feat(cli): add ccs bar command for the macOS menu bar app

Add ccs bar (install/launch/uninstall/version) and the ~/.ccs/bar.json
discovery handshake the app reads. install resolves a floating release asset,
follows redirects, validates the download host over HTTPS, checks status,
verifies the extracted app, and runs a real version-compat handshake against
/api/overview.
This commit is contained in:
Tam Nhu Tran
2026-06-07 15:32:51 -04:00
parent 40f32ebbf0
commit 205c2f3cd6
8 changed files with 1772 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
/**
* `ccs bar` command dispatcher
*
* Mirrors the pattern in src/commands/docker/index.ts.
* Subcommands: launch (default), install, uninstall, version / --version.
*/
export async function handleBarCommand(args: string[]): Promise<void> {
const subcommand = args[0];
// --version / version are aliases for the version subcommand
if (subcommand === '--version' || subcommand === 'version') {
const { handleBarVersion } = await import('./version-subcommand');
await handleBarVersion();
return;
}
const commandHandlers: Record<string, (subArgs: string[]) => Promise<void>> = {
launch: async (subArgs) => {
const { handleBarLaunch } = await import('./launch-subcommand');
await handleBarLaunch(subArgs);
},
install: async (subArgs) => {
const { handleBarInstall } = await import('./install-subcommand');
await handleBarInstall(subArgs);
},
uninstall: async (subArgs) => {
const { handleBarUninstall } = await import('./uninstall-subcommand');
await handleBarUninstall(subArgs);
},
};
// Bare `ccs bar` → launch
if (!subcommand || subcommand === 'launch') {
await commandHandlers.launch(subcommand ? args.slice(1) : []);
return;
}
const handler = commandHandlers[subcommand];
if (!handler) {
console.error(`[X] Unknown bar subcommand: ${subcommand}`);
console.error('[i] Usage: ccs bar [launch|install|uninstall|--version]');
return;
}
await handler(args.slice(1));
}
+374
View File
@@ -0,0 +1,374 @@
/**
* `ccs bar install` — download the CCS Bar app from the floating
* `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.
*
* Mirrors the download/version-pin pattern in src/cliproxy/binary-manager.ts.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getCcsDir } from '../../config/config-loader-facade';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Floating release tag — never pin to exact CLI semver. */
const BAR_RELEASE_TAG = 'ccs-bar-latest';
const BAR_APP_NAME = 'CCS Bar.app';
const BAR_ASSET_NAME = 'CCS-Bar.app.zip';
const BAR_GITHUB_REPO = 'kaitranntt/ccs';
/**
* Allowlist of hostnames from which we will accept asset downloads.
* GitHub releases redirect from github.com to objects.githubusercontent.com.
*
* TODO(checksum-v2): once release assets ship a checksums.txt/.sha256 file,
* wire SHA-256 verification here. The download URL is already validated for
* host+HTTPS as a v1 minimum guard. The verifier hook below is the intended
* extension point.
*/
const DOWNLOAD_HOST_ALLOWLIST: ReadonlyArray<string> = [
'github.com',
'objects.githubusercontent.com',
];
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ReleaseAssetResult {
downloadUrl: string;
version: string;
}
export interface CompatResult {
version: string;
compatible: boolean;
}
export interface InstallDeps {
/**
* Resolve the asset download URL + version string from a GitHub release tag.
* Production: calls GitHub API releases/tags/{tag}.
* Test: mock that returns a fake URL + version.
*/
fetchReleaseAsset: (tag: string, asset: string) => Promise<ReleaseAssetResult>;
/**
* Download the zip archive and extract the .app bundle into dest/.
* Production: uses undici to stream + extract (with redirect + status check).
*/
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.
*/
verifyCompat: (baseUrl: string, installedVersion: string) => Promise<CompatResult>;
/** Returns path to ~/.ccs (respects CCS_HOME). */
getCcsDir: () => string;
/** Destination directory for the .app bundle (~/Applications by default). */
getAppsDir: () => string;
}
// ---------------------------------------------------------------------------
// Host allowlist validation (Finding #9)
// ---------------------------------------------------------------------------
/**
* Validate that a download URL is https and its hostname is in the allowlist
* (exact match or *.githubusercontent.com wildcard).
* Throws a descriptive Error if validation fails.
*/
export function validateDownloadUrl(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`Download URL is not a valid URL: ${url}`);
}
if (parsed.protocol !== 'https:') {
throw new Error(`Download URL must use HTTPS, got: ${parsed.protocol} for ${url}`);
}
const host = parsed.hostname.toLowerCase();
const allowed =
(DOWNLOAD_HOST_ALLOWLIST as string[]).includes(host) || host.endsWith('.githubusercontent.com');
if (!allowed) {
throw new Error(
`Download URL hostname "${host}" is not in the trusted allowlist ` +
`(${DOWNLOAD_HOST_ALLOWLIST.join(', ')}, *.githubusercontent.com). ` +
`Refusing to download from untrusted host.`
);
}
}
// ---------------------------------------------------------------------------
// Production implementation helpers
// ---------------------------------------------------------------------------
async function defaultFetchReleaseAsset(tag: string, asset: string): Promise<ReleaseAssetResult> {
const { request } = await import('undici');
const apiUrl = `https://api.github.com/repos/${BAR_GITHUB_REPO}/releases/tags/${tag}`;
const { statusCode, body } = await request(apiUrl, {
headers: {
'User-Agent': 'ccs-cli',
Accept: 'application/vnd.github+json',
},
});
if (statusCode !== 200) {
throw new Error(`GitHub API returned ${statusCode} for tag ${tag}`);
}
// 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);
if (!found) {
throw new Error(`Asset "${asset}" not found in release ${tag}`);
}
return { downloadUrl: found.browser_download_url as string, version };
}
/**
* Download a zip from `url` (following up to 5 GitHub 302 redirects) and
* extract its contents into `dest`.
*
* Fixes applied:
* - Finding #8: pass `maxRedirections: 5` so GitHub's 302 → objects.githubusercontent.com is followed.
* - Finding #11: check `statusCode` and throw a descriptive error before streaming.
* - Finding #9: validate host+HTTPS before making the request.
*/
async function defaultDownloadAndExtract(url: string, dest: string): Promise<void> {
const { request } = await import('undici');
const { createWriteStream, mkdirSync } = fs;
const { promisify } = await import('util');
const { pipeline } = await import('stream');
const streamPipeline = promisify(pipeline);
const { execFile } = await import('child_process');
const execFileAsync = promisify(execFile);
// Finding #9: validate host + HTTPS before any network request.
validateDownloadUrl(url);
mkdirSync(dest, { recursive: true });
const tmpZip = path.join(os.tmpdir(), `ccs-bar-${Date.now()}.zip`);
// Finding #8: maxRedirections:5 ensures GitHub's 302 to objects.githubusercontent.com is followed.
// Finding #11: destructure statusCode and reject non-200 before streaming.
const { statusCode, body } = await request(url, {
maxRedirections: 5,
});
if (statusCode !== 200) {
throw new Error(`Download failed: HTTP ${statusCode} for ${url}`);
}
// Stream body to tmpZip — undici body is a Node ReadableStream
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await streamPipeline(body as any, createWriteStream(tmpZip));
// Extract the zip into dest
await execFileAsync('unzip', ['-o', tmpZip, '-d', dest]);
// Clean up the temp archive
try {
fs.unlinkSync(tmpZip);
} catch {
/* ignore */
}
}
/**
* 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.).
*
* 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.).
*/
async function defaultVerifyCompat(
baseUrl: string,
installedVersion: string
): Promise<CompatResult> {
try {
const { request } = await import('undici');
const { statusCode, body } = await request(`${baseUrl}/api/overview`, {
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 };
}
// 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 };
}
const compatible = installedMajor === serverMajor;
return { version: serverVersion, compatible };
} catch {
// Server unreachable — warn but do not claim compatible.
return { version: 'unknown', compatible: false };
}
}
function defaultGetCcsDir(): string {
return getCcsDir();
}
function defaultGetAppsDir(): string {
return path.join(os.homedir(), 'Applications');
}
// ---------------------------------------------------------------------------
// Version pin helpers
// ---------------------------------------------------------------------------
function getBarVersionFilePath(ccsDir: string): string {
return path.join(ccsDir, 'bar', '.version');
}
function pinBarVersion(ccsDir: string, version: string): void {
const versionFile = getBarVersionFilePath(ccsDir);
fs.mkdirSync(path.dirname(versionFile), { recursive: true });
fs.writeFileSync(versionFile, version);
}
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
export async function handleBarInstall(
_args: string[],
deps: Partial<InstallDeps> = {}
): Promise<void> {
const fetchReleaseAsset = deps.fetchReleaseAsset ?? defaultFetchReleaseAsset;
const downloadAndExtract = deps.downloadAndExtract ?? defaultDownloadAndExtract;
const verifyCompat = deps.verifyCompat ?? defaultVerifyCompat;
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.
let releaseInfo: ReleaseAssetResult;
try {
releaseInfo = await fetchReleaseAsset(BAR_RELEASE_TAG, BAR_ASSET_NAME);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[X] Failed to fetch release asset: ${msg}`);
console.error('[i] Check your network connection and try again.');
return;
}
const { downloadUrl, version } = releaseInfo;
console.log(`[i] Installing CCS Bar v${version} from ${BAR_RELEASE_TAG}...`);
// 2. Download and extract into ~/Applications.
try {
await downloadAndExtract(downloadUrl, appsDir);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[X] Download or extraction failed: ${msg}`);
return;
}
// 3. Finding #12: assert that the expected .app bundle was actually extracted
// before reporting success.
const appPath = path.join(appsDir, BAR_APP_NAME);
if (!fs.existsSync(appPath)) {
// Report what was actually extracted to help diagnose archive issues.
let extracted: string[] = [];
try {
extracted = fs.readdirSync(appsDir);
} catch {
/* ignore */
}
const found = extracted.length > 0 ? extracted.join(', ') : '(none)';
console.error(`[X] Extraction succeeded but "${BAR_APP_NAME}" was not found in ${appsDir}.`);
console.error(`[i] Files found in ${appsDir}: ${found}`);
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.
}
console.log(`[OK] CCS Bar v${version} installed to ${appsDir}/${BAR_APP_NAME}`);
// 5. Version-compat handshake via /api/overview.
// 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';
try {
const raw = fs.readFileSync(barJsonPath, 'utf8');
const parsed = JSON.parse(raw) as { baseUrl?: string };
if (parsed.baseUrl) baseUrl = parsed.baseUrl;
} catch {
/* bar.json absent — use fallback */
}
try {
// Pass the pinned version so verifyCompat can do a real major-version comparison.
const compat = await verifyCompat(baseUrl, version);
if (!compat.compatible) {
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.'
);
} else {
console.log(`[OK] Version compatibility confirmed (server: v${compat.version}).`);
}
} catch {
console.log('[!] Could not verify version compatibility (server may not be running).');
console.log('[i] Run `ccs bar` to start the server and recheck.');
}
// 6. Print Gatekeeper guidance for ad-hoc/unsigned builds.
console.log('');
console.log('[i] Gatekeeper note (ad-hoc build):');
console.log(' If macOS says the app is "damaged" or "unverified", run:');
console.log(` xattr -dr com.apple.quarantine "${path.join(appsDir, BAR_APP_NAME)}"`);
console.log(' Or right-click the app and select Open.');
}
+157
View File
@@ -0,0 +1,157 @@
/**
* `ccs bar launch` — ensure web-server is up, write ~/.ccs/bar.json, open the app.
*
* bar.json shape (v1):
* { baseUrl: string, port: number, authMode: "loopback" }
*
* authMode is always "loopback" for v1 (auth-enabled unsupported until v1.1).
*/
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../../config/config-loader-facade';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface BarDiscoveryJson {
baseUrl: string;
port: number;
authMode: 'loopback';
}
export interface DashboardInfo {
port: number;
baseUrl: string;
}
export interface LaunchDeps {
/**
* Ensure the CCS web-server / dashboard is running.
* Returns { port, baseUrl } of the running server.
* Throws if the server cannot be started (degraded path).
*/
ensureDashboard: () => Promise<DashboardInfo>;
/** Open the installed .app bundle. Throws if the app is not found. */
openApp: (appPath: string) => Promise<void>;
/** Returns path to ~/.ccs (respects CCS_HOME for test isolation). */
getCcsDir: () => string;
/** Full path where the .app should be installed, e.g. ~/Applications/CCS Bar.app */
appInstallPath: string;
}
// ---------------------------------------------------------------------------
// Port discovery helpers (exported for unit tests)
// ---------------------------------------------------------------------------
/**
* Read the port recorded in an existing bar.json.
* Returns null when the file is absent or malformed.
*/
export function resolveBarPort(ccsDir: string): number | null {
const barJsonPath = path.join(ccsDir, 'bar.json');
try {
const raw = fs.readFileSync(barJsonPath, 'utf8');
const parsed = JSON.parse(raw) as Partial<BarDiscoveryJson>;
return typeof parsed.port === 'number' ? parsed.port : null;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Default production dependencies
// ---------------------------------------------------------------------------
async function defaultEnsureDashboard(): Promise<DashboardInfo> {
// Reuse the same startup path as `ccs config`:
// find a free port then start the web-server via startServer().
const getPort = (await import('get-port')).default;
const { startServer } = await import('../../web-server');
const port = await getPort({ port: [3000, 3001, 3002, 8000, 8080] });
const { server } = await startServer({ port });
const addr = server.address();
const resolvedPort = addr && typeof addr === 'object' ? addr.port : port;
const baseUrl = `http://127.0.0.1:${resolvedPort}`;
return { port: resolvedPort, baseUrl };
}
async function defaultOpenApp(appPath: string): Promise<void> {
const { execFile } = await import('child_process');
const { promisify } = await import('util');
const execFileAsync = promisify(execFile);
await execFileAsync('open', ['-a', appPath]);
}
function defaultGetCcsDir(): string {
return getCcsDir();
}
const DEFAULT_APP_INSTALL_PATH = path.join(
process.env.HOME ?? process.env.CCS_HOME ?? '~',
'Applications',
'CCS Bar.app'
);
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
export async function handleBarLaunch(
_args: string[],
deps: Partial<LaunchDeps> = {}
): Promise<void> {
const ensureDashboard = deps.ensureDashboard ?? defaultEnsureDashboard;
const openApp = deps.openApp ?? defaultOpenApp;
const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)();
const appInstallPath = deps.appInstallPath ?? DEFAULT_APP_INSTALL_PATH;
// 1. Ensure the web-server/dashboard is running.
let dashboardInfo: DashboardInfo;
try {
dashboardInfo = await ensureDashboard();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[X] Could not start CCS web-server: ${msg}`);
console.error('[i] Run `ccs config` to start the dashboard manually.');
return;
}
// 2. Write bar.json — this is the single source of discovery for the Swift app.
const barJson: BarDiscoveryJson = {
baseUrl: dashboardInfo.baseUrl,
port: dashboardInfo.port,
authMode: 'loopback',
};
try {
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(path.join(ccsDir, 'bar.json'), JSON.stringify(barJson, null, 2));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[X] Failed to write bar.json: ${msg}`);
return;
}
console.log(`[OK] CCS web-server running at ${dashboardInfo.baseUrl}`);
console.log(`[i] Discovery file written: ${path.join(ccsDir, 'bar.json')}`);
// 3. Open the app.
try {
await openApp(appInstallPath);
console.log('[OK] CCS Bar launched.');
} catch {
// Degraded path: app not installed or open failed.
if (!fs.existsSync(appInstallPath)) {
console.log('[!] CCS Bar app is not installed.');
console.log('[i] Run `ccs bar install` to install it.');
} else {
console.log('[!] Could not open CCS Bar. Try right-clicking and selecting Open.');
console.log('[i] If Gatekeeper blocks the app, run:');
console.log(` xattr -dr com.apple.quarantine "${appInstallPath}"`);
}
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* `ccs bar uninstall` — remove CCS Bar.app from ~/Applications
* and clear the version pin at ~/.ccs/bar/.version.
*
* No-op (and no error) when neither the app nor the pin exists.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getCcsDir } from '../../config/config-loader-facade';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface UninstallDeps {
getCcsDir: () => string;
getAppsDir: () => string;
appName: string;
}
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
export async function handleBarUninstall(
_args: string[],
deps: Partial<UninstallDeps> = {}
): Promise<void> {
const ccsDir = (deps.getCcsDir ?? (() => getCcsDir()))();
const appsDir = (deps.getAppsDir ?? (() => path.join(os.homedir(), 'Applications')))();
const appName = deps.appName ?? 'CCS Bar.app';
const appPath = path.join(appsDir, appName);
const versionPin = path.join(ccsDir, 'bar', '.version');
let removed = false;
// Remove the .app bundle (a directory on macOS).
if (fs.existsSync(appPath)) {
try {
fs.rmSync(appPath, { recursive: true, force: true });
console.log(`[OK] Removed ${appPath}`);
removed = true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[X] Failed to remove ${appPath}: ${msg}`);
}
}
// Clear the version pin.
if (fs.existsSync(versionPin)) {
try {
fs.unlinkSync(versionPin);
removed = true;
} catch {
// Non-fatal — pin may already be gone.
}
}
if (!removed) {
console.log('[i] CCS Bar is not installed — nothing to remove.');
} else {
console.log('[OK] CCS Bar uninstalled.');
console.log('[i] Run `ccs bar install` to reinstall.');
}
}
+37
View File
@@ -0,0 +1,37 @@
/**
* `ccs bar --version` / `ccs bar version`
*
* Prints the CCS CLI version alongside the installed CCS Bar app version
* (read from ~/.ccs/bar/.version, if present).
*/
import * as fs from 'fs';
import * as path from 'path';
import { getVersion } from '../../utils/version';
import { getCcsDir } from '../../config/config-loader-facade';
function readInstalledBarVersion(ccsDir: string): string | null {
const versionFile = path.join(ccsDir, 'bar', '.version');
try {
const content = fs.readFileSync(versionFile, 'utf8').trim();
return content || null;
} catch {
return null;
}
}
export async function handleBarVersion(): Promise<void> {
const cliVersion = getVersion();
const ccsDir = getCcsDir();
const barVersion = readInstalledBarVersion(ccsDir);
// Finding #13: label each line unambiguously — CLI version vs installed Bar app version.
console.log(`[i] CCS CLI v${cliVersion}`);
if (barVersion) {
console.log(`[i] CCS Bar app: v${barVersion}`);
} else {
console.log('[i] CCS Bar app: not installed (run `ccs bar install`)');
}
process.exit(0);
}
+6
View File
@@ -139,6 +139,12 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [
group: 'operations',
visibility: 'public',
},
{
name: 'bar',
summary: 'Install and launch the CCS macOS menu bar app',
group: 'operations',
visibility: 'public',
},
{
name: 'sync',
summary: 'Sync delegation commands and skills',
+7
View File
@@ -193,6 +193,13 @@ export const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
await handleSetupCommand(args);
},
},
{
name: 'bar',
handle: async (args) => {
const { handleBarCommand } = await import('./bar');
await handleBarCommand(args);
},
},
];
export async function tryHandleRootCommand(args: string[]): Promise<boolean> {
File diff suppressed because it is too large Load Diff