Merge pull request #1498 from kaitranntt/kai/fix/1497-bar-install-version

fix(bar): install version from app bundle and bar-API capability handshake
This commit is contained in:
Kai (Tam Nhu) Tran
2026-06-10 00:19:04 -04:00
committed by GitHub
7 changed files with 622 additions and 166 deletions
+3
View File
@@ -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.
+56
View File
@@ -0,0 +1,56 @@
import { color, dim, header, initUI, subheader } from '../../utils/ui';
export async function showHelp(): Promise<void> {
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('');
}
+13 -2
View File
@@ -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<void> {
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<void> {
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;
}
+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.');
}
+1
View File
@@ -342,6 +342,7 @@ export const COMMAND_FLAG_SUGGESTIONS: Readonly<Record<string, readonly string[]
'--shell-completion': ['--bash', '--zsh', '--fish', '--powershell', '--force', '-f'],
auth: ['--help', '-h'],
api: ['--help', '-h'],
bar: ['launch', 'install', 'uninstall', 'version', '--version', '--help', '-h'],
cleanup: CLEANUP_FLAGS,
config: ['--help', '-h', '--port', '-p', '--host', '-H', '--dev'],
cursor: ['--help', '-h'],
+2
View File
@@ -290,6 +290,7 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr
},
{ name: 'ccs proxy --help', summary: 'Deep help for the OpenAI-compatible local proxy' },
{ name: 'ccs docker --help', summary: 'Deep help for Docker deployment commands' },
{ name: 'ccs bar --help', summary: 'Deep help for the CCS Bar macOS menu bar app' },
{ name: 'ccs cursor --help', summary: 'Deep help for Cursor runtime/admin commands' },
{ name: 'ccs copilot --help', summary: 'Deep help for deprecated GitHub Copilot commands' },
],
@@ -355,6 +356,7 @@ export async function handleHelpRoute(
cursor: async () => 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']),
+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);
});
});