diff --git a/apps/cli/src/http/routes/web/cache.test.ts b/apps/cli/src/http/routes/web/cache.test.ts new file mode 100644 index 00000000..48534811 --- /dev/null +++ b/apps/cli/src/http/routes/web/cache.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { openDirectoryPath, showPathInFolder } from './cache' + +describe('CLI Web cache route helpers', () => { + it('opens directories with execFile arguments instead of shell commands', async () => { + const calls: Array<{ file: string; args: string[] }> = [] + const dirPath = '/tmp/chat"lab; touch /tmp/pwned' + + await openDirectoryPath(dirPath, 'darwin', (file, args, callback) => { + calls.push({ file, args }) + callback(null) + }) + + assert.deepEqual(calls, [{ file: 'open', args: [dirPath] }]) + }) + + it('reveals files with execFile arguments instead of shell commands', async () => { + const calls: Array<{ file: string; args: string[] }> = [] + const filePath = '/tmp/chat"lab; touch /tmp/pwned/export.json' + + await showPathInFolder(filePath, 'darwin', (file, args, callback) => { + calls.push({ file, args }) + callback(null) + }) + + assert.deepEqual(calls, [{ file: 'open', args: ['-R', filePath] }]) + }) +}) diff --git a/apps/cli/src/http/routes/web/cache.ts b/apps/cli/src/http/routes/web/cache.ts index cffe81d2..e993f765 100644 --- a/apps/cli/src/http/routes/web/cache.ts +++ b/apps/cli/src/http/routes/web/cache.ts @@ -1,10 +1,53 @@ import * as fs from 'fs' import * as fsp from 'fs/promises' import * as path from 'path' +import { execFile } from 'child_process' import type { FastifyInstance } from 'fastify' import type { PathProvider } from '@openchatlab/core' import { loadConfig } from '@openchatlab/config' +type ExecFileRunner = (file: string, args: string[], callback: (error: Error | null) => void) => unknown + +function runExecFile(file: string, args: string[], runner: ExecFileRunner = execFile): Promise { + return new Promise((resolve, reject) => { + runner(file, args, (error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) +} + +export async function openDirectoryPath( + dirPath: string, + platform: NodeJS.Platform = process.platform, + runner?: ExecFileRunner +): Promise { + if (platform === 'darwin') { + await runExecFile('open', [dirPath], runner) + } else if (platform === 'win32') { + await runExecFile('explorer.exe', [dirPath], runner) + } else { + await runExecFile('xdg-open', [dirPath], runner) + } +} + +export async function showPathInFolder( + filePath: string, + platform: NodeJS.Platform = process.platform, + runner?: ExecFileRunner +): Promise { + if (platform === 'darwin') { + await runExecFile('open', ['-R', filePath], runner) + } else if (platform === 'win32') { + await runExecFile('explorer.exe', [`/select,${filePath}`], runner) + } else { + await runExecFile('xdg-open', [path.dirname(filePath)], runner) + } +} + async function getDirSize(dirPath: string): Promise { let totalSize = 0 try { @@ -142,14 +185,10 @@ export function registerCacheRoutes(server: FastifyInstance, pathProvider: PathP await fsp.mkdir(dirPath, { recursive: true }) } - const { exec } = await import('child_process') - const platform = process.platform - if (platform === 'darwin') { - exec(`open "${dirPath}"`) - } else if (platform === 'win32') { - exec(`explorer "${dirPath}"`) - } else { - exec(`xdg-open "${dirPath}"`) + try { + await openDirectoryPath(dirPath) + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } } return { success: true } }) @@ -216,16 +255,10 @@ export function registerCacheRoutes(server: FastifyInstance, pathProvider: PathP return { success: false, error: 'filePath is required' } } - const { exec } = await import('child_process') - const platform = process.platform - const dir = path.dirname(filePath) - - if (platform === 'darwin') { - exec(`open -R "${filePath}"`) - } else if (platform === 'win32') { - exec(`explorer /select,"${filePath}"`) - } else { - exec(`xdg-open "${dir}"`) + try { + await showPathInFolder(filePath) + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } } return { success: true } diff --git a/apps/cli/src/http/routes/web/index.ts b/apps/cli/src/http/routes/web/index.ts index f566f8a2..e51328e4 100644 --- a/apps/cli/src/http/routes/web/index.ts +++ b/apps/cli/src/http/routes/web/index.ts @@ -53,6 +53,7 @@ function isNewerVersion(latest: string, current: string): boolean { if (l.minor !== c.minor) return l.minor > c.minor if (l.patch !== c.patch) return l.patch > c.patch // Same core version: stable > pre-release + // Design note: prerelease-to-prerelease updates are intentionally not surfaced. if (c.pre && !l.pre) return true return false } diff --git a/apps/cli/src/update-checker.ts b/apps/cli/src/update-checker.ts index c26651c0..07399fee 100644 --- a/apps/cli/src/update-checker.ts +++ b/apps/cli/src/update-checker.ts @@ -55,6 +55,8 @@ function isNewerVersion(latest: string, current: string): boolean { if (l.major !== c.major) return l.major > c.major if (l.minor !== c.minor) return l.minor > c.minor if (l.patch !== c.patch) return l.patch > c.patch + // Design note: prerelease users are not prompted for prerelease-to-prerelease updates. + // Only a stable release with the same core version should supersede a prerelease build. if (c.pre && !l.pre) return true return false }