diff --git a/docs/ccs-bar.md b/docs/ccs-bar.md index 82bb9a47..63c9254d 100644 --- a/docs/ccs-bar.md +++ b/docs/ccs-bar.md @@ -31,6 +31,8 @@ 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. +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`. + ### 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: @@ -69,6 +71,7 @@ This removes `~/Applications/CCS Bar.app` and the installed version pin. It is a ## Troubleshooting +- Install fails with "server predates CCS Bar" or bar API returns 404: the CCS server running does not yet include CCS Bar. Update CCS (`npm i -g ccs@latest` or equivalent), then restart `ccs bar`. - Server failed to start: usually a port conflict. Free the port or re-run `ccs bar` to pick a fresh one. - App won't open (Gatekeeper): right-click and Open, or clear quarantine with the `xattr` command above. - Blank app: the web-server is not running. Re-run `ccs bar` and confirm `~/.ccs/bar.json` exists. diff --git a/src/commands/bar/help-subcommand.ts b/src/commands/bar/help-subcommand.ts new file mode 100644 index 00000000..139dd234 --- /dev/null +++ b/src/commands/bar/help-subcommand.ts @@ -0,0 +1,56 @@ +import { color, dim, header, initUI, subheader } from '../../utils/ui'; + +export async function showHelp(): Promise { + await initUI(); + console.log(''); + console.log(header('CCS Bar (macOS Menu Bar App)')); + console.log(''); + console.log(subheader('Usage:')); + console.log(` ${color('ccs bar', 'command')} [command] [options]`); + console.log(''); + + const sections: [string, [string, string][]][] = [ + [ + 'Commands:', + [ + ['launch', 'Start the web-server, write ~/.ccs/bar.json, and open the app (default)'], + ['install', 'Download CCS Bar from the ccs-bar-latest GitHub release into ~/Applications'], + ['uninstall', 'Remove the app and version pin'], + ['version', 'Show CLI and installed app versions'], + ], + ], + [ + 'Options:', + [ + ['--help, -h', 'Show this help message'], + ['--version', 'Show CLI and installed app versions'], + ], + ], + [ + '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 version', 'Show CLI and installed app versions'], + ['ccs bar uninstall', 'Remove CCS Bar and its version pin'], + ], + ], + ]; + + for (const [title, rows] of sections) { + console.log(subheader(title)); + const width = Math.max(...rows.map(([command]) => command.length)); + for (const [command, description] of rows) { + console.log(` ${color(command.padEnd(width + 2), 'command')} ${description}`); + } + console.log(''); + } + + 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.' + ) + ); + console.log(''); +} diff --git a/src/commands/bar/index.ts b/src/commands/bar/index.ts index 717aaff3..6ae1e57f 100644 --- a/src/commands/bar/index.ts +++ b/src/commands/bar/index.ts @@ -2,12 +2,23 @@ * `ccs bar` command dispatcher * * Mirrors the pattern in src/commands/docker/index.ts. - * Subcommands: launch (default), install, uninstall, version / --version. + * Subcommands: launch (default), install, uninstall, version / --version, help / --help / -h. */ +import { hasAnyFlag } from '../arg-extractor'; + export async function handleBarCommand(args: string[]): Promise { const subcommand = args[0]; + // --help / -h anywhere in args (e.g. `ccs bar install --help`) → show help. + // Mirrors docker/index.ts: hasAnyFlag(normalizedArgs, ['--help', '-h']). + // Also dispatch bare `help` subcommand for symmetry. + if (hasAnyFlag(args, ['--help', '-h']) || subcommand === 'help') { + const { showHelp } = await import('./help-subcommand'); + await showHelp(); + return; + } + // --version / version are aliases for the version subcommand if (subcommand === '--version' || subcommand === 'version') { const { handleBarVersion } = await import('./version-subcommand'); @@ -39,7 +50,7 @@ export async function handleBarCommand(args: string[]): Promise { const handler = commandHandlers[subcommand]; if (!handler) { console.error(`[X] Unknown bar subcommand: ${subcommand}`); - console.error('[i] Usage: ccs bar [launch|install|uninstall|--version]'); + console.error('[i] Usage: ccs bar [launch|install|uninstall|version|--help]'); return; } diff --git a/src/commands/bar/install-subcommand.ts b/src/commands/bar/install-subcommand.ts index 8f286932..c3fd5a46 100644 --- a/src/commands/bar/install-subcommand.ts +++ b/src/commands/bar/install-subcommand.ts @@ -3,9 +3,14 @@ * `ccs-bar-latest` GitHub release tag and install to ~/Applications. * * Intentionally uses a FLOATING tag (not the exact CLI version) so the - * Swift app can be rebuilt and published independently. After install the - * handler calls GET /api/overview to verify version compatibility and warns - * (but does not hard-fail) on mismatch. + * Swift app can be rebuilt and published independently. After extraction, + * the real app version is read from the bundle's Contents/Info.plist + * (CFBundleShortVersionString) and pinned to ~/.ccs/bar/.version. + * + * Post-install compat check: single GET {baseUrl}/api/bar/summary. + * 200 = server serves the bar API (compatible). + * 404 = server too old (actionable warning). + * Other / unreachable = soft-warn, never hard-fail install. * * Mirrors the download/version-pin pattern in src/cliproxy/binary-manager.ts. */ @@ -45,19 +50,18 @@ const DOWNLOAD_HOST_ALLOWLIST: ReadonlyArray = [ export interface ReleaseAssetResult { downloadUrl: string; - version: string; } export interface CompatResult { - version: string; compatible: boolean; + reason: 'ok' | 'no-bar-api' | 'unreachable'; } export interface InstallDeps { /** - * Resolve the asset download URL + version string from a GitHub release tag. + * Resolve the asset download URL from a GitHub release tag. * Production: calls GitHub API releases/tags/{tag}. - * Test: mock that returns a fake URL + version. + * Test: mock that returns a fake URL. */ fetchReleaseAsset: (tag: string, asset: string) => Promise; /** @@ -66,11 +70,16 @@ export interface InstallDeps { */ downloadAndExtract: (url: string, dest: string) => Promise; /** - * Call GET {baseUrl}/api/overview and return { version, compatible }. - * compatible = server version major === installed app version major. - * Returns compatible:false (never true) when versions cannot be compared. + * GET {baseUrl}/api/bar/summary — capability handshake. + * 200 → compatible; 404 → no-bar-api; else/unreachable → unreachable. + * Never hard-fails install. */ - verifyCompat: (baseUrl: string, installedVersion: string) => Promise; + verifyCompat: (baseUrl: string) => Promise; + /** + * Read CFBundleShortVersionString from {appPath}/Contents/Info.plist. + * Returns null if the file is absent or unreadable. + */ + readAppBundleVersion: (appPath: string) => string | null; /** Returns path to ~/.ccs (respects CCS_HOME). */ getCcsDir: () => string; /** Destination directory for the .app bundle (~/Applications by default). */ @@ -131,9 +140,6 @@ async function defaultFetchReleaseAsset(tag: string, asset: string): Promise a.name === asset); @@ -141,7 +147,7 @@ async function defaultFetchReleaseAsset(tag: string, asset: string): Promise { +async function defaultVerifyCompat(baseUrl: string): Promise { try { const { request } = await import('undici'); - const { statusCode, body } = await request(`${baseUrl}/api/overview`, { + const { statusCode, body } = await request(`${baseUrl}/api/bar/summary`, { headers: { 'User-Agent': 'ccs-cli' }, }); - if (statusCode !== 200) { - console.log( - `[!] Version compatibility check failed: /api/overview returned HTTP ${statusCode}.` - ); - return { version: 'unknown', compatible: false }; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const data = (await body.json()) as any; - const serverVersion: string = (data.version as string) ?? 'unknown'; - if (serverVersion === 'unknown') { - console.log('[!] Version compatibility: server did not report a version.'); - return { version: serverVersion, compatible: false }; + // Drain the body regardless of status to free the socket. + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (body as any).dump?.(); + } catch { + /* ignore drain errors */ } - // Parse installed version major from the pinned version string. - const installedMajorRaw = installedVersion.split('.')[0]; - const serverMajorRaw = serverVersion.split('.')[0]; - const installedMajor = parseInt(installedMajorRaw ?? '', 10); - const serverMajor = parseInt(serverMajorRaw ?? '', 10); - - if (isNaN(installedMajor) || isNaN(serverMajor)) { - console.log( - `[!] Version compatibility: cannot parse major versions ` + - `(installed="${installedVersion}", server="${serverVersion}").` - ); - return { version: serverVersion, compatible: false }; + if (statusCode === 200) { + return { compatible: true, reason: 'ok' }; } - - const compatible = installedMajor === serverMajor; - return { version: serverVersion, compatible }; + if (statusCode === 404) { + return { compatible: false, reason: 'no-bar-api' }; + } + return { compatible: false, reason: 'unreachable' }; } catch { - // Server unreachable — warn but do not claim compatible. - return { version: 'unknown', compatible: false }; + // Network error or server not running. + return { compatible: false, reason: 'unreachable' }; + } +} + +/** + * Read CFBundleShortVersionString from {appPath}/Contents/Info.plist. + * The plist is XML text post-codesign (verified on the installed app). + * Returns null on any error (missing file, binary plist, parse failure). + */ +function defaultReadAppBundleVersion(appPath: string): string | null { + try { + const plistPath = path.join(appPath, 'Contents', 'Info.plist'); + const contents = fs.readFileSync(plistPath, 'utf8'); + const match = /CFBundleShortVersionString<\/key>\s*([^<]+)<\/string>/.exec( + contents + ); + if (!match || !match[1]) return null; + const v = match[1].trim(); + return v || null; + } catch { + return null; } } @@ -357,12 +366,13 @@ export async function handleBarInstall( const fetchReleaseAsset = deps.fetchReleaseAsset ?? defaultFetchReleaseAsset; const downloadAndExtract = deps.downloadAndExtract ?? defaultDownloadAndExtract; const verifyCompat = deps.verifyCompat ?? defaultVerifyCompat; + const readAppBundleVersion = deps.readAppBundleVersion ?? defaultReadAppBundleVersion; const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)(); const appsDir = (deps.getAppsDir ?? defaultGetAppsDir)(); console.log('[i] Fetching CCS Bar release info...'); - // 1. Resolve the floating tag → download URL + version. + // 1. Resolve the floating tag → download URL. let releaseInfo: ReleaseAssetResult; try { releaseInfo = await fetchReleaseAsset(BAR_RELEASE_TAG, BAR_ASSET_NAME); @@ -373,8 +383,8 @@ export async function handleBarInstall( return; } - const { downloadUrl, version } = releaseInfo; - console.log(`[i] Installing CCS Bar v${version} from ${BAR_RELEASE_TAG}...`); + const { downloadUrl } = releaseInfo; + console.log(`[i] Installing CCS Bar from ${BAR_RELEASE_TAG}...`); // 2. Download and extract into ~/Applications. try { @@ -402,18 +412,33 @@ export async function handleBarInstall( return; } - // 4. Pin the installed version to ~/.ccs/bar/.version. - try { - pinBarVersion(ccsDir, version); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error(`[!] Could not write version pin: ${msg}`); - // Non-fatal — continue. + // 4. Read the real version from the extracted bundle's Info.plist. + const installedVersion = readAppBundleVersion(appPath); + + if (installedVersion !== null) { + // Pin the real version string. + try { + pinBarVersion(ccsDir, installedVersion); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[!] Could not write version pin: ${msg}`); + // Non-fatal — continue. + } + console.log(`[OK] CCS Bar v${installedVersion} installed to ${appsDir}/${BAR_APP_NAME}`); + } else { + // Info.plist unreadable — best-effort remove any stale version pin from a previous install, + // so `ccs bar version` does not show an outdated version string. + try { + fs.rmSync(path.join(ccsDir, 'bar', '.version'), { force: true }); + } catch { + /* non-fatal — ignore */ + } + // No pin written; ASCII notice; no crash. + console.log(`[OK] CCS Bar installed to ${appsDir}/${BAR_APP_NAME}`); + console.log('[!] Could not read app version from Info.plist.'); } - console.log(`[OK] CCS Bar v${version} installed to ${appsDir}/${BAR_APP_NAME}`); - - // 5. Version-compat handshake via /api/overview. + // 5. 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'; @@ -426,19 +451,21 @@ export async function handleBarInstall( } try { - // Pass the pinned version so verifyCompat can do a real major-version comparison. - const compat = await verifyCompat(baseUrl, version); - if (!compat.compatible) { + const compat = await verifyCompat(baseUrl); + if (compat.reason === 'ok') { + console.log('[OK] Server bar API reachable.'); + } else if (compat.reason === 'no-bar-api') { console.log( - `[!] Version mismatch: CCS server reports v${compat.version}, ` + - `app is v${version}. Some features may not work until you restart ` + - '`ccs bar` or update CCS.' + '[!] CCS server does not serve the bar API (/api/bar/summary returned 404).' + + ' Update CCS, then restart `ccs bar`.' ); } else { - console.log(`[OK] Version compatibility confirmed (server: v${compat.version}).`); + // unreachable — soft-warn + console.log('[!] Could not verify server compatibility (server may not be running).'); + console.log('[i] Run `ccs bar` to start the server and recheck.'); } } catch { - console.log('[!] Could not verify version compatibility (server may not be running).'); + console.log('[!] Could not verify server compatibility (server may not be running).'); console.log('[i] Run `ccs bar` to start the server and recheck.'); } diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index 3f54961e..4043a68e 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -342,6 +342,7 @@ export const COMMAND_FLAG_SUGGESTIONS: Readonly await showProviderShortcutHelp('cursor', writeLine), proxy: async () => process.exit(await (await import('./proxy-command')).handleProxyCommand(['--help'])), + bar: async () => (await import('./bar/help-subcommand')).showHelp(), docker: async () => (await import('./docker/help-subcommand')).showHelp(), migrate: async () => (await import('./migrate-command')).printMigrateHelp(), setup: async () => (await import('./setup-command')).handleSetupCommand(['--help']), diff --git a/tests/unit/commands/bar-command.test.ts b/tests/unit/commands/bar-command.test.ts index ca8bd313..227cf1e8 100644 --- a/tests/unit/commands/bar-command.test.ts +++ b/tests/unit/commands/bar-command.test.ts @@ -3,8 +3,8 @@ * * Tests run FIRST per TDD mandate. * Covers: subcommand routing, bar.json contract, floating-tag install, - * version-compat handshake, port-discovery fallback, uninstall, version, - * and verified review findings #8-#13. + * Info.plist version extraction, capability handshake compat, port-discovery + * fallback, uninstall, version, and verified review findings #8-#13. * * All network I/O and filesystem-home operations are mocked. * Uses CCS_HOME env var for isolation — never touches real ~/.ccs. @@ -141,6 +141,12 @@ describe('bar command dispatcher (index.ts)', () => { calls.push(`version:`); }, })); + + mock.module('../../../src/commands/bar/help-subcommand', () => ({ + showHelp: async () => { + calls.push(`help:`); + }, + })); }); it('dispatches bare `ccs bar` to launch', async () => { @@ -190,6 +196,28 @@ describe('bar command dispatcher (index.ts)', () => { // Should print help or error but not crash await expect(handleBarCommand(['unknown-subcommand'])).resolves.toBeUndefined(); }); + + it('dispatches `ccs bar --help` to help subcommand and does not launch', async () => { + const handleBarCommand = await loadHandleBarCommand(); + await handleBarCommand(['--help']); + expect(calls).toEqual(['help:']); + expect(calls).not.toContain(expect.stringMatching(/^launch:/)); + }); + + it('dispatches `ccs bar -h` to help subcommand and does not launch', async () => { + const handleBarCommand = await loadHandleBarCommand(); + await handleBarCommand(['-h']); + expect(calls).toEqual(['help:']); + expect(calls).not.toContain(expect.stringMatching(/^launch:/)); + }); + + it('dispatches `ccs bar help` to help subcommand and does not hit unknown-subcommand error', async () => { + const handleBarCommand = await loadHandleBarCommand(); + await handleBarCommand(['help']); + expect(calls).toEqual(['help:']); + const allOutput = consoleOutput.join('\n'); + expect(allOutput).not.toMatch(/Unknown bar subcommand/); + }); }); // --------------------------------------------------------------------------- @@ -352,7 +380,7 @@ describe('bar.json contract (launch subcommand)', () => { }); // --------------------------------------------------------------------------- -// 4. install subcommand — floating tag + version-compat handshake +// 4. install subcommand — floating tag + Info.plist version + compat handshake // --------------------------------------------------------------------------- describe('bar install subcommand', () => { @@ -377,13 +405,11 @@ describe('bar install subcommand', () => { await handleBarInstall([], { fetchReleaseAsset: async (tag: string, _asset: string) => { fetchedUrls.push(tag); - return { downloadUrl: FAKE_DOWNLOAD_URL, version: FAKE_VERSION }; + return { downloadUrl: FAKE_DOWNLOAD_URL }; }, downloadAndExtract: fakeExtract(appsDir), - verifyCompat: async (_baseUrl: string, _installedVersion: string) => ({ - version: FAKE_VERSION, - compatible: true, - }), + verifyCompat: async (_baseUrl: string) => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: (_appPath: string) => FAKE_VERSION, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -393,7 +419,7 @@ describe('bar install subcommand', () => { expect(fetchedUrls).not.toContain(expect.stringMatching(/^\d+\.\d+\.\d+$/)); }); - it('pins the installed version to ~/.ccs/bar/.version', async () => { + it('pins the Info.plist version (not tag name) to ~/.ccs/bar/.version', async () => { const ccsDir = path.join(tempHome, '.ccs'); const appsDir = path.join(tempHome, 'Applications'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -401,9 +427,10 @@ describe('bar install subcommand', () => { const { handleBarInstall } = await loadInstallSubcommand(); await handleBarInstall([], { - fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, version: FAKE_VERSION }), + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), downloadAndExtract: fakeExtract(appsDir), - verifyCompat: async () => ({ version: FAKE_VERSION, compatible: true }), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: (_appPath: string) => FAKE_VERSION, getCcsDir: () => ccsDir, getAppsDir: () => appsDir, }); @@ -413,19 +440,20 @@ describe('bar install subcommand', () => { expect(fs.readFileSync(versionFile, 'utf8').trim()).toBe(FAKE_VERSION); }); - it('calls /api/overview for version-compat handshake after install', async () => { + it('calls /api/bar/summary for compat handshake after install', async () => { const compatCalls: string[] = []; const appsDir = path.join(tempHome, 'Applications'); const { handleBarInstall } = await loadInstallSubcommand(); await handleBarInstall([], { - fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, version: FAKE_VERSION }), + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), downloadAndExtract: fakeExtract(appsDir), - verifyCompat: async (baseUrl: string, _installedVersion: string) => { + verifyCompat: async (baseUrl: string) => { compatCalls.push(baseUrl); - return { version: FAKE_VERSION, compatible: true }; + return { compatible: true, reason: 'ok' }; }, + readAppBundleVersion: (_appPath: string) => FAKE_VERSION, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -433,26 +461,23 @@ describe('bar install subcommand', () => { expect(compatCalls.length).toBeGreaterThan(0); }); - it('warns on version mismatch but does not hard-fail', async () => { + it('does not hard-fail when compat returns no-bar-api', async () => { const appsDir = path.join(tempHome, 'Applications'); const { handleBarInstall } = await loadInstallSubcommand(); - // Version mismatch: server says 2.0.0 but app is 1.2.3 await expect( handleBarInstall([], { - fetchReleaseAsset: async () => ({ - downloadUrl: FAKE_DOWNLOAD_URL, - version: '1.2.3', - }), + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), downloadAndExtract: fakeExtract(appsDir), - verifyCompat: async () => ({ version: '2.0.0', compatible: false }), + verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }), + readAppBundleVersion: (_appPath: string) => FAKE_VERSION, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }) ).resolves.toBeUndefined(); // does not throw const allOutput = consoleOutput.join('\n'); - expect(allOutput.toLowerCase()).toMatch(/warn|mismatch|version/i); + expect(allOutput.toLowerCase()).toMatch(/warn|api|update/i); }); it('prints xattr/Gatekeeper note for ad-hoc builds', async () => { @@ -460,9 +485,10 @@ describe('bar install subcommand', () => { const { handleBarInstall } = await loadInstallSubcommand(); await handleBarInstall([], { - fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, version: FAKE_VERSION }), + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), downloadAndExtract: fakeExtract(appsDir), - verifyCompat: async () => ({ version: FAKE_VERSION, compatible: true }), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: (_appPath: string) => FAKE_VERSION, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -485,7 +511,8 @@ describe('bar install subcommand', () => { downloadAndExtract: async () => { /* noop */ }, - verifyCompat: async () => ({ version: FAKE_VERSION, compatible: true }), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: (_appPath: string) => FAKE_VERSION, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }) @@ -526,10 +553,10 @@ describe('bar install: redirect-following download (#8)', () => { await handleBarInstall([], { fetchReleaseAsset: async () => ({ downloadUrl: REDIRECT_URL, - version: FAKE_VERSION, }), downloadAndExtract: redirectFollowingExtract, - verifyCompat: async () => ({ version: FAKE_VERSION, compatible: true }), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: (_appPath: string) => FAKE_VERSION, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -562,10 +589,10 @@ describe('bar install: HTTP status code validation (#11)', () => { await handleBarInstall([], { fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, - version: '1.0.0', }), downloadAndExtract: statusCheckingExtract, - verifyCompat: async () => ({ version: '1.0.0', compatible: true }), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: (_appPath: string) => '1.0.0', getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => path.join(tempHome, 'Applications'), }); @@ -582,12 +609,12 @@ describe('bar install: HTTP status code validation (#11)', () => { await handleBarInstall([], { fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, - version: '1.0.0', }), downloadAndExtract: async (_url) => { throw new Error(`Download failed: HTTP 404 for ${_url}`); }, - verifyCompat: async () => ({ version: '1.0.0', compatible: true }), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: (_appPath: string) => '1.0.0', getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => path.join(tempHome, 'Applications'), }); @@ -651,7 +678,6 @@ describe('bar install: host allowlist validation (#9)', () => { await handleBarInstall([], { fetchReleaseAsset: async () => ({ downloadUrl: 'https://evil.example.com/CCS-Bar.app.zip', - version: '1.0.0', }), // downloadAndExtract is the production default; it calls validateDownloadUrl internally. // We pass a test-double that applies the same validation. @@ -660,7 +686,8 @@ describe('bar install: host allowlist validation (#9)', () => { const { validateDownloadUrl } = await loadInstallSubcommand(); validateDownloadUrl(url); }, - verifyCompat: async () => ({ version: '1.0.0', compatible: true }), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: (_appPath: string) => '1.0.0', getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => path.join(tempHome, 'Applications'), }); @@ -673,10 +700,10 @@ describe('bar install: host allowlist validation (#9)', () => { }); // --------------------------------------------------------------------------- -// 4d. Finding #10 — verifyCompat real major-version comparison +// 4d. Compat capability handshake (replaces old major-version check) // --------------------------------------------------------------------------- -describe('bar install: real version compatibility check (#10)', () => { +describe('bar install: compat capability handshake', () => { const FAKE_DOWNLOAD_URL = 'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip'; @@ -686,90 +713,76 @@ describe('bar install: real version compatibility check (#10)', () => { }; } - it('passes installedVersion to verifyCompat so major comparison is possible', async () => { + it('verifyCompat receives only baseUrl (no installedVersion param)', async () => { const appsDir = path.join(tempHome, 'Applications'); - const capturedArgs: Array<{ baseUrl: string; installedVersion: string }> = []; + const capturedArgs: Array<{ baseUrl: string }> = []; const { handleBarInstall } = await loadInstallSubcommand(); await handleBarInstall([], { - fetchReleaseAsset: async () => ({ - downloadUrl: FAKE_DOWNLOAD_URL, - version: '2.5.0', - }), + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), downloadAndExtract: fakeExtract(appsDir), - verifyCompat: async (baseUrl: string, installedVersion: string) => { - capturedArgs.push({ baseUrl, installedVersion }); - // Same major — compatible. - return { version: '2.1.0', compatible: true }; + verifyCompat: async (baseUrl: string) => { + capturedArgs.push({ baseUrl }); + return { compatible: true, reason: 'ok' }; }, + readAppBundleVersion: (_appPath: string) => '1.4.0', getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); expect(capturedArgs.length).toBe(1); - // installedVersion must be the version from the release, not a hardcoded 0. - expect(capturedArgs[0].installedVersion).toBe('2.5.0'); + // verifyCompat receives only one argument (baseUrl) + expect(capturedArgs[0].baseUrl).toMatch(/http/); }); - it('reports compatible when server major equals installed major', async () => { + it('prints [OK] server bar API reachable when compat returns ok', async () => { const appsDir = path.join(tempHome, 'Applications'); const { handleBarInstall } = await loadInstallSubcommand(); await handleBarInstall([], { - fetchReleaseAsset: async () => ({ - downloadUrl: FAKE_DOWNLOAD_URL, - version: '3.0.0', - }), + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), downloadAndExtract: fakeExtract(appsDir), - verifyCompat: async (_baseUrl: string, _installedVersion: string) => { - // Same major (3 == 3). - return { version: '3.1.0', compatible: true }; - }, + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: (_appPath: string) => '1.4.0', getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); const allOutput = consoleOutput.join('\n'); - expect(allOutput).toMatch(/\[OK\].*[Vv]ersion/i); + expect(allOutput).toMatch(/\[OK\].*[Ss]erver bar API/); + // Must NOT print any mismatch warning + expect(allOutput).not.toMatch(/mismatch/i); }); - it('warns when server major differs from installed major', async () => { + it('warns with actionable message when compat returns no-bar-api (404)', async () => { const appsDir = path.join(tempHome, 'Applications'); const { handleBarInstall } = await loadInstallSubcommand(); await handleBarInstall([], { - fetchReleaseAsset: async () => ({ - downloadUrl: FAKE_DOWNLOAD_URL, - version: '1.0.0', - }), + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), downloadAndExtract: fakeExtract(appsDir), - verifyCompat: async (_baseUrl: string, _installedVersion: string) => { - // Major mismatch: server v2, installed v1. - return { version: '2.0.0', compatible: false }; - }, + verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }), + readAppBundleVersion: (_appPath: string) => '1.4.0', getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); const allOutput = consoleOutput.join('\n'); expect(allOutput).toMatch(/\[!\]/); - expect(allOutput.toLowerCase()).toMatch(/mismatch|version/i); + // Must mention the bar API and instruct the user to update CCS + expect(allOutput.toLowerCase()).toMatch(/bar api|\/api\/bar\/summary/i); + expect(allOutput.toLowerCase()).toMatch(/update ccs/i); }); - it('warns (not silently compatible) when server is unreachable', async () => { + it('soft-warns (not crash) when compat returns unreachable', async () => { const appsDir = path.join(tempHome, 'Applications'); const { handleBarInstall } = await loadInstallSubcommand(); await handleBarInstall([], { - fetchReleaseAsset: async () => ({ - downloadUrl: FAKE_DOWNLOAD_URL, - version: '1.0.0', - }), + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), downloadAndExtract: fakeExtract(appsDir), - verifyCompat: async (_baseUrl: string, _installedVersion: string) => { - // Server unreachable — never claim compatible. - return { version: 'unknown', compatible: false }; - }, + verifyCompat: async () => ({ compatible: false, reason: 'unreachable' }), + readAppBundleVersion: (_appPath: string) => '1.4.0', getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -777,6 +790,25 @@ describe('bar install: real version compatibility check (#10)', () => { const allOutput = consoleOutput.join('\n'); // Should warn rather than print [OK] compat confirmed expect(allOutput).not.toMatch(/\[OK\].*[Cc]ompat/); + expect(allOutput).toMatch(/\[!\].*[Cc]ould not verify|server may not be running/i); + }); + + it('install never hard-fails due to compat failure', async () => { + const appsDir = path.join(tempHome, 'Applications'); + const { handleBarInstall } = await loadInstallSubcommand(); + + await expect( + handleBarInstall([], { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + downloadAndExtract: fakeExtract(appsDir), + verifyCompat: async () => { + throw new Error('network explosion'); + }, + readAppBundleVersion: (_appPath: string) => '1.4.0', + getCcsDir: () => path.join(tempHome, '.ccs'), + getAppsDir: () => appsDir, + }) + ).resolves.toBeUndefined(); }); }); @@ -796,12 +828,12 @@ describe('bar install: post-extract app-exists assertion (#12)', () => { await handleBarInstall([], { fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, - version: '1.0.0', }), downloadAndExtract: async (_url: string, dest: string) => { fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); }, - verifyCompat: async () => ({ version: '1.0.0', compatible: true }), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: (_appPath: string) => '1.0.0', getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -819,14 +851,14 @@ describe('bar install: post-extract app-exists assertion (#12)', () => { await handleBarInstall([], { fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, - version: '1.0.0', }), downloadAndExtract: async (_url: string, dest: string) => { // Places a wrongly-named artifact (simulates a bad archive). fs.mkdirSync(dest, { recursive: true }); fs.writeFileSync(path.join(dest, 'WrongName.app'), 'dummy'); }, - verifyCompat: async () => ({ version: '1.0.0', compatible: true }), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: (_appPath: string) => '1.0.0', getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -844,13 +876,13 @@ describe('bar install: post-extract app-exists assertion (#12)', () => { await handleBarInstall([], { fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, - version: '1.0.0', }), downloadAndExtract: async (_url: string, dest: string) => { // Nothing extracted fs.mkdirSync(dest, { recursive: true }); }, - verifyCompat: async () => ({ version: '1.0.0', compatible: true }), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: (_appPath: string) => '1.0.0', getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -861,6 +893,179 @@ describe('bar install: post-extract app-exists assertion (#12)', () => { }); }); +// --------------------------------------------------------------------------- +// 4h. Regression: version source of truth — Info.plist, not tag_name +// --------------------------------------------------------------------------- + +describe('bar install: Info.plist version extraction regression tests', () => { + 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('output never contains "vccs-bar-latest" when release tag is floating', 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 returns the real version from Info.plist + readAppBundleVersion: (_appPath: string) => '1.4.0', + getCcsDir: () => path.join(tempHome, '.ccs'), + getAppsDir: () => appsDir, + }); + + const allOutput = consoleOutput.join('\n'); + // Must never print the floating tag as a version string + expect(allOutput).not.toMatch(/vccs-bar-latest/i); + // Must print the actual plist version + expect(allOutput).toMatch(/1\.4\.0/); + }); + + it('pins Info.plist version (1.4.0) to .version file, not the tag name', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const appsDir = path.join(tempHome, 'Applications'); + fs.mkdirSync(ccsDir, { recursive: true }); + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + downloadAndExtract: fakeExtract(appsDir), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: (_appPath: string) => '1.4.0', + getCcsDir: () => ccsDir, + getAppsDir: () => appsDir, + }); + + const versionFile = path.join(ccsDir, 'bar', '.version'); + expect(fs.existsSync(versionFile)).toBe(true); + const pinned = fs.readFileSync(versionFile, 'utf8').trim(); + // Must be the plist value, not the floating tag + expect(pinned).toBe('1.4.0'); + expect(pinned).not.toMatch(/ccs-bar-latest/i); + }); + + it('no pin write and ASCII notice when readAppBundleVersion returns null', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const appsDir = path.join(tempHome, 'Applications'); + fs.mkdirSync(ccsDir, { recursive: true }); + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + downloadAndExtract: fakeExtract(appsDir), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + // Unreadable Info.plist — returns null + readAppBundleVersion: (_appPath: string) => null, + getCcsDir: () => ccsDir, + getAppsDir: () => appsDir, + }); + + // No pin written + const versionFile = path.join(ccsDir, 'bar', '.version'); + expect(fs.existsSync(versionFile)).toBe(false); + + const allOutput = consoleOutput.join('\n'); + // ASCII notice must appear + expect(allOutput).toMatch(/\[!\].*Info\.plist/i); + // Install itself must still succeed (no [X]) + expect(allOutput).toMatch(/\[OK\].*CCS Bar/); + // No crash — test reaches here + }); + + it('defaultReadAppBundleVersion: extracts CFBundleShortVersionString from XML plist fixture', async () => { + // Test the default implementation directly using a temp file. + // We import the real module (not a mock) to access the default implementation. + // Because the module only exports handleBarInstall and validateDownloadUrl, + // we exercise the default via handleBarInstall without mocking readAppBundleVersion. + const ccsDir = path.join(tempHome, '.ccs'); + const appsDir = path.join(tempHome, 'Applications'); + const appPath = path.join(appsDir, 'CCS Bar.app'); + + // Create a real Info.plist fixture inside the fake .app bundle + const contentsDir = path.join(appPath, 'Contents'); + fs.mkdirSync(contentsDir, { recursive: true }); + fs.writeFileSync( + path.join(contentsDir, 'Info.plist'), + ` + + + + CFBundleIdentifier + com.kaitranntt.CCSBar + CFBundleShortVersionString + 2.7.1 + CFBundleVersion + 271 + +` + ); + + const { handleBarInstall } = await loadInstallSubcommand(); + + // Use production readAppBundleVersion (omit from deps so default is used) + 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 + }, + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + // readAppBundleVersion intentionally omitted → uses production default + getCcsDir: () => ccsDir, + getAppsDir: () => appsDir, + }); + + const versionFile = path.join(ccsDir, 'bar', '.version'); + expect(fs.existsSync(versionFile)).toBe(true); + expect(fs.readFileSync(versionFile, 'utf8').trim()).toBe('2.7.1'); + + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/2\.7\.1/); + }); + + it('defaultReadAppBundleVersion: returns null when Info.plist is missing', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const appsDir = path.join(tempHome, 'Applications'); + const appPath = path.join(appsDir, 'CCS Bar.app'); + + // Create app bundle WITHOUT Info.plist + fs.mkdirSync(appPath, { recursive: true }); + + const { handleBarInstall } = await loadInstallSubcommand(); + + 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 + }, + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + // readAppBundleVersion omitted → uses production default + getCcsDir: () => ccsDir, + getAppsDir: () => appsDir, + }); + + // No pin because plist is missing + const versionFile = path.join(ccsDir, 'bar', '.version'); + expect(fs.existsSync(versionFile)).toBe(false); + + const allOutput = consoleOutput.join('\n'); + // Should emit the ASCII notice + expect(allOutput).toMatch(/\[!\].*Info\.plist/i); + }); +}); + // --------------------------------------------------------------------------- // 5. Port discovery fallback // --------------------------------------------------------------------------- @@ -1101,10 +1306,10 @@ describe('bar install: redirect host re-validation (fix #6)', () => { await handleBarInstall([], { fetchReleaseAsset: async () => ({ downloadUrl: INITIAL_URL, - version: FAKE_VERSION, }), downloadAndExtract: redirectFollowingExtract, - verifyCompat: async () => ({ version: FAKE_VERSION, compatible: true }), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: (_appPath: string) => FAKE_VERSION, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => path.join(tempHome, 'Applications'), }); @@ -1151,10 +1356,10 @@ describe('bar install: zip-slip guard (fix #14)', () => { await handleBarInstall([], { fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, - version: FAKE_VERSION, }), downloadAndExtract: zipSlipExtract, - verifyCompat: async () => ({ version: FAKE_VERSION, compatible: true }), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: (_appPath: string) => FAKE_VERSION, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => path.join(tempHome, 'Applications'), }); @@ -1180,10 +1385,10 @@ describe('bar install: zip-slip guard (fix #14)', () => { await handleBarInstall([], { fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, - version: FAKE_VERSION, }), downloadAndExtract: safeExtract, - verifyCompat: async () => ({ version: FAKE_VERSION, compatible: true }), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: (_appPath: string) => FAKE_VERSION, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -1193,3 +1398,154 @@ describe('bar install: zip-slip guard (fix #14)', () => { expect(allOutput).not.toMatch(/\[X\]/); }); }); + +// --------------------------------------------------------------------------- +// Fix 1 — stale .version cleanup when readAppBundleVersion returns null +// --------------------------------------------------------------------------- + +describe('bar install: stale version-pin removal on null plist read (Fix 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('removes a stale .version pin when readAppBundleVersion returns null, and install still succeeds', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const appsDir = path.join(tempHome, 'Applications'); + const barDir = path.join(ccsDir, 'bar'); + + // Pre-write a stale pin from a previous install + fs.mkdirSync(barDir, { recursive: true }); + fs.writeFileSync(path.join(barDir, '.version'), '9.9.9'); + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + downloadAndExtract: fakeExtract(appsDir), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + // Simulate unreadable Info.plist + readAppBundleVersion: (_appPath: string) => null, + getCcsDir: () => ccsDir, + getAppsDir: () => appsDir, + }); + + // Stale pin must be gone + const versionFile = path.join(barDir, '.version'); + expect(fs.existsSync(versionFile)).toBe(false); + + // Install still reports success (no [X]) + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/\[OK\].*CCS Bar/); + expect(allOutput).not.toMatch(/\[X\]/); + // ASCII notice still appears + expect(allOutput).toMatch(/\[!\].*Info\.plist/i); + }); +}); + +// --------------------------------------------------------------------------- +// Fix 2 — `ccs bar install --help` dispatches to help, not install +// --------------------------------------------------------------------------- + +describe('bar command dispatcher: --help anywhere in args (Fix 2)', () => { + beforeEach(() => { + mock.module('../../../src/commands/bar/launch-subcommand', () => ({ + handleBarLaunch: async (args: string[]) => { + calls.push(`launch:${args.join(' ')}`); + }, + })); + + mock.module('../../../src/commands/bar/install-subcommand', () => ({ + handleBarInstall: async (args: string[]) => { + calls.push(`install:${args.join(' ')}`); + }, + })); + + mock.module('../../../src/commands/bar/uninstall-subcommand', () => ({ + handleBarUninstall: async (args: string[]) => { + calls.push(`uninstall:${args.join(' ')}`); + }, + })); + + mock.module('../../../src/commands/bar/version-subcommand', () => ({ + handleBarVersion: async () => { + calls.push(`version:`); + }, + })); + + mock.module('../../../src/commands/bar/help-subcommand', () => ({ + showHelp: async () => { + calls.push(`help:`); + }, + })); + }); + + it('`ccs bar install --help` dispatches to help and does NOT call install handler', async () => { + const handleBarCommand = await loadHandleBarCommand(); + await handleBarCommand(['install', '--help']); + expect(calls).toContain('help:'); + expect(calls.some((c) => c.startsWith('install:'))).toBe(false); + }); + + it('`ccs bar uninstall -h` dispatches to help and does NOT call uninstall handler', async () => { + const handleBarCommand = await loadHandleBarCommand(); + await handleBarCommand(['uninstall', '-h']); + expect(calls).toContain('help:'); + expect(calls.some((c) => c.startsWith('uninstall:'))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Fix 3 — defaultReadAppBundleVersion: whitespace-only plist value → null +// --------------------------------------------------------------------------- + +describe('bar install: whitespace-only CFBundleShortVersionString yields null (Fix 3)', () => { + it('defaultReadAppBundleVersion returns null for a whitespace-only version string', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const appsDir = path.join(tempHome, 'Applications'); + const appPath = path.join(appsDir, 'CCS Bar.app'); + const contentsDir = path.join(appPath, 'Contents'); + + // Create Info.plist with a whitespace-only CFBundleShortVersionString + fs.mkdirSync(contentsDir, { recursive: true }); + fs.writeFileSync( + path.join(contentsDir, 'Info.plist'), + ` + + + + CFBundleShortVersionString + + +` + ); + + const { handleBarInstall } = await loadInstallSubcommand(); + + 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 + }, + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + // readAppBundleVersion intentionally omitted → uses production default + getCcsDir: () => ccsDir, + getAppsDir: () => appsDir, + }); + + // Whitespace-only version treated as null → no pin written + const versionFile = path.join(ccsDir, 'bar', '.version'); + expect(fs.existsSync(versionFile)).toBe(false); + + // ASCII notice must appear + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/\[!\].*Info\.plist/i); + }); +});