From 9ae6f85fa7d8f204dc9821d1f9c0fde77a44cc1b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 10 Jun 2026 00:11:19 -0400 Subject: [PATCH 1/3] feat(bar): add ccs bar --help with docker-style help screen --help and -h are honored anywhere in the args so 'ccs bar install --help' shows help instead of running the installer. Also wires the 'ccs help bar' topic, shell-completion flag suggestions, and a more-help table entry. --- src/commands/bar/help-subcommand.ts | 56 +++++++++++++++++++++++++++++ src/commands/bar/index.ts | 15 ++++++-- src/commands/command-catalog.ts | 1 + src/commands/help-command.ts | 2 ++ 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 src/commands/bar/help-subcommand.ts 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/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']), From c572d9f8608a14d1150b6c9cf8064fd9e6f77417 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 10 Jun 2026 00:11:29 -0400 Subject: [PATCH 2/3] fix(bar): read app version from Info.plist and verify bar API instead of version majors The floating ccs-bar-latest release tag carries no version, so deriving the app version from tag_name printed 'vccs-bar-latest' and pinned that literal string. The version now comes from CFBundleShortVersionString in the extracted bundle's Info.plist; an unreadable plist skips pinning and clears any stale pin. The post-install check compared app semver major against the CCS server major, but the app is versioned independently of the CLI, so the mismatch warning fired on every install. It is now a capability handshake against GET /api/bar/summary: 200 confirms the server serves the bar API, 404 warns the server predates CCS Bar, and anything else keeps the soft warning. Install never hard-fails on the handshake. Closes #1497 --- src/commands/bar/install-subcommand.ts | 177 ++++---- tests/unit/commands/bar-command.test.ts | 534 ++++++++++++++++++++---- 2 files changed, 547 insertions(+), 164 deletions(-) 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/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); + }); +}); From e7f3ec0da1142efa1536ad3f4b95ddfdde097e25 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 10 Jun 2026 00:11:38 -0400 Subject: [PATCH 3/3] docs(bar): align install docs with Info.plist version pinning and bar-API check --- docs/ccs-bar.md | 3 +++ 1 file changed, 3 insertions(+) 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.