diff --git a/docs/ccs-bar.md b/docs/ccs-bar.md index 1e225cae..0cc48fb6 100644 --- a/docs/ccs-bar.md +++ b/docs/ccs-bar.md @@ -31,16 +31,27 @@ ccs bar install This downloads `CCS-Bar.app.zip` from the floating `ccs-bar-latest` GitHub release and installs `CCS Bar.app` into `~/Applications`. Downloads are restricted to `github.com` and `objects.githubusercontent.com`, and extraction is guarded against zip-slip. +If `CCS Bar.app` is already installed, the command shows the current version and proceeds as a reinstall. + After installation, CCS reads the app version directly from the bundle's `Info.plist` and pins it to `~/.ccs/bar/.version`. It then performs a reachability check against the bar API (`GET /api/bar/summary`). A 404 response means the running CCS server predates CCS Bar support — update CCS to a version that includes CCS Bar, then restart `ccs bar`. +After a successful install, CCS asks whether to launch CCS Bar immediately (default: yes). Pass `--launch` to skip the prompt and launch right away, or `--no-launch` to suppress the prompt entirely: + +```bash +ccs bar install --launch # install and launch immediately +ccs bar install --no-launch # install, skip launch prompt +``` + ### Gatekeeper note -The v1 builds use ad-hoc signing, so the first launch may be blocked by Gatekeeper. Either right-click the app and choose Open, or clear the quarantine attribute: +The install command automatically clears the macOS Gatekeeper quarantine attribute (`xattr -dr com.apple.quarantine`) on the downloaded app. If clearing fails for any reason, the command falls back to printing the manual command: ```bash xattr -dr com.apple.quarantine "$HOME/Applications/CCS Bar.app" ``` +Or right-click the app and choose Open. + ## Launch ```bash diff --git a/src/commands/bar/help-subcommand.ts b/src/commands/bar/help-subcommand.ts index 139dd234..5d9c37dd 100644 --- a/src/commands/bar/help-subcommand.ts +++ b/src/commands/bar/help-subcommand.ts @@ -26,11 +26,20 @@ export async function showHelp(): Promise { ['--version', 'Show CLI and installed app versions'], ], ], + [ + 'Install options:', + [ + ['--launch', 'Launch CCS Bar immediately after install without prompting'], + ['--no-launch', 'Skip the launch prompt after install'], + ], + ], [ 'Examples:', [ ['ccs bar', 'Start the web-server and open CCS Bar (default launch)'], - ['ccs bar install', 'Download and install CCS Bar into ~/Applications'], + ['ccs bar install', 'Download and install CCS Bar, then prompt to launch'], + ['ccs bar install --launch', 'Install and launch immediately (no prompt)'], + ['ccs bar install --no-launch', 'Install without launching'], ['ccs bar version', 'Show CLI and installed app versions'], ['ccs bar uninstall', 'Remove CCS Bar and its version pin'], ], @@ -49,8 +58,9 @@ export async function showHelp(): Promise { console.log(dim(' macOS only. The app communicates with the CCS web-server on localhost only.')); console.log( dim( - ' Ad-hoc signed builds may require right-click > Open or `xattr -dr com.apple.quarantine` on first launch.' + ' Gatekeeper quarantine is cleared automatically after install. If the app is still blocked,' ) ); + console.log(dim(' right-click > Open or run `xattr -dr com.apple.quarantine` manually.')); console.log(''); } diff --git a/src/commands/bar/install-subcommand.ts b/src/commands/bar/install-subcommand.ts index c3fd5a46..af5b2670 100644 --- a/src/commands/bar/install-subcommand.ts +++ b/src/commands/bar/install-subcommand.ts @@ -19,6 +19,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { getCcsDir } from '../../config/config-loader-facade'; +import { hasAnyFlag } from '../arg-extractor'; // --------------------------------------------------------------------------- // Constants @@ -84,6 +85,39 @@ export interface InstallDeps { getCcsDir: () => string; /** Destination directory for the .app bundle (~/Applications by default). */ getAppsDir: () => string; + /** + * Clear the macOS Gatekeeper quarantine attribute from the installed app. + * Run via `/usr/bin/xattr -dr com.apple.quarantine ` (execFile, not shell-string). + * Returns true on success, false if xattr is unavailable or the call fails (non-fatal). + * Injectable for tests — avoids touching the real system. + */ + clearQuarantine: (appPath: string) => Promise; + /** + * Invoke handleBarLaunch after a successful install when the user consents. + * Injectable so tests can assert invocation without starting a real server. + */ + launchBar: (args: string[]) => Promise; + /** + * Ask the user whether to launch CCS Bar now (stdin-TTY-only). + * Returns true on yes/default, false on no or non-TTY stdin. + * Injectable for tests. + */ + promptLaunch: () => Promise; + /** + * Detect whether CCS Bar is already running. + * Production: uses /usr/bin/pgrep -x CCSBar (exit 0 = running, exit 1 = not found). + * Returns false on any error (treat pgrep failure as not running). + * Injectable for tests — avoids touching the real process table. + */ + isBarRunning: () => Promise; + /** + * Remove an existing app bundle before extraction so a malformed archive + * cannot silently leave the old version in place. + * Production: fs.rmSync(appPath, { recursive: true, force: true }). + * Injectable for tests — avoids real filesystem side-effects. + * Throws on failure; caller catches and aborts install. + */ + removeExistingApp: (appPath: string) => void; } // --------------------------------------------------------------------------- @@ -341,6 +375,79 @@ function defaultGetAppsDir(): string { return path.join(os.homedir(), 'Applications'); } +/** + * Clear the macOS Gatekeeper quarantine attribute from the installed app. + * Uses absolute path `/usr/bin/xattr` to avoid PATH hijacking. + * Runs `/usr/bin/xattr -dr com.apple.quarantine ` via execFile (not shell-string) + * so the path is passed as an argument, not interpolated into a shell command. + * Returns true on success, false on any error (non-fatal — install already succeeded). + */ +async function defaultClearQuarantine(appPath: string): Promise { + try { + const { execFile } = await import('child_process'); + const { promisify } = await import('util'); + const execFileAsync = promisify(execFile); + await execFileAsync('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', appPath]); + return true; + } catch { + return false; + } +} + +function defaultRemoveExistingApp(appPath: string): void { + fs.rmSync(appPath, { recursive: true, force: true }); +} + +async function defaultLaunchBar(args: string[]): Promise { + const { handleBarLaunch } = await import('./launch-subcommand'); + await handleBarLaunch(args); +} + +/** + * Detect whether CCS Bar is already running using `/usr/bin/pgrep -x CCSBar`. + * pgrep exits 0 when a match is found (running), 1 when no match (not running). + * execFile surfaces exit code 1 as an error, so we catch and return false. + * Any other error (pgrep unavailable, permission denied) is also treated as not running. + */ +async function defaultIsBarRunning(): Promise { + try { + const { execFile } = await import('child_process'); + const { promisify } = await import('util'); + const execFileAsync = promisify(execFile); + await execFileAsync('/usr/bin/pgrep', ['-x', 'CCSBar']); + return true; + } catch { + // pgrep exits 1 when no match found; execFile throws for non-zero exit codes. + return false; + } +} + +/** + * Ask the user whether to launch CCS Bar now. + * Only prompts when stdin is a TTY — stdin is what the prompt actually reads. + * Non-TTY stdin: return false (caller prints guidance). + * Default answer is yes (Enter = launch). + */ +async function defaultPromptLaunch(): Promise { + if (!process.stdin.isTTY) { + return false; + } + const readline = await import('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + }); + return new Promise((resolve) => { + rl.question('Launch CCS Bar now? [Y/n] ', (answer: string) => { + rl.close(); + const normalized = answer.trim().toLowerCase(); + // Empty (Enter) or 'y'/'yes' → launch + resolve(normalized === '' || normalized === 'y' || normalized === 'yes'); + }); + }); +} + // --------------------------------------------------------------------------- // Version pin helpers // --------------------------------------------------------------------------- @@ -360,17 +467,37 @@ function pinBarVersion(ccsDir: string, version: string): void { // --------------------------------------------------------------------------- export async function handleBarInstall( - _args: string[], + args: string[], deps: Partial = {} ): Promise { + // Parse --launch / --no-launch flags before delegating to deps. + const forceLaunch = hasAnyFlag(args, ['--launch']); + const noLaunch = hasAnyFlag(args, ['--no-launch']); + const fetchReleaseAsset = deps.fetchReleaseAsset ?? defaultFetchReleaseAsset; const downloadAndExtract = deps.downloadAndExtract ?? defaultDownloadAndExtract; const verifyCompat = deps.verifyCompat ?? defaultVerifyCompat; const readAppBundleVersion = deps.readAppBundleVersion ?? defaultReadAppBundleVersion; + const clearQuarantine = deps.clearQuarantine ?? defaultClearQuarantine; + const launchBar = deps.launchBar ?? defaultLaunchBar; + const promptLaunch = deps.promptLaunch ?? defaultPromptLaunch; + const isBarRunning = deps.isBarRunning ?? defaultIsBarRunning; + const removeExistingApp = deps.removeExistingApp ?? defaultRemoveExistingApp; const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)(); const appsDir = (deps.getAppsDir ?? defaultGetAppsDir)(); - console.log('[i] Fetching CCS Bar release info...'); + // 0. Already-installed detection: show the current version before proceeding. + const appPath = path.join(appsDir, BAR_APP_NAME); + if (fs.existsSync(appPath)) { + const existingVersion = readAppBundleVersion(appPath); + if (existingVersion !== null) { + console.log(`[i] CCS Bar v${existingVersion} is already installed. Reinstalling...`); + } else { + console.log('[i] CCS Bar is already installed. Reinstalling...'); + } + } else { + console.log('[i] Fetching CCS Bar release info...'); + } // 1. Resolve the floating tag → download URL. let releaseInfo: ReleaseAssetResult; @@ -386,32 +513,83 @@ export async function handleBarInstall( const { downloadUrl } = releaseInfo; console.log(`[i] Installing CCS Bar from ${BAR_RELEASE_TAG}...`); - // 2. Download and extract into ~/Applications. + // 1b. Stage-then-swap: extract into a hidden staging dir on the same filesystem + // so the rename to appPath is atomic-ish. The old bundle is only removed + // AFTER the new one is verified in staging — a transient download/extraction + // failure during reinstall therefore leaves the previous working install intact. + fs.mkdirSync(appsDir, { recursive: true }); + let stagingDir: string; try { - await downloadAndExtract(downloadUrl, appsDir); + stagingDir = fs.mkdtempSync(path.join(appsDir, '.ccs-bar-staging-')); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[X] Could not create staging directory in ${appsDir}: ${msg}`); + return; + } + + // Helper: remove staging dir, ignoring errors (best-effort cleanup). + function cleanupStaging(): void { + try { + fs.rmSync(stagingDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } + + // 2. Download and extract into staging, NOT appsDir. + try { + await downloadAndExtract(downloadUrl, stagingDir); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[X] Download or extraction failed: ${msg}`); + cleanupStaging(); return; } // 3. Finding #12: assert that the expected .app bundle was actually extracted - // before reporting success. - const appPath = path.join(appsDir, BAR_APP_NAME); - if (!fs.existsSync(appPath)) { + // into the staging dir before touching the old install. + const stagedApp = path.join(stagingDir, BAR_APP_NAME); + if (!fs.existsSync(stagedApp)) { // Report what was actually extracted to help diagnose archive issues. let extracted: string[] = []; try { - extracted = fs.readdirSync(appsDir); + extracted = fs.readdirSync(stagingDir); } catch { /* ignore */ } const found = extracted.length > 0 ? extracted.join(', ') : '(none)'; - console.error(`[X] Extraction succeeded but "${BAR_APP_NAME}" was not found in ${appsDir}.`); - console.error(`[i] Files found in ${appsDir}: ${found}`); + console.error(`[X] Extraction succeeded but "${BAR_APP_NAME}" was not found in staging.`); + console.error(`[i] Files found in staging: ${found}`); + cleanupStaging(); return; } + // 3b. New bundle verified in staging — now safe to remove the old install. + if (fs.existsSync(appPath)) { + try { + removeExistingApp(appPath); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[X] Could not remove the existing CCS Bar.app: ${msg}`); + console.error('[i] Close any running instance and try again.'); + cleanupStaging(); + return; + } + } + + // 3c. Atomic-ish rename: move staged bundle into the final location. + try { + fs.renameSync(stagedApp, appPath); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[X] Could not move staged app into place: ${msg}`); + cleanupStaging(); + return; + } + + // Staging dir is now empty (bundle was moved out); remove it. + cleanupStaging(); + // 4. Read the real version from the extracted bundle's Info.plist. const installedVersion = readAppBundleVersion(appPath); @@ -439,6 +617,9 @@ export async function handleBarInstall( } // 5. Capability handshake via GET /api/bar/summary. + // Runs BEFORE the quarantine-clear + launch handoff because it is a server-side + // check unrelated to Gatekeeper. A failed quarantine clear must not prevent the + // compat result from being shown to the user. // Read bar.json for baseUrl if present; otherwise fall back to localhost:3000. const barJsonPath = path.join(ccsDir, 'bar.json'); let baseUrl = 'http://127.0.0.1:3000'; @@ -469,10 +650,51 @@ export async function handleBarInstall( console.log('[i] Run `ccs bar` to start the server and recheck.'); } - // 6. Print Gatekeeper guidance for ad-hoc/unsigned builds. - console.log(''); - console.log('[i] Gatekeeper note (ad-hoc build):'); - console.log(' If macOS says the app is "damaged" or "unverified", run:'); - console.log(` xattr -dr com.apple.quarantine "${path.join(appsDir, BAR_APP_NAME)}"`); - console.log(' Or right-click the app and select Open.'); + // 6. Quarantine handling: run `xattr -dr com.apple.quarantine` automatically. + // This clears the Gatekeeper quarantine flag that ad-hoc builds receive on download. + // On success: print [OK] confirmation. On failure: fall back to printed guidance and + // SKIP the launch handoff entirely — launching a still-quarantined app hits the + // Gatekeeper block, so the user must clear quarantine manually first. + const quarantineCleared = await clearQuarantine(appPath); + if (quarantineCleared) { + console.log('[OK] Cleared Gatekeeper quarantine.'); + } else { + console.log('[i] Gatekeeper note (ad-hoc build):'); + console.log(' If macOS says the app is "damaged" or "unverified", run:'); + console.log(` xattr -dr com.apple.quarantine "${appPath}"`); + console.log(' Or right-click the app and select Open.'); + console.log('[i] After clearing quarantine, run `ccs bar` to launch.'); + return; + } + + // 7. Launch handoff. + // Already-running check: if CCS Bar is running after a (re)install, skip the + // prompt entirely and print a restart hint (the user needs to quit and reopen + // to pick up the new version). --launch with the app already running still + // proceeds — the launch flow opens/activates the app, which is harmless. + // + // --launch: skip prompt and launch immediately. + // --no-launch: skip prompt, print guidance. + // stdin-TTY: ask interactively (default yes). + // Non-TTY stdin: print guidance, no prompt. + const barIsRunning = await isBarRunning(); + + if (barIsRunning && !forceLaunch) { + console.log( + '[!] CCS Bar is currently running the previous version. Quit and reopen it (or run `ccs bar`) to use the update.' + ); + } else if (forceLaunch) { + await launchBar([]); + } else if (noLaunch) { + console.log('[i] Run `ccs bar` to launch.'); + } else { + const shouldLaunch = await promptLaunch(); + if (shouldLaunch) { + await launchBar([]); + } else { + // User declined or non-TTY stdin — always print guidance so the user + // knows how to start the app after install. + console.log('[i] Run `ccs bar` to launch later.'); + } + } } diff --git a/tests/unit/commands/bar-command.test.ts b/tests/unit/commands/bar-command.test.ts index 6c51d32e..4ad796f7 100644 --- a/tests/unit/commands/bar-command.test.ts +++ b/tests/unit/commands/bar-command.test.ts @@ -415,6 +415,9 @@ describe('bar install subcommand', () => { downloadAndExtract: fakeExtract(appsDir), verifyCompat: async (_baseUrl: string) => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: (_appPath: string) => FAKE_VERSION, + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -436,6 +439,9 @@ describe('bar install subcommand', () => { downloadAndExtract: fakeExtract(appsDir), verifyCompat: async () => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: (_appPath: string) => FAKE_VERSION, + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, getCcsDir: () => ccsDir, getAppsDir: () => appsDir, }); @@ -459,6 +465,9 @@ describe('bar install subcommand', () => { return { compatible: true, reason: 'ok' }; }, readAppBundleVersion: (_appPath: string) => FAKE_VERSION, + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -476,6 +485,9 @@ describe('bar install subcommand', () => { downloadAndExtract: fakeExtract(appsDir), verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }), readAppBundleVersion: (_appPath: string) => FAKE_VERSION, + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }) @@ -489,11 +501,14 @@ describe('bar install subcommand', () => { const appsDir = path.join(tempHome, 'Applications'); const { handleBarInstall } = await loadInstallSubcommand(); + // Inject clearQuarantine returning false so the fallback xattr guidance is + // always printed regardless of host platform (/usr/bin/xattr availability). await handleBarInstall([], { fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), downloadAndExtract: fakeExtract(appsDir), verifyCompat: async () => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: (_appPath: string) => FAKE_VERSION, + clearQuarantine: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -562,6 +577,9 @@ describe('bar install: redirect-following download (#8)', () => { downloadAndExtract: redirectFollowingExtract, verifyCompat: async () => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: (_appPath: string) => FAKE_VERSION, + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -731,6 +749,9 @@ describe('bar install: compat capability handshake', () => { return { compatible: true, reason: 'ok' }; }, readAppBundleVersion: (_appPath: string) => '1.4.0', + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -749,6 +770,9 @@ describe('bar install: compat capability handshake', () => { downloadAndExtract: fakeExtract(appsDir), verifyCompat: async () => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: (_appPath: string) => '1.4.0', + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -768,6 +792,9 @@ describe('bar install: compat capability handshake', () => { downloadAndExtract: fakeExtract(appsDir), verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }), readAppBundleVersion: (_appPath: string) => '1.4.0', + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -788,6 +815,9 @@ describe('bar install: compat capability handshake', () => { downloadAndExtract: fakeExtract(appsDir), verifyCompat: async () => ({ compatible: false, reason: 'unreachable' }), readAppBundleVersion: (_appPath: string) => '1.4.0', + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -810,6 +840,9 @@ describe('bar install: compat capability handshake', () => { throw new Error('network explosion'); }, readAppBundleVersion: (_appPath: string) => '1.4.0', + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }) @@ -839,6 +872,9 @@ describe('bar install: post-extract app-exists assertion (#12)', () => { }, verifyCompat: async () => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: (_appPath: string) => '1.0.0', + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -922,6 +958,9 @@ describe('bar install: Info.plist version extraction regression tests', () => { verifyCompat: async () => ({ compatible: true, reason: 'ok' }), // readAppBundleVersion returns the real version from Info.plist readAppBundleVersion: (_appPath: string) => '1.4.0', + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -944,6 +983,9 @@ describe('bar install: Info.plist version extraction regression tests', () => { downloadAndExtract: fakeExtract(appsDir), verifyCompat: async () => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: (_appPath: string) => '1.4.0', + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, getCcsDir: () => ccsDir, getAppsDir: () => appsDir, }); @@ -968,6 +1010,9 @@ describe('bar install: Info.plist version extraction regression tests', () => { verifyCompat: async () => ({ compatible: true, reason: 'ok' }), // Unreadable Info.plist — returns null readAppBundleVersion: (_appPath: string) => null, + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, getCcsDir: () => ccsDir, getAppsDir: () => appsDir, }); @@ -1014,17 +1059,39 @@ describe('bar install: Info.plist version extraction regression tests', () => { const { handleBarInstall } = await loadInstallSubcommand(); - // Use production readAppBundleVersion (omit from deps so default is used) + // Use production readAppBundleVersion (omit from deps so default is used). + // downloadAndExtract must recreate the bundle because the stale-reinstall guard + // removes the pre-seeded app before extraction. await handleBarInstall([], { fetchReleaseAsset: async () => ({ downloadUrl: 'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip', }), - downloadAndExtract: async (_url: string, _dest: string) => { - // App bundle already created above — no actual download needed + downloadAndExtract: async (_url: string, dest: string) => { + // Recreate the bundle with its Info.plist fixture after the old bundle is removed. + const contentsOut = path.join(dest, 'CCS Bar.app', 'Contents'); + fs.mkdirSync(contentsOut, { recursive: true }); + fs.writeFileSync( + path.join(contentsOut, 'Info.plist'), + ` + + + + CFBundleIdentifier + com.kaitranntt.CCSBar + CFBundleShortVersionString + 2.7.1 + CFBundleVersion + 271 + +` + ); }, verifyCompat: async () => ({ compatible: true, reason: 'ok' }), // readAppBundleVersion intentionally omitted → uses production default + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, getCcsDir: () => ccsDir, getAppsDir: () => appsDir, }); @@ -1047,16 +1114,22 @@ describe('bar install: Info.plist version extraction regression tests', () => { const { handleBarInstall } = await loadInstallSubcommand(); + // downloadAndExtract must recreate the bundle (without Info.plist) after the + // stale-reinstall guard removes the pre-seeded app. await handleBarInstall([], { fetchReleaseAsset: async () => ({ downloadUrl: 'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip', }), - downloadAndExtract: async (_url: string, _dest: string) => { - // App bundle already created — no plist inside + downloadAndExtract: async (_url: string, dest: string) => { + // Recreate bare bundle — no Info.plist, matching the test's intent. + fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); }, verifyCompat: async () => ({ compatible: true, reason: 'ok' }), // readAppBundleVersion omitted → uses production default + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, getCcsDir: () => ccsDir, getAppsDir: () => appsDir, }); @@ -1394,6 +1467,9 @@ describe('bar install: zip-slip guard (fix #14)', () => { downloadAndExtract: safeExtract, verifyCompat: async () => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: (_appPath: string) => FAKE_VERSION, + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -1435,6 +1511,9 @@ describe('bar install: stale version-pin removal on null plist read (Fix 1)', () verifyCompat: async () => ({ compatible: true, reason: 'ok' }), // Simulate unreadable Info.plist readAppBundleVersion: (_appPath: string) => null, + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, getCcsDir: () => ccsDir, getAppsDir: () => appsDir, }); @@ -1894,6 +1973,730 @@ describe('defaultFindRunningServer: priority over response speed (GH-1500)', () }); }); +// --------------------------------------------------------------------------- +// GH-1504 — already-installed detection +// --------------------------------------------------------------------------- + +describe('bar install: already-installed detection (GH-1504)', () => { + const FAKE_DOWNLOAD_URL = + 'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip'; + + it('shows existing version when app is already installed', async () => { + const appsDir = path.join(tempHome, 'Applications'); + // Create a pre-existing CCS Bar.app with a known version + const existingAppPath = path.join(appsDir, 'CCS Bar.app'); + fs.mkdirSync(path.join(existingAppPath, 'Contents'), { recursive: true }); + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + downloadAndExtract: async (_url: string, dest: string) => { + fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); + }, + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: (appPath: string) => { + // Return version for pre-existing app (before download replaces it) + if (fs.existsSync(appPath)) return '1.0.0'; + return null; + }, + clearQuarantine: async () => true, + launchBar: async () => { + calls.push('launch'); + }, + promptLaunch: async () => false, + getCcsDir: () => path.join(tempHome, '.ccs'), + getAppsDir: () => appsDir, + }); + + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/already installed|Reinstall/i); + expect(allOutput).toMatch(/1\.0\.0/); + }); + + it('shows generic already-installed message when version is unreadable', async () => { + const appsDir = path.join(tempHome, 'Applications'); + const existingAppPath = path.join(appsDir, 'CCS Bar.app'); + fs.mkdirSync(existingAppPath, { recursive: true }); + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + downloadAndExtract: async (_url: string, dest: string) => { + fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); + }, + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: () => null, + clearQuarantine: async () => true, + launchBar: async () => { + calls.push('launch'); + }, + promptLaunch: async () => false, + getCcsDir: () => path.join(tempHome, '.ccs'), + getAppsDir: () => appsDir, + }); + + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/already installed|Reinstall/i); + }); + + it('does not print already-installed message on fresh install', async () => { + const appsDir = path.join(tempHome, 'Applications'); + // Do NOT pre-create the app + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + downloadAndExtract: async (_url: string, dest: string) => { + fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); + }, + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: () => '2.0.0', + clearQuarantine: async () => true, + launchBar: async () => { + calls.push('launch'); + }, + promptLaunch: async () => false, + getCcsDir: () => path.join(tempHome, '.ccs'), + getAppsDir: () => appsDir, + }); + + const allOutput = consoleOutput.join('\n'); + expect(allOutput).not.toMatch(/already installed/i); + }); +}); + +// --------------------------------------------------------------------------- +// GH-1504 — quarantine handling +// --------------------------------------------------------------------------- + +describe('bar install: quarantine handling (GH-1504)', () => { + const FAKE_DOWNLOAD_URL = + 'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip'; + + function fakeExtract(appsDir: string) { + return async (_url: string, dest: string) => { + fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); + }; + } + + it('calls clearQuarantine with the correct app path after successful extraction', async () => { + const appsDir = path.join(tempHome, 'Applications'); + const quarantineCalls: string[] = []; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + downloadAndExtract: fakeExtract(appsDir), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: () => '1.0.0', + clearQuarantine: async (appPath: string) => { + quarantineCalls.push(appPath); + return true; + }, + launchBar: async () => { + /* noop */ + }, + promptLaunch: async () => false, + getCcsDir: () => path.join(tempHome, '.ccs'), + getAppsDir: () => appsDir, + }); + + expect(quarantineCalls).toHaveLength(1); + expect(quarantineCalls[0]).toMatch(/CCS Bar\.app/); + expect(quarantineCalls[0]).toBe(path.join(appsDir, 'CCS Bar.app')); + + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/\[OK\].*[Cc]leared.*[Qq]uarantine/); + }); + + it('quarantine failure is non-fatal: falls back to printed xattr hint; launch handoff skipped', async () => { + // Launch Retry finding: when clearQuarantine fails, the entire launch handoff must be + // skipped (prompt and launchBar must NOT be called) and the follow-up hint must be printed. + const appsDir = path.join(tempHome, 'Applications'); + let launchCalled = false; + let promptCalled = false; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + downloadAndExtract: fakeExtract(appsDir), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: () => '1.0.0', + clearQuarantine: async () => false, + launchBar: async () => { + launchCalled = true; + }, + promptLaunch: async () => { + promptCalled = true; + return false; + }, + getCcsDir: () => path.join(tempHome, '.ccs'), + getAppsDir: () => appsDir, + }); + + // Install still reports success + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/\[OK\].*CCS Bar/); + expect(allOutput).not.toMatch(/\[X\]/); + + // Fallback xattr hint printed + expect(allOutput).toMatch(/xattr.*quarantine/i); + + // Launch Retry finding: prompt and launch must both be skipped + expect(promptCalled).toBe(false); + expect(launchCalled).toBe(false); + + // Follow-up hint must guide the user to run `ccs bar` after manual clear + expect(allOutput).toMatch(/After clearing quarantine.*ccs bar/i); + }); + + it('clearQuarantine is NOT called when extraction fails (app path absent)', async () => { + const appsDir = path.join(tempHome, 'Applications'); + const quarantineCalls: string[] = []; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + // Extraction does NOT place CCS Bar.app + downloadAndExtract: async (_url: string, dest: string) => { + fs.mkdirSync(dest, { recursive: true }); + }, + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: () => '1.0.0', + clearQuarantine: async (appPath: string) => { + quarantineCalls.push(appPath); + return true; + }, + launchBar: async () => { + /* noop */ + }, + promptLaunch: async () => false, + getCcsDir: () => path.join(tempHome, '.ccs'), + getAppsDir: () => appsDir, + }); + + // Should have returned early due to missing app + expect(quarantineCalls).toHaveLength(0); + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/\[X\]/); + }); +}); + +// --------------------------------------------------------------------------- +// Launch Retry finding — quarantine failure blocks entire launch handoff +// --------------------------------------------------------------------------- + +describe('bar install: launch retry finding — quarantine failure skips launch handoff', () => { + const FAKE_DOWNLOAD_URL = + 'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip'; + + function fakeExtract(appsDir: string) { + return async (_url: string, dest: string) => { + fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); + }; + } + + function baseDeps(appsDir: string, extra?: Partial>) { + return { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + downloadAndExtract: fakeExtract(appsDir), + verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }), + readAppBundleVersion: () => '1.0.0', + isBarRunning: async () => false, + getCcsDir: () => path.join(tempHome, '.ccs'), + getAppsDir: () => appsDir, + ...extra, + }; + } + + it('clearQuarantine false: promptLaunch is NOT called', async () => { + const appsDir = path.join(tempHome, 'Applications'); + let promptCalled = false; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + clearQuarantine: async () => false, + launchBar: async () => { /* noop */ }, + promptLaunch: async () => { + promptCalled = true; + return true; + }, + }); + + expect(promptCalled).toBe(false); + }); + + it('clearQuarantine false + --launch: launchBar is NOT called (explicit flag cannot override failed clear)', async () => { + // --launch does not bypass a failed quarantine clear — Gatekeeper would still block. + const appsDir = path.join(tempHome, 'Applications'); + let launchCalled = false; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall(['--launch'], { + ...baseDeps(appsDir), + clearQuarantine: async () => false, + launchBar: async () => { + launchCalled = true; + }, + promptLaunch: async () => false, + }); + + expect(launchCalled).toBe(false); + }); + + it('clearQuarantine false: follow-up hint printed directing user to run `ccs bar` after manual clear', async () => { + const appsDir = path.join(tempHome, 'Applications'); + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + clearQuarantine: async () => false, + launchBar: async () => { /* noop */ }, + promptLaunch: async () => false, + }); + + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/After clearing quarantine.*ccs bar/i); + }); + + it('clearQuarantine true: launch handoff proceeds normally (prompt called)', async () => { + // Successful clear must not change existing behavior — prompt is still called. + const appsDir = path.join(tempHome, 'Applications'); + let promptCalled = false; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + clearQuarantine: async () => true, + launchBar: async () => { /* noop */ }, + promptLaunch: async () => { + promptCalled = true; + return false; + }, + }); + + expect(promptCalled).toBe(true); + }); + + it('clearQuarantine true + --launch: launchBar invoked as before', async () => { + const appsDir = path.join(tempHome, 'Applications'); + let launchCalled = false; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall(['--launch'], { + ...baseDeps(appsDir), + clearQuarantine: async () => true, + launchBar: async () => { + launchCalled = true; + }, + promptLaunch: async () => false, + }); + + expect(launchCalled).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// GH-1504 — launch flags and prompt behavior +// --------------------------------------------------------------------------- + +describe('bar install: launch flags and prompt (GH-1504)', () => { + const FAKE_DOWNLOAD_URL = + 'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip'; + + function fakeExtract(appsDir: string) { + return async (_url: string, dest: string) => { + fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); + }; + } + + function baseDeps(appsDir: string, extra?: Partial>) { + return { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + downloadAndExtract: fakeExtract(appsDir), + verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }), + readAppBundleVersion: () => '1.0.0', + clearQuarantine: async () => true, + // isBarRunning injected as false so tests are deterministic regardless of + // whether the real CCS Bar process is running on the test machine. + isBarRunning: async () => false, + getCcsDir: () => path.join(tempHome, '.ccs'), + getAppsDir: () => appsDir, + ...extra, + }; + } + + it('--no-launch skips prompt and does not invoke launchBar', async () => { + const appsDir = path.join(tempHome, 'Applications'); + let launchCalled = false; + let promptCalled = false; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall(['--no-launch'], { + ...baseDeps(appsDir), + launchBar: async () => { + launchCalled = true; + }, + promptLaunch: async () => { + promptCalled = true; + return false; + }, + }); + + expect(launchCalled).toBe(false); + expect(promptCalled).toBe(false); + + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/Run `ccs bar` to launch/i); + }); + + it('--launch invokes launchBar without prompting', async () => { + const appsDir = path.join(tempHome, 'Applications'); + let launchCalled = false; + let promptCalled = false; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall(['--launch'], { + ...baseDeps(appsDir), + launchBar: async () => { + launchCalled = true; + }, + promptLaunch: async () => { + promptCalled = true; + return false; + }, + }); + + expect(launchCalled).toBe(true); + expect(promptCalled).toBe(false); + }); + + it('non-TTY skips prompt and prints launch guidance', async () => { + const appsDir = path.join(tempHome, 'Applications'); + let launchCalled = false; + + const { handleBarInstall } = await loadInstallSubcommand(); + + // Simulate non-TTY: promptLaunch returns false (mimics defaultPromptLaunch non-TTY path) + await handleBarInstall([], { + ...baseDeps(appsDir), + launchBar: async () => { + launchCalled = true; + }, + promptLaunch: async () => false, + }); + + expect(launchCalled).toBe(false); + }); + + it('user answers yes to prompt: launchBar is invoked', async () => { + const appsDir = path.join(tempHome, 'Applications'); + let launchCalled = false; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + launchBar: async () => { + launchCalled = true; + }, + promptLaunch: async () => true, + }); + + expect(launchCalled).toBe(true); + }); + + it('user answers no to prompt: launchBar not invoked', async () => { + const appsDir = path.join(tempHome, 'Applications'); + let launchCalled = false; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + launchBar: async () => { + launchCalled = true; + }, + promptLaunch: async () => false, + }); + + expect(launchCalled).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Finding 1 — PATH hijack: xattr must use absolute path /usr/bin/xattr +// (The default impl is internal; tested via injectable clearQuarantine seam. +// The production behavior is captured by the contract test below.) +// --------------------------------------------------------------------------- + +describe('bar install: xattr absolute path contract (Finding 1)', () => { + const FAKE_DOWNLOAD_URL = + 'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip'; + + function fakeExtract(appsDir: string) { + return async (_url: string, dest: string) => { + fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); + }; + } + + it('clearQuarantine injectable dep receives the correct app path (contract test)', async () => { + const appsDir = path.join(tempHome, 'Applications'); + const quarantineArgs: string[] = []; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + downloadAndExtract: fakeExtract(appsDir), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: () => '1.0.0', + clearQuarantine: async (appPath: string) => { + quarantineArgs.push(appPath); + return true; + }, + launchBar: async () => { /* noop */ }, + promptLaunch: async () => false, + isBarRunning: async () => false, + getCcsDir: () => path.join(tempHome, '.ccs'), + getAppsDir: () => appsDir, + }); + + // Production invokes clearQuarantine with the full app path — not a bare binary name. + expect(quarantineArgs).toHaveLength(1); + expect(quarantineArgs[0]).toBe(path.join(appsDir, 'CCS Bar.app')); + }); +}); + +// --------------------------------------------------------------------------- +// Finding 2 — TTY gate: prompt gated on stdin.isTTY, not stdout.isTTY +// --------------------------------------------------------------------------- + +describe('bar install: stdin-TTY gate for launch prompt (Finding 2)', () => { + const FAKE_DOWNLOAD_URL = + 'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip'; + + function fakeExtract(appsDir: string) { + return async (_url: string, dest: string) => { + fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); + }; + } + + function baseDeps(appsDir: string, extra?: Partial>) { + return { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + downloadAndExtract: fakeExtract(appsDir), + verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }), + readAppBundleVersion: () => '1.0.0', + clearQuarantine: async () => true, + isBarRunning: async () => false, + getCcsDir: () => path.join(tempHome, '.ccs'), + getAppsDir: () => appsDir, + ...extra, + }; + } + + it('stdin-TTY true + stdout non-TTY: promptLaunch is called (prompt shown via seam)', async () => { + // This test proves the prompt path is reached even when stdout is not a TTY, + // as long as stdin is a TTY. We verify via the injectable promptLaunch dep. + const appsDir = path.join(tempHome, 'Applications'); + let promptCalled = false; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + launchBar: async () => { /* noop */ }, + // promptLaunch returning true simulates stdin-TTY + user answered yes. + // The production defaultPromptLaunch now checks stdin.isTTY; by injecting + // a mock that returns true we confirm this code path (prompt called, launch invoked). + promptLaunch: async () => { + promptCalled = true; + return false; // user said no — we just want to confirm the dep was called + }, + }); + + expect(promptCalled).toBe(true); + }); + + it('non-TTY stdin path: promptLaunch returns false + hint printed', async () => { + // Simulate defaultPromptLaunch behavior on non-TTY stdin: returns false. + // The handleBarInstall non-TTY fallback must print the launch hint. + const appsDir = path.join(tempHome, 'Applications'); + let launchCalled = false; + + const { handleBarInstall } = await loadInstallSubcommand(); + + // Save and override process.stdin.isTTY to simulate non-TTY stdin. + // We also inject promptLaunch that mirrors what defaultPromptLaunch does + // on non-TTY stdin (returns false) so the fallback branch executes. + const origStdinIsTTY = process.stdin.isTTY; + try { + // Force stdin to look like non-TTY so the fallback check fires. + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + await handleBarInstall([], { + ...baseDeps(appsDir), + launchBar: async () => { + launchCalled = true; + }, + promptLaunch: async () => false, // non-TTY stdin: defaultPromptLaunch returns false + }); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { + value: origStdinIsTTY, + configurable: true, + }); + } + + expect(launchCalled).toBe(false); + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/Run `ccs bar` to launch/i); + }); +}); + +// --------------------------------------------------------------------------- +// Finding 3 — Already-running detection via isBarRunning dep +// --------------------------------------------------------------------------- + +describe('bar install: already-running detection (Finding 3)', () => { + const FAKE_DOWNLOAD_URL = + 'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip'; + + function fakeExtract(appsDir: string) { + return async (_url: string, dest: string) => { + fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); + }; + } + + function baseDeps(appsDir: string, extra?: Partial>) { + return { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + downloadAndExtract: fakeExtract(appsDir), + verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }), + readAppBundleVersion: () => '1.0.0', + clearQuarantine: async () => true, + getCcsDir: () => path.join(tempHome, '.ccs'), + getAppsDir: () => appsDir, + ...extra, + }; + } + + it('when running after install: prompt skipped, restart hint printed', async () => { + const appsDir = path.join(tempHome, 'Applications'); + let promptCalled = false; + let launchCalled = false; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + isBarRunning: async () => true, + launchBar: async () => { + launchCalled = true; + }, + promptLaunch: async () => { + promptCalled = true; + return true; + }, + }); + + // Prompt must NOT be called when bar is running (without --launch) + expect(promptCalled).toBe(false); + // launchBar must NOT be invoked without --launch + expect(launchCalled).toBe(false); + + const allOutput = consoleOutput.join('\n'); + // Must print the restart hint + expect(allOutput).toMatch(/currently running the previous version/i); + expect(allOutput).toMatch(/Quit and reopen/i); + }); + + it('when not running after install: prompt path unchanged', async () => { + const appsDir = path.join(tempHome, 'Applications'); + let promptCalled = false; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + isBarRunning: async () => false, + launchBar: async () => { /* noop */ }, + promptLaunch: async () => { + promptCalled = true; + return false; + }, + }); + + // When bar is not running, normal prompt flow applies + expect(promptCalled).toBe(true); + const allOutput = consoleOutput.join('\n'); + // The restart hint must NOT appear when bar is not running + expect(allOutput).not.toMatch(/currently running the previous version/i); + }); + + it('pgrep error treated as not running: prompt path unchanged', async () => { + // Simulate pgrep failure (e.g. not available, permission error) — treated as not running. + const appsDir = path.join(tempHome, 'Applications'); + let promptCalled = false; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + // Simulate pgrep error — isBarRunning returns false per spec + isBarRunning: async () => false, + launchBar: async () => { /* noop */ }, + promptLaunch: async () => { + promptCalled = true; + return false; + }, + }); + + // pgrep error treated as not running → normal prompt flow + expect(promptCalled).toBe(true); + const allOutput = consoleOutput.join('\n'); + expect(allOutput).not.toMatch(/currently running the previous version/i); + }); + + it('--launch with bar already running: launchBar still invoked', async () => { + // --launch overrides the already-running guard; opening/activating an already-running + // app is harmless. + const appsDir = path.join(tempHome, 'Applications'); + let launchCalled = false; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall(['--launch'], { + ...baseDeps(appsDir), + isBarRunning: async () => true, + launchBar: async () => { + launchCalled = true; + }, + promptLaunch: async () => false, + }); + + expect(launchCalled).toBe(true); + // Restart hint must NOT appear when --launch is passed + const allOutput = consoleOutput.join('\n'); + expect(allOutput).not.toMatch(/currently running the previous version/i); + }); +}); + // --------------------------------------------------------------------------- // Fix 3 — defaultReadAppBundleVersion: whitespace-only plist value → null // --------------------------------------------------------------------------- @@ -1921,16 +2724,34 @@ describe('bar install: whitespace-only CFBundleShortVersionString yields null (F const { handleBarInstall } = await loadInstallSubcommand(); + // downloadAndExtract must recreate the bundle (with whitespace-only plist) after the + // stale-reinstall guard removes the pre-seeded app. await handleBarInstall([], { fetchReleaseAsset: async () => ({ downloadUrl: 'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip', }), - downloadAndExtract: async (_url: string, _dest: string) => { - // App bundle already created above + downloadAndExtract: async (_url: string, dest: string) => { + // Recreate bundle with the whitespace-only plist fixture. + const contentsOut = path.join(dest, 'CCS Bar.app', 'Contents'); + fs.mkdirSync(contentsOut, { recursive: true }); + fs.writeFileSync( + path.join(contentsOut, 'Info.plist'), + ` + + + + CFBundleShortVersionString + + +` + ); }, verifyCompat: async () => ({ compatible: true, reason: 'ok' }), // readAppBundleVersion intentionally omitted → uses production default + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, getCcsDir: () => ccsDir, getAppsDir: () => appsDir, }); @@ -1944,3 +2765,271 @@ describe('bar install: whitespace-only CFBundleShortVersionString yields null (F expect(allOutput).toMatch(/\[!\].*Info\.plist/i); }); }); + +// --------------------------------------------------------------------------- +// Data Loss finding — stage-then-swap: old bundle preserved on failure +// --------------------------------------------------------------------------- + +describe('bar install: stage-then-swap safety (Data Loss finding)', () => { + const FAKE_DOWNLOAD_URL = + 'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip'; + + function baseDeps( + appsDir: string, + extra?: Partial> + ): Record { + return { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }), + readAppBundleVersion: () => '2.0.0', + clearQuarantine: async () => true, + isBarRunning: async () => false, + launchBar: async () => { /* noop */ }, + promptLaunch: async () => false, + getCcsDir: () => path.join(tempHome, '.ccs'), + getAppsDir: () => appsDir, + ...extra, + }; + } + + it('downloadAndExtract receives a staging dir inside appsDir, NOT appsDir itself', async () => { + // The new contract: downloadAndExtract dest is a hidden staging dir under appsDir, + // not appsDir directly. This keeps the old install untouched during download. + const appsDir = path.join(tempHome, 'Applications'); + const destDirs: string[] = []; + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + downloadAndExtract: async (_url: string, dest: string) => { + destDirs.push(dest); + // Create the expected bundle inside staging so the verify step passes. + fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); + }, + }); + + expect(destDirs).toHaveLength(1); + const dest = destDirs[0]; + // Must NOT equal appsDir + expect(dest).not.toBe(appsDir); + // Must be a child of appsDir (same filesystem for atomic rename) + expect(dest.startsWith(appsDir + path.sep)).toBe(true); + // Dotted prefix to minimise Finder noise + expect(path.basename(dest)).toMatch(/^\..+/); + }); + + it('downloadAndExtract throws during reinstall: old bundle still present, removeExistingApp NOT called', async () => { + // Core data-loss guard: if download/extraction fails, the old working install + // must remain intact — removeExistingApp must never have been called. + const appsDir = path.join(tempHome, 'Applications'); + const appPath = path.join(appsDir, 'CCS Bar.app'); + // Pre-seed the existing install. + fs.mkdirSync(appPath, { recursive: true }); + fs.writeFileSync(path.join(appPath, 'sentinel'), 'old-install'); + + let removeCalled = false; + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + removeExistingApp: (_path: unknown) => { + removeCalled = true; + fs.rmSync(String(_path), { recursive: true, force: true }); + }, + downloadAndExtract: async (_url: string, _dest: string) => { + throw new Error('network timeout'); + }, + }); + + // removeExistingApp must NOT have been called — download failed before the swap. + expect(removeCalled).toBe(false); + // Old bundle must still be present. + expect(fs.existsSync(appPath)).toBe(true); + expect(fs.existsSync(path.join(appPath, 'sentinel'))).toBe(true); + + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/\[X\].*Download or extraction failed/i); + }); + + it('extracted archive missing the bundle: old bundle still present, install aborts with diagnostics', async () => { + // If the archive extracts successfully but does not contain CCS Bar.app, + // the old install must be left untouched and [X] with file listing printed. + const appsDir = path.join(tempHome, 'Applications'); + const appPath = path.join(appsDir, 'CCS Bar.app'); + fs.mkdirSync(appPath, { recursive: true }); + fs.writeFileSync(path.join(appPath, 'sentinel'), 'old-install'); + + let removeCalled = false; + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + removeExistingApp: (_path: unknown) => { + removeCalled = true; + fs.rmSync(String(_path), { recursive: true, force: true }); + }, + downloadAndExtract: async (_url: string, dest: string) => { + // Extraction produces a wrong-named artifact — CCS Bar.app absent. + fs.mkdirSync(dest, { recursive: true }); + fs.writeFileSync(path.join(dest, 'WrongName.app'), 'dummy'); + }, + }); + + // removeExistingApp must NOT have been called. + expect(removeCalled).toBe(false); + // Old bundle still present. + expect(fs.existsSync(appPath)).toBe(true); + + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/\[X\]/); + expect(allOutput).toMatch(/CCS Bar\.app/); + // Diagnostics: should list staging contents + expect(allOutput).toMatch(/WrongName\.app/); + }); + + it('success path: staging dir removed, app present at appPath', async () => { + const appsDir = path.join(tempHome, 'Applications'); + const appPath = path.join(appsDir, 'CCS Bar.app'); + let stagingDirUsed = ''; + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + downloadAndExtract: async (_url: string, dest: string) => { + stagingDirUsed = dest; + fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); + }, + }); + + // App at the final location. + expect(fs.existsSync(appPath)).toBe(true); + // Staging dir cleaned up. + if (stagingDirUsed) { + expect(fs.existsSync(stagingDirUsed)).toBe(false); + } + + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/\[OK\].*CCS Bar/); + expect(allOutput).not.toMatch(/\[X\]/); + }); + + it('aborts install and prints [X] when removeExistingApp throws (old app present = no swap)', async () => { + // removeExistingApp now runs AFTER staging verification. + // If it throws, the new bundle is still in staging (not yet moved); old app untouched. + const appsDir = path.join(tempHome, 'Applications'); + const appPath = path.join(appsDir, 'CCS Bar.app'); + fs.mkdirSync(appPath, { recursive: true }); + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + removeExistingApp: (_path: unknown) => { + throw new Error('Permission denied'); + }, + downloadAndExtract: async (_url: string, dest: string) => { + fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); + }, + }); + + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/\[X\].*Could not remove.*CCS Bar\.app/); + }); + + it('fresh install (no pre-existing bundle): removeExistingApp not invoked, install proceeds', async () => { + const appsDir = path.join(tempHome, 'Applications'); + // Do NOT pre-create the app bundle. + + let removeCalled = false; + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + removeExistingApp: (_path: unknown) => { + removeCalled = true; + }, + downloadAndExtract: async (_url: string, dest: string) => { + fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); + }, + }); + + expect(removeCalled).toBe(false); + + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/\[OK\].*CCS Bar/); + }); +}); + +// --------------------------------------------------------------------------- +// Review finding — silent decline: hint always printed when user says no +// --------------------------------------------------------------------------- + +describe('bar install: silent-decline fix — hint on user decline (review finding)', () => { + const FAKE_DOWNLOAD_URL = + 'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip'; + + function baseDeps( + appsDir: string, + extra?: Partial> + ): Record { + return { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + downloadAndExtract: async (_url: string, dest: string) => { + fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); + }, + verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }), + readAppBundleVersion: () => '1.0.0', + clearQuarantine: async () => true, + isBarRunning: async () => false, + launchBar: async () => { /* noop */ }, + getCcsDir: () => path.join(tempHome, '.ccs'), + getAppsDir: () => appsDir, + ...extra, + }; + } + + it('prints hint when user declines launch prompt (TTY path, answers no)', async () => { + const appsDir = path.join(tempHome, 'Applications'); + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + // Simulate TTY user answering "n" + promptLaunch: async () => false, + }); + + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/\[i\].*Run `ccs bar` to launch/i); + }); + + it('prints hint when non-TTY stdin (promptLaunch returns false on non-TTY)', async () => { + const appsDir = path.join(tempHome, 'Applications'); + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + promptLaunch: async () => false, + }); + + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/\[i\].*Run `ccs bar` to launch/i); + }); + + it('does NOT print the decline hint when user says yes (launch invoked instead)', async () => { + const appsDir = path.join(tempHome, 'Applications'); + let launchCalled = false; + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + launchBar: async () => { + launchCalled = true; + }, + promptLaunch: async () => true, + }); + + expect(launchCalled).toBe(true); + const allOutput = consoleOutput.join('\n'); + expect(allOutput).not.toMatch(/Run `ccs bar` to launch later/i); + }); +});