From f68c08ede3188e2f58f74c50049bd72d06ad4ee6 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 10 Jun 2026 13:59:08 -0400 Subject: [PATCH] fix(bar): verify server compat before Gatekeeper steps and harden reinstall Three review/CI corrections to the install flow: The compat handshake now runs before the quarantine-clear and launch block. It is a server-side check unrelated to Gatekeeper, and the previous ordering let a failed quarantine clear (always the case where xattr is absent, e.g. Linux CI) skip the handshake entirely. Reinstalls remove the existing bundle before extraction so the post-extraction existence check actually proves the fresh bundle landed; an unremovable bundle aborts install instead of extracting over it. Declining the launch prompt now prints the same run-ccs-bar hint as the non-TTY path instead of ending silently. Install tests inject the newer deps (clearQuarantine, isBarRunning, promptLaunch) everywhere the defaults could touch host binaries, keeping results identical on macOS and Linux runners. --- src/commands/bar/install-subcommand.ts | 74 +++++-- tests/unit/commands/bar-command.test.ts | 283 +++++++++++++++++++++++- 2 files changed, 328 insertions(+), 29 deletions(-) diff --git a/src/commands/bar/install-subcommand.ts b/src/commands/bar/install-subcommand.ts index d5109437..cc3cd068 100644 --- a/src/commands/bar/install-subcommand.ts +++ b/src/commands/bar/install-subcommand.ts @@ -110,6 +110,14 @@ export interface InstallDeps { * 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; } // --------------------------------------------------------------------------- @@ -386,6 +394,10 @@ async function defaultClearQuarantine(appPath: string): Promise { } } +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); @@ -470,6 +482,7 @@ export async function handleBarInstall( 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)(); @@ -500,6 +513,21 @@ export async function handleBarInstall( const { downloadUrl } = releaseInfo; console.log(`[i] Installing CCS Bar from ${BAR_RELEASE_TAG}...`); + // 1b. Stale reinstall guard: remove the existing bundle BEFORE extraction so a + // malformed archive that lacks the expected bundle cannot silently leave the + // old version in place. The removal window is intentionally small — release + // info is already resolved so we are committed to downloading. + 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.'); + return; + } + } + // 2. Download and extract into ~/Applications. try { await downloadAndExtract(downloadUrl, appsDir); @@ -551,24 +579,10 @@ export async function handleBarInstall( console.log('[!] Could not read app version from Info.plist.'); } - // 5. 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; - } - - // 6. Capability handshake via GET /api/bar/summary. + // 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'; @@ -599,6 +613,23 @@ export async function handleBarInstall( console.log('[i] Run `ccs bar` to start the server and recheck.'); } + // 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 @@ -624,10 +655,9 @@ export async function handleBarInstall( if (shouldLaunch) { await launchBar([]); } else { - // Either user said no or non-TTY stdin - if (!process.stdin.isTTY) { - console.log('[i] Run `ccs bar` to launch.'); - } + // 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 9ae83815..bb27967a 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, }); @@ -2645,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, }); @@ -2668,3 +2765,175 @@ describe('bar install: whitespace-only CFBundleShortVersionString yields null (F expect(allOutput).toMatch(/\[!\].*Info\.plist/i); }); }); + +// --------------------------------------------------------------------------- +// Review finding — stale reinstall: old bundle removed before extraction +// --------------------------------------------------------------------------- + +describe('bar install: stale reinstall guard — removeExistingApp (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 }), + 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('removes old bundle before calling downloadAndExtract on reinstall', async () => { + // Pre-seed a fake CCS Bar.app so the already-installed path is exercised. + const appsDir = path.join(tempHome, 'Applications'); + const appPath = path.join(appsDir, 'CCS Bar.app'); + fs.mkdirSync(appPath, { recursive: true }); + + let appExistedAtDownload = true; // pessimistic default + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + // Default removeExistingApp (fs.rmSync) is used — it will remove the dir. + downloadAndExtract: async (_url: string, dest: string) => { + // Assert: old bundle must be gone when extraction is invoked. + appExistedAtDownload = fs.existsSync(appPath); + // Place a fresh bundle so the post-extract assertion passes. + fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); + }, + }); + + expect(appExistedAtDownload).toBe(false); + }); + + it('aborts install and does NOT call downloadAndExtract when removeExistingApp throws', async () => { + // Pre-seed the old bundle so the removal path is triggered. + const appsDir = path.join(tempHome, 'Applications'); + const appPath = path.join(appsDir, 'CCS Bar.app'); + fs.mkdirSync(appPath, { recursive: true }); + + let downloadCalled = false; + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + ...baseDeps(appsDir), + removeExistingApp: (_path: unknown) => { + throw new Error('Permission denied'); + }, + downloadAndExtract: async (_url: string, _dest: string) => { + downloadCalled = true; + }, + }); + + expect(downloadCalled).toBe(false); + + 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); + }); +});