From 4eef3f77a4a8210501a9dfe369ceeac581772939 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 10 Jun 2026 13:25:07 -0400 Subject: [PATCH] feat(bar): one-flow install with quarantine automation and launch handoff 'ccs bar install' previously ended with two manual steps: clearing the Gatekeeper quarantine by hand and running 'ccs bar' separately. Install now detects an existing installation and says so before reinstalling, clears the quarantine attribute itself via execFile with a graceful fallback to the printed hint when xattr fails, and ends with a TTY-aware 'Launch CCS Bar now?' prompt (default yes) that hands off to the existing launch flow. --launch forces the handoff and --no-launch suppresses it for scripted installs; non-TTY runs skip the prompt and print the manual command instead. Closes #1504 --- docs/ccs-bar.md | 13 +- src/commands/bar/help-subcommand.ts | 14 +- src/commands/bar/install-subcommand.ts | 131 +++++++++- tests/unit/commands/bar-command.test.ts | 328 ++++++++++++++++++++++++ 4 files changed, 473 insertions(+), 13 deletions(-) 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..817113ad 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,24 @@ 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 `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 (TTY-only). + * Returns true on yes/default, false on no or non-TTY. + * Injectable for tests. + */ + promptLaunch: () => Promise; } // --------------------------------------------------------------------------- @@ -341,6 +360,54 @@ function defaultGetAppsDir(): string { return path.join(os.homedir(), 'Applications'); } +/** + * Clear the macOS Gatekeeper quarantine attribute from the installed app. + * Uses `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('xattr', ['-dr', 'com.apple.quarantine', appPath]); + return true; + } catch { + return false; + } +} + +async function defaultLaunchBar(args: string[]): Promise { + const { handleBarLaunch } = await import('./launch-subcommand'); + await handleBarLaunch(args); +} + +/** + * Ask the user whether to launch CCS Bar now. + * Only prompts when stdout is a TTY. Non-TTY: return false (caller prints guidance). + * Default answer is yes (Enter = launch). + */ +async function defaultPromptLaunch(): Promise { + if (!process.stdout.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 +427,35 @@ 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 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; @@ -397,7 +482,6 @@ export async function handleBarInstall( // 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)) { // Report what was actually extracted to help diagnose archive issues. let extracted: string[] = []; @@ -438,7 +522,20 @@ export async function handleBarInstall( console.log('[!] Could not read app version from Info.plist.'); } - // 5. Capability handshake via GET /api/bar/summary. + // 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. + 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.'); + } + + // 6. Capability handshake via GET /api/bar/summary. // 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 +566,24 @@ 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.'); + // 7. Launch handoff. + // --launch: skip prompt and launch immediately. + // --no-launch: skip prompt, print guidance. + // TTY: ask interactively (default yes). + // Non-TTY: print guidance, no prompt. + 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 { + // Either user said no or non-TTY + if (!process.stdout.isTTY) { + console.log('[i] Run `ccs bar` to launch.'); + } + } + } } diff --git a/tests/unit/commands/bar-command.test.ts b/tests/unit/commands/bar-command.test.ts index 6c51d32e..d9f222f2 100644 --- a/tests/unit/commands/bar-command.test.ts +++ b/tests/unit/commands/bar-command.test.ts @@ -1894,6 +1894,334 @@ 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', async () => { + const appsDir = path.join(tempHome, 'Applications'); + + 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 () => { + /* noop */ + }, + promptLaunch: async () => 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); + }); + + 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\]/); + }); +}); + +// --------------------------------------------------------------------------- +// 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, + 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); + }); +}); + // --------------------------------------------------------------------------- // Fix 3 — defaultReadAppBundleVersion: whitespace-only plist value → null // ---------------------------------------------------------------------------