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
This commit is contained in:
Tam Nhu Tran
2026-06-10 00:11:29 -04:00
parent 9ae6f85fa7
commit c572d9f860
2 changed files with 547 additions and 164 deletions
+102 -75
View File
@@ -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<string> = [
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<ReleaseAssetResult>;
/**
@@ -66,11 +70,16 @@ export interface InstallDeps {
*/
downloadAndExtract: (url: string, dest: string) => Promise<void>;
/**
* 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<CompatResult>;
verifyCompat: (baseUrl: string) => Promise<CompatResult>;
/**
* 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<Rel
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const release = (await body.json()) as any;
const tagName: string = release.tag_name ?? tag;
// Strip leading 'v' for the version pin file.
const version = tagName.replace(/^v/, '');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const found = (release.assets as any[]).find((a: { name: string }) => a.name === asset);
@@ -141,7 +147,7 @@ async function defaultFetchReleaseAsset(tag: string, asset: string): Promise<Rel
throw new Error(`Asset "${asset}" not found in release ${tag}`);
}
return { downloadUrl: found.browser_download_url as string, version };
return { downloadUrl: found.browser_download_url as string };
}
/**
@@ -271,56 +277,59 @@ async function defaultDownloadAndExtract(url: string, dest: string): Promise<voi
}
/**
* Call GET {baseUrl}/api/overview and compare server version against the
* installed bar version. Returns compat=false whenever the comparison cannot
* be made (server unreachable, version strings unparseable, etc.).
* Single-request capability handshake: GET {baseUrl}/api/bar/summary.
* 200 → server serves the bar API (compatible).
* 404 → server is too old and does not serve the bar API.
* Any other status or network error → soft-warn (unreachable).
*
* Fix for Finding #10: replaces the phantom check that always returned true.
* Compatible is defined as same semver major (0 vs 0, 1 vs 1, etc.).
* The route is loopback-gated server-side; install-time baseUrl is always
* loopback (bar.json baseUrl or http://127.0.0.1:3000), so the gate passes.
*/
async function defaultVerifyCompat(
baseUrl: string,
installedVersion: string
): Promise<CompatResult> {
async function defaultVerifyCompat(baseUrl: string): Promise<CompatResult> {
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 = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/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.');
}
+445 -89
View File
@@ -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'),
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>com.kaitranntt.CCSBar</string>
<key>CFBundleShortVersionString</key>
<string>2.7.1</string>
<key>CFBundleVersion</key>
<string>271</string>
</dict>
</plist>`
);
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'),
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleShortVersionString</key>
<string> </string>
</dict>
</plist>`
);
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);
});
});