mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 18:18:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user