From b5ef06a4d5fe0ea2a83110947a2772b8ea87ee3f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 15 Jun 2026 17:13:47 -0400 Subject: [PATCH] feat(bar): run the CCS server detached with serve/stop/status ccs bar now spawns the web-server as a detached background process and exits immediately instead of hosting it in the foreground, so closing the terminal no longer takes the bar offline. Adds 'bar serve' (the long-lived host), 'bar stop', and 'bar status', and records ~/.ccs/bar/launch.json at install so the app can start the server without a shell PATH. Refs #1526 --- src/commands/bar/bar-paths.ts | 50 ++ src/commands/bar/bar-server-probe.ts | 77 ++ src/commands/bar/help-subcommand.ts | 12 +- src/commands/bar/index.ts | 21 +- src/commands/bar/install-subcommand.ts | 33 + src/commands/bar/launch-subcommand.ts | 327 ++++--- src/commands/bar/serve-subcommand.ts | 214 +++++ src/commands/bar/status-subcommand.ts | 142 +++ src/commands/bar/stop-subcommand.ts | 109 +++ tests/unit/commands/bar-command.test.ts | 128 ++- .../bar-lifecycle-subcommands.test.ts | 850 ++++++++++++++++++ 11 files changed, 1754 insertions(+), 209 deletions(-) create mode 100644 src/commands/bar/bar-paths.ts create mode 100644 src/commands/bar/bar-server-probe.ts create mode 100644 src/commands/bar/serve-subcommand.ts create mode 100644 src/commands/bar/status-subcommand.ts create mode 100644 src/commands/bar/stop-subcommand.ts create mode 100644 tests/unit/commands/bar-lifecycle-subcommands.test.ts diff --git a/src/commands/bar/bar-paths.ts b/src/commands/bar/bar-paths.ts new file mode 100644 index 00000000..c8edfe93 --- /dev/null +++ b/src/commands/bar/bar-paths.ts @@ -0,0 +1,50 @@ +/** + * Shared path helpers for the `ccs bar` command family. + * + * Centralises the paths under ~/.ccs/bar/ so every subcommand stays DRY. + * All paths derive from ccsDir (i.e. getCcsDir()) so CCS_HOME isolation + * works correctly in tests. + */ + +import * as path from 'path'; + +/** launch.json — consumed by the Swift app to spawn the server without a shell PATH. */ +export function getLaunchJsonPath(ccsDir: string): string { + return path.join(ccsDir, 'bar', 'launch.json'); +} + +/** server.pid — PID of the live detached server process. */ +export function getServerPidPath(ccsDir: string): string { + return path.join(ccsDir, 'bar', 'server.pid'); +} + +/** serve.log — stdout/stderr of the detached server process. */ +export function getServeLogPath(ccsDir: string): string { + return path.join(ccsDir, 'bar', 'serve.log'); +} + +/** bar.json — live discovery file consumed by the Swift app. */ +export function getBarJsonPath(ccsDir: string): string { + return path.join(ccsDir, 'bar.json'); +} + +/** bar/ subdirectory (parent of all bar artefacts). */ +export function getBarDir(ccsDir: string): string { + return path.join(ccsDir, 'bar'); +} + +/** Schema version constant for launch.json. */ +export const LAUNCH_JSON_SCHEMA = 1; + +/** Shape of launch.json written by install and refreshed by launch. */ +export interface LaunchJson { + schema: typeof LAUNCH_JSON_SCHEMA; + /** Absolute path to the node/bun binary (process.execPath). */ + runtime: string; + /** Absolute CCS entry point + subcommand args: [process.argv[1], 'bar', 'serve']. */ + args: string[]; + /** os.homedir() — cwd for the spawned server. */ + home: string; + /** CCS_HOME env value when set; omitted otherwise. */ + ccsHome?: string; +} diff --git a/src/commands/bar/bar-server-probe.ts b/src/commands/bar/bar-server-probe.ts new file mode 100644 index 00000000..c4c05a0e --- /dev/null +++ b/src/commands/bar/bar-server-probe.ts @@ -0,0 +1,77 @@ +/** + * CCS Bar — server liveness probe utilities. + * + * Shared by launch-subcommand.ts and serve-subcommand.ts so neither imports + * from the other (which would create a cross-module dependency that breaks + * Bun's test module isolation when cache-busting URLs are used). + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +export interface DashboardInfo { + port: number; + baseUrl: string; +} + +/** + * 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<{ port: number }>; + return typeof parsed.port === 'number' ? parsed.port : null; + } catch { + return null; + } +} + +/** + * Probe candidate ports for a running CCS server. + * + * Both IPv4 (127.0.0.1) and IPv6 (::1) loopback addresses are probed for each + * port. All probes are fired concurrently so worst-case latency is ~1.5 s + * (one timeout) rather than N × 1.5 s sequentially. Priority selection is + * applied after all results are in: the bar.json port is preferred over the + * defaults, and within a port 127.0.0.1 is preferred over [::1]. + */ +export async function defaultFindRunningServer(ccsDir: string): Promise { + const { request } = await import('undici'); + + async function probe(url: string): Promise<{ ok: boolean }> { + try { + const { statusCode, body } = await request(url, { + method: 'GET', + headersTimeout: 1500, + bodyTimeout: 1500, + }); + await body.text(); + return { ok: statusCode === 200 }; + } catch { + return { ok: false }; + } + } + + const barJsonPort = resolveBarPort(ccsDir); + const base = [3000, 3001, 3002, 8000, 8080]; + const candidates: number[] = + barJsonPort !== null ? [barJsonPort, ...base.filter((p) => p !== barJsonPort)] : base; + + const probeTargets = candidates.flatMap((port) => [ + { port, baseUrl: `http://127.0.0.1:${port}`, url: `http://127.0.0.1:${port}/api/bar/summary` }, + { port, baseUrl: `http://[::1]:${port}`, url: `http://[::1]:${port}/api/bar/summary` }, + ]); + + const results = await Promise.all(probeTargets.map((t) => probe(t.url))); + + for (let i = 0; i < probeTargets.length; i++) { + if (results[i].ok) { + const { port, baseUrl } = probeTargets[i]; + return { port, baseUrl }; + } + } + return null; +} diff --git a/src/commands/bar/help-subcommand.ts b/src/commands/bar/help-subcommand.ts index 5d9c37dd..b244a277 100644 --- a/src/commands/bar/help-subcommand.ts +++ b/src/commands/bar/help-subcommand.ts @@ -13,7 +13,9 @@ export async function showHelp(): Promise { [ 'Commands:', [ - ['launch', 'Start the web-server, write ~/.ccs/bar.json, and open the app (default)'], + ['launch', 'Spawn the server detached, write ~/.ccs/bar.json, open the app (default)'], + ['stop', 'Stop the detached CCS Bar server'], + ['status', 'Show whether the CCS Bar server is running'], ['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'], @@ -36,7 +38,9 @@ export async function showHelp(): Promise { [ 'Examples:', [ - ['ccs bar', 'Start the web-server and open CCS Bar (default launch)'], + ['ccs bar', 'Start the server detached and open CCS Bar'], + ['ccs bar stop', 'Stop the detached CCS Bar server'], + ['ccs bar status', 'Show server running state and PID'], ['ccs bar install', 'Download and install CCS Bar, then prompt to launch'], ['ccs bar install --launch', 'Install and launch immediately (no prompt)'], ['ccs bar install --no-launch', 'Install without launching'], @@ -56,6 +60,10 @@ export async function showHelp(): Promise { } console.log(dim(' macOS only. The app communicates with the CCS web-server on localhost only.')); + console.log( + dim(' `ccs bar launch` spawns the server detached — the terminal is freed immediately.') + ); + console.log(dim(' The server persists until stopped with `ccs bar stop` or system reboot.')); console.log( dim( ' Gatekeeper quarantine is cleared automatically after install. If the app is still blocked,' diff --git a/src/commands/bar/index.ts b/src/commands/bar/index.ts index 6ae1e57f..27ecb28b 100644 --- a/src/commands/bar/index.ts +++ b/src/commands/bar/index.ts @@ -2,7 +2,11 @@ * `ccs bar` command dispatcher * * Mirrors the pattern in src/commands/docker/index.ts. - * Subcommands: launch (default), install, uninstall, version / --version, help / --help / -h. + * Subcommands: launch (default), serve, stop, status, install, uninstall, + * version / --version, help / --help / -h. + * + * `serve` is the long-lived server host (spawned detached by `launch` and by + * the Swift app). `stop` and `status` manage the detached server lifecycle. */ import { hasAnyFlag } from '../arg-extractor'; @@ -11,7 +15,6 @@ export async function handleBarCommand(args: string[]): Promise { const subcommand = args[0]; // --help / -h anywhere in args (e.g. `ccs bar install --help`) → show help. - // Mirrors docker/index.ts: hasAnyFlag(normalizedArgs, ['--help', '-h']). // Also dispatch bare `help` subcommand for symmetry. if (hasAnyFlag(args, ['--help', '-h']) || subcommand === 'help') { const { showHelp } = await import('./help-subcommand'); @@ -31,6 +34,18 @@ export async function handleBarCommand(args: string[]): Promise { const { handleBarLaunch } = await import('./launch-subcommand'); await handleBarLaunch(subArgs); }, + serve: async (subArgs) => { + const { handleBarServe } = await import('./serve-subcommand'); + await handleBarServe(subArgs); + }, + stop: async (subArgs) => { + const { handleBarStop } = await import('./stop-subcommand'); + await handleBarStop(subArgs); + }, + status: async (subArgs) => { + const { handleBarStatus } = await import('./status-subcommand'); + await handleBarStatus(subArgs); + }, install: async (subArgs) => { const { handleBarInstall } = await import('./install-subcommand'); await handleBarInstall(subArgs); @@ -50,7 +65,7 @@ export async function handleBarCommand(args: string[]): Promise { const handler = commandHandlers[subcommand]; if (!handler) { console.error(`[X] Unknown bar subcommand: ${subcommand}`); - console.error('[i] Usage: ccs bar [launch|install|uninstall|version|--help]'); + console.error('[i] Usage: ccs bar [launch|serve|stop|status|install|uninstall|version|--help]'); return; } diff --git a/src/commands/bar/install-subcommand.ts b/src/commands/bar/install-subcommand.ts index c51d740a..f2d20790 100644 --- a/src/commands/bar/install-subcommand.ts +++ b/src/commands/bar/install-subcommand.ts @@ -20,6 +20,8 @@ import * as os from 'os'; import * as path from 'path'; import { getCcsDir } from '../../config/config-loader-facade'; import { hasAnyFlag } from '../arg-extractor'; +import { getLaunchJsonPath, LAUNCH_JSON_SCHEMA } from './bar-paths'; +import type { LaunchJson } from './bar-paths'; // --------------------------------------------------------------------------- // Constants @@ -118,6 +120,13 @@ export interface InstallDeps { * Throws on failure; caller catches and aborts install. */ removeExistingApp: (appPath: string) => void; + /** + * Write launch.json so the Swift app can spawn the server without a shell PATH. + * Called after successful install so the descriptor is always fresh. + * Injectable for tests — asserts the file is written without real fs side-effects. + * Non-fatal when it throws (install already succeeded). + */ + writeLaunchDescriptor: (jsonPath: string, descriptor: LaunchJson) => void; } // --------------------------------------------------------------------------- @@ -398,6 +407,11 @@ function defaultRemoveExistingApp(appPath: string): void { fs.rmSync(appPath, { recursive: true, force: true }); } +function defaultWriteLaunchDescriptor(jsonPath: string, descriptor: LaunchJson): void { + fs.mkdirSync(path.dirname(jsonPath), { recursive: true }); + fs.writeFileSync(jsonPath, JSON.stringify(descriptor, null, 2)); +} + async function defaultLaunchBar(args: string[]): Promise { const { handleBarLaunch } = await import('./launch-subcommand'); await handleBarLaunch(args); @@ -483,6 +497,7 @@ export async function handleBarInstall( const promptLaunch = deps.promptLaunch ?? defaultPromptLaunch; const isBarRunning = deps.isBarRunning ?? defaultIsBarRunning; const removeExistingApp = deps.removeExistingApp ?? defaultRemoveExistingApp; + const writeLaunchDescriptor = deps.writeLaunchDescriptor ?? defaultWriteLaunchDescriptor; const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)(); const appsDir = (deps.getAppsDir ?? defaultGetAppsDir)(); @@ -616,6 +631,24 @@ export async function handleBarInstall( console.log('[!] Could not read app version from Info.plist.'); } + // 4b. Write launch.json so the Swift app can spawn the server without a shell PATH. + // Non-fatal — install has already succeeded at this point. + try { + const launchJsonPath = getLaunchJsonPath(ccsDir); + const launchDescriptor: LaunchJson = { + schema: LAUNCH_JSON_SCHEMA, + runtime: process.execPath, + args: [process.argv[1], 'bar', 'serve'], + home: os.homedir(), + ...(process.env.CCS_HOME ? { ccsHome: process.env.CCS_HOME } : {}), + }; + writeLaunchDescriptor(launchJsonPath, launchDescriptor); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[!] Could not write launch.json: ${msg}`); + // Non-fatal — continue to quarantine clear + launch handoff. + } + // 5. Capability handshake via GET /api/bar/summary. // Runs BEFORE the quarantine-clear + launch handoff because it is a server-side // check unrelated to Gatekeeper. A failed quarantine clear must not prevent the diff --git a/src/commands/bar/launch-subcommand.ts b/src/commands/bar/launch-subcommand.ts index 605981e5..5a449638 100644 --- a/src/commands/bar/launch-subcommand.ts +++ b/src/commands/bar/launch-subcommand.ts @@ -1,21 +1,47 @@ /** - * `ccs bar launch` — ensure web-server is up, write ~/.ccs/bar.json, open the app. + * `ccs bar launch` — start the CCS Bar server detached, write 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). + * Detached model (replaces the old in-process model): + * 1. Probe candidate ports (bar.json port first, then 3000/3001/3002/8000/8080). + * 2. If a live server is found → reuse it, write bar.json, open app, return. + * 3. Else → refresh launch.json, getPort to pick a free port, spawn + * `ccs bar serve --port N` detached with stdio → serve.log, poll + * /api/bar/summary until 200 (timeout ~10 s), write bar.json, open app, + * return. The CLI process exits; the server continues as a detached child. * - * Reuse-first behavior: before starting a new server, launch probes the candidate - * ports (bar.json port first, then 3000, 3001, 3002, 8000, 8080) for a live CCS - * server at GET /api/bar/summary. A 200 response means the server is already - * running; launch reuses it without attempting to bind a new one. + * All side-effectful deps are injectable so tests can run without real + * servers, ports, or file-system writes. */ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { getCcsDir } from '../../config/config-loader-facade'; +import { + getBarDir, + getBarJsonPath, + getLaunchJsonPath, + getServeLogPath, + LAUNCH_JSON_SCHEMA, +} from './bar-paths'; +import type { LaunchJson } from './bar-paths'; +import { + defaultFindRunningServer as _defaultFindRunningServer, + resolveBarPort as _resolveBarPort, +} from './bar-server-probe'; +import type { DashboardInfo as _DashboardInfo } from './bar-server-probe'; + +// --------------------------------------------------------------------------- +// Re-exports — backward compat for tests that import from this module. +// resolveBarPort + defaultFindRunningServer are canonical in bar-server-probe.ts; +// we re-export them so existing imports from launch-subcommand continue to work. +// --------------------------------------------------------------------------- + +export { _defaultFindRunningServer as defaultFindRunningServer }; +export { _resolveBarPort as resolveBarPort }; // --------------------------------------------------------------------------- // Types @@ -27,10 +53,7 @@ export interface BarDiscoveryJson { authMode: 'loopback'; } -export interface DashboardInfo { - port: number; - baseUrl: string; -} +export type DashboardInfo = _DashboardInfo; export interface LaunchDeps { /** @@ -40,11 +63,24 @@ export interface LaunchDeps { */ findRunningServer: () => Promise; /** - * Ensure the CCS web-server / dashboard is running. - * Returns { port, baseUrl } of the running server. - * Throws if the server cannot be started (degraded path). + * Find a free port from the candidate list. + * Used to pre-select a port before spawning the detached server. */ - ensureDashboard: () => Promise; + getPort: (opts: { port: number[]; host: string }) => Promise; + /** + * Spawn the `ccs bar serve --port N` process detached and return immediately. + * The spawned process must be unref()ed so the launcher can exit. + */ + spawnDetachedServer: (port: number, logPath: string) => void; + /** + * Poll GET {baseUrl}/api/bar/summary until HTTP 200 or timeout. + * Returns the live baseUrl on success, throws on timeout. + */ + waitForServerLive: (baseUrl: string) => Promise; + /** + * Write launch.json so the Swift app can spawn the server independently. + */ + writeLaunchDescriptor: (jsonPath: string, descriptor: LaunchJson) => void; /** Open the installed .app bundle. Throws if the app is not found. */ openApp: (appPath: string) => Promise; /** Returns path to ~/.ccs (respects CCS_HOME for test isolation). */ @@ -53,114 +89,68 @@ export interface LaunchDeps { 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; - return typeof parsed.port === 'number' ? parsed.port : null; - } catch { - return null; - } -} - // --------------------------------------------------------------------------- // Default production dependencies // --------------------------------------------------------------------------- -/** - * Probe candidate ports for a running CCS server. - * - * Both IPv4 (127.0.0.1) and IPv6 (::1) loopback addresses are probed for - * each port. This is necessary because `ccs config` starts the server with - * host 'localhost', which macOS resolves to ::1 — an IPv4-only probe would - * miss that server and start a redundant second instance. - * - * All probes are fired concurrently (cheap loopback requests) so worst-case - * latency is ~1.5 s (one timeout) rather than N × 1.5 s sequentially. - * Priority selection is still applied after all results are in: the bar.json - * port is preferred over the defaults (3000, 3001, 3002, 8000, 8080), and - * within a port 127.0.0.1 is preferred over [::1]. - */ -export async function defaultFindRunningServer(ccsDir: string): Promise { - const { request } = await import('undici'); +async function defaultGetPort(opts: { port: number[]; host: string }): Promise { + const getPort = (await import('get-port')).default; + return getPort(opts); +} - /** - * Probe a single URL. Resolves to { ok: true } on HTTP 200, { ok: false } - * on any other status or error (connection refused, timeout, etc.). - * Never rejects — all errors are absorbed so Promise.all never short-circuits. - */ - async function probe(url: string): Promise<{ ok: boolean }> { +/** + * Spawn `ccs bar serve --port N` detached so it outlives this CLI process. + * + * stdio is redirected to serve.log so server output is preserved for + * debugging without a terminal. unref() lets the launcher exit immediately. + */ +function defaultSpawnDetachedServer(port: number, logPath: string): void { + const { spawn } = require('child_process') as typeof import('child_process'); + + // Open (or create) the log file for appending. + const logFd = fs.openSync(logPath, 'a'); + const child = spawn(process.execPath, [process.argv[1], 'bar', 'serve', '--port', String(port)], { + detached: true, + stdio: ['ignore', logFd, logFd], + cwd: os.homedir(), + env: process.env, + }); + child.unref(); + // Close our copy of the fd — the child has its own reference. + fs.closeSync(logFd); +} + +/** + * Poll GET {baseUrl}/api/bar/summary every 250 ms until HTTP 200 or ~10 s. + * Resolves when the server is live. Rejects on timeout. + */ +async function defaultWaitForServerLive(baseUrl: string): Promise { + const { request } = await import('undici'); + const INTERVAL_MS = 250; + const TIMEOUT_MS = 10_000; + const deadline = Date.now() + TIMEOUT_MS; + + while (Date.now() < deadline) { try { - const { statusCode, body } = await request(url, { + const { statusCode, body } = await request(`${baseUrl}/api/bar/summary`, { method: 'GET', headersTimeout: 1500, bodyTimeout: 1500, }); - // Drain the body to avoid hanging sockets regardless of status. - // body.dump() is not available in Bun's undici shim; body.text() works - // cross-runtime and fully consumes the response stream. await body.text(); - return { ok: statusCode === 200 }; + if (statusCode === 200) return; } catch { - return { ok: false }; + /* not yet live — keep polling */ } + await new Promise((resolve) => setTimeout(resolve, INTERVAL_MS)); } - const barJsonPort = resolveBarPort(ccsDir); - const base = [3000, 3001, 3002, 8000, 8080]; - const candidates: number[] = - barJsonPort !== null ? [barJsonPort, ...base.filter((p) => p !== barJsonPort)] : base; - - // Build the ordered list of (port, host, baseUrl) tuples. Within each port, - // IPv4 comes before IPv6 to preserve the existing priority semantics. - const probeTargets = candidates.flatMap((port) => [ - { port, baseUrl: `http://127.0.0.1:${port}`, url: `http://127.0.0.1:${port}/api/bar/summary` }, - { port, baseUrl: `http://[::1]:${port}`, url: `http://[::1]:${port}/api/bar/summary` }, - ]); - - // Fire all probes concurrently. Each probe resolves (never rejects), so - // Promise.all collects all results without short-circuiting on failure. - const results = await Promise.all(probeTargets.map((t) => probe(t.url))); - - // Walk the results in priority order and return the first successful hit. - for (let i = 0; i < probeTargets.length; i++) { - if (results[i].ok) { - const { port, baseUrl } = probeTargets[i]; - return { port, baseUrl }; - } - } - return null; + throw new Error(`CCS Bar server did not become live at ${baseUrl} within ${TIMEOUT_MS / 1000}s`); } -async function defaultEnsureDashboard(): Promise { - // 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'); - - // Pass host: '127.0.0.1' so getPort probes the same address that startServer - // will bind. On macOS, a wildcard bind and a specific 127.0.0.1 bind are - // independent: getPort without a host can return 3000 "free" while a live - // CCS server already holds 127.0.0.1:3000, causing EADDRINUSE on startServer. - const port = await getPort({ port: [3000, 3001, 3002, 8000, 8080], host: '127.0.0.1' }); - // Bind IPv4 loopback explicitly. Without a host, startServer defaults to - // 'localhost', which on macOS resolves to ::1 (IPv6) — but bar.json's baseUrl - // (and the Swift app) use 127.0.0.1, so the app could not reach its own server. - const { server } = await startServer({ port, host: '127.0.0.1' }); - - 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 }; +function defaultWriteLaunchDescriptor(jsonPath: string, descriptor: LaunchJson): void { + fs.mkdirSync(path.dirname(jsonPath), { recursive: true }); + fs.writeFileSync(jsonPath, JSON.stringify(descriptor, null, 2)); } async function defaultOpenApp(appPath: string): Promise { @@ -175,8 +165,6 @@ function defaultGetCcsDir(): string { } // Fix #5: use os.homedir() to match install-subcommand.ts and uninstall-subcommand.ts. -// process.env.HOME may be unset in restricted environments, and CCS_HOME is the CCS -// data directory (~/.ccs), not the user's home — neither is a safe fallback here. const DEFAULT_APP_INSTALL_PATH = path.join(os.homedir(), 'Applications', 'CCS Bar.app'); // --------------------------------------------------------------------------- @@ -187,69 +175,136 @@ export async function handleBarLaunch( _args: string[], deps: Partial = {} ): Promise { - const ensureDashboard = deps.ensureDashboard ?? defaultEnsureDashboard; - const openApp = deps.openApp ?? defaultOpenApp; const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)(); + const openApp = deps.openApp ?? defaultOpenApp; const appInstallPath = deps.appInstallPath ?? DEFAULT_APP_INSTALL_PATH; + const getPortFn = deps.getPort ?? defaultGetPort; + const spawnDetachedServer = deps.spawnDetachedServer ?? defaultSpawnDetachedServer; + const waitForServerLive = deps.waitForServerLive ?? defaultWaitForServerLive; + const writeLaunchDescriptor = deps.writeLaunchDescriptor ?? defaultWriteLaunchDescriptor; - // Wire findRunningServer after ccsDir is resolved so the default impl can - // read bar.json from the correct directory. - const findRunningServer = deps.findRunningServer ?? (() => defaultFindRunningServer(ccsDir)); + // Wire findRunningServer after ccsDir is resolved. + const findRunningServer = deps.findRunningServer ?? (() => _defaultFindRunningServer(ccsDir)); - // 1. Try to reuse an already-running CCS server; fall back to starting one. - // Probe errors are treated as "not found" — they never abort the launch flow. + const barJsonPath = getBarJsonPath(ccsDir); + const launchJsonPath = getLaunchJsonPath(ccsDir); + + // 1. Probe for an already-running server. let running: DashboardInfo | null = null; try { running = await findRunningServer(); } catch { - // Any probe error counts as null; ensureDashboard() is the fallback. + /* any probe error counts as null */ } - let dashboardInfo: DashboardInfo; - let reused = false; if (running !== null) { - dashboardInfo = running; - reused = true; - } else { + // Reuse the live server — write bar.json and open the app. + const barJson: BarDiscoveryJson = { + baseUrl: running.baseUrl, + port: running.port, + authMode: 'loopback', + }; try { - dashboardInfo = await ensureDashboard(); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync(barJsonPath, JSON.stringify(barJson, null, 2)); } 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.'); + console.error(`[X] Failed to write bar.json: ${msg}`); return; } + console.log(`[OK] Reusing running CCS web-server at ${running.baseUrl}`); + console.log(`[i] Discovery file written: ${barJsonPath}`); + await _openAppWithFallback(appInstallPath, openApp); + return; } - // 2. Write bar.json — this is the single source of discovery for the Swift app. + // 2. No live server — pick a port, write/refresh launch.json, spawn detached. + + // 2a. Pick a free port. + let port: number; + try { + port = await getPortFn({ port: [3000, 3001, 3002, 8000, 8080], host: '127.0.0.1' }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[X] Could not find a free port: ${msg}`); + return; + } + + // 2b. Write/refresh launch.json so the Swift app can self-start next time. + const launchDescriptor: LaunchJson = { + schema: LAUNCH_JSON_SCHEMA, + runtime: process.execPath, + args: [process.argv[1], 'bar', 'serve'], + home: os.homedir(), + ...(process.env.CCS_HOME ? { ccsHome: process.env.CCS_HOME } : {}), + }; + try { + writeLaunchDescriptor(launchJsonPath, launchDescriptor); + } catch (err) { + // Non-fatal — the Swift app falls back to resolving `ccs` via PATH. + const msg = err instanceof Error ? err.message : String(err); + console.error(`[!] Could not write launch.json: ${msg}`); + } + + // 2c. Spawn the detached server. + const serveLogPath = getServeLogPath(ccsDir); + const baseUrl = `http://127.0.0.1:${port}`; + try { + fs.mkdirSync(getBarDir(ccsDir), { recursive: true }); + spawnDetachedServer(port, serveLogPath); + } 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; + } + + console.log('[i] Starting CCS Bar server...'); + + // 2d. Poll until live or timeout. + try { + await waitForServerLive(baseUrl); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[X] Could not connect to CCS web-server: ${msg}`); + console.error(`[i] Check logs at ${serveLogPath}`); + return; + } + + // 2e. Write bar.json. const barJson: BarDiscoveryJson = { - baseUrl: dashboardInfo.baseUrl, - port: dashboardInfo.port, + baseUrl, + port, authMode: 'loopback', }; - try { fs.mkdirSync(ccsDir, { recursive: true }); - fs.writeFileSync(path.join(ccsDir, 'bar.json'), JSON.stringify(barJson, null, 2)); + fs.writeFileSync(barJsonPath, 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; } - if (reused) { - console.log(`[OK] Reusing running CCS web-server at ${dashboardInfo.baseUrl}`); - } else { - console.log(`[OK] CCS web-server running at ${dashboardInfo.baseUrl}`); - } - console.log(`[i] Discovery file written: ${path.join(ccsDir, 'bar.json')}`); + console.log(`[OK] CCS web-server running at ${baseUrl}`); + console.log(`[i] Discovery file written: ${barJsonPath}`); - // 3. Open the app. + // 3. Open the app — then return (process exits; server continues detached). + await _openAppWithFallback(appInstallPath, openApp); +} + +// --------------------------------------------------------------------------- +// Internal helper — open app with graceful degraded path +// --------------------------------------------------------------------------- + +async function _openAppWithFallback( + appInstallPath: string, + openApp: (p: string) => Promise +): Promise { 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.'); diff --git a/src/commands/bar/serve-subcommand.ts b/src/commands/bar/serve-subcommand.ts new file mode 100644 index 00000000..e0769baa --- /dev/null +++ b/src/commands/bar/serve-subcommand.ts @@ -0,0 +1,214 @@ +/** + * `ccs bar serve` — long-lived server host for CCS Bar. + * + * Reuse-or-start: probe candidate ports first. If a live server exists, + * write bar.json pointing at it and exit 0 (no double-start). Otherwise + * pick a free port, call startServer(), write bar.json + server.pid, + * then stay alive until SIGINT/SIGTERM. + * + * This is the process that `ccs bar launch` spawns detached. The Swift + * app also spawns it directly via launch.json. + * + * Accepts: --port N (honours a port the launcher pre-selected via getPort). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../../config/config-loader-facade'; +import { getBarJsonPath, getServerPidPath } from './bar-paths'; +import { defaultFindRunningServer } from './bar-server-probe'; +import type { DashboardInfo } from './bar-server-probe'; +import type { BarDiscoveryJson } from './launch-subcommand'; + +// --------------------------------------------------------------------------- +// Types — injectable deps for testability +// --------------------------------------------------------------------------- + +export interface ServeDeps { + /** Probe candidate ports for a running CCS server. Never throws. */ + findRunningServer: () => Promise; + /** + * Start the CCS web-server on the given port. + * Returns { port, baseUrl } of the bound server. + * The returned server keeps the process event loop alive while listening. + */ + startServer: (opts: { port: number; host: string }) => Promise; + /** + * Find a free port from the candidate list. + * Returns one of the candidates or another free port. + */ + getPort: (opts: { port: number[]; host: string }) => Promise; + /** Returns ~/.ccs dir (respects CCS_HOME). */ + getCcsDir: () => string; + /** Write a file, creating parent dirs as needed. */ + writeFile: (filePath: string, content: string) => void; + /** Remove a file if it exists (for cleanup on exit). */ + removeFile: (filePath: string) => void; + /** Register process signal handlers. */ + onSignal: (signal: 'SIGINT' | 'SIGTERM', handler: () => void) => void; + /** Exit the process. */ + exit: (code: number) => never; +} + +// --------------------------------------------------------------------------- +// Default implementations +// --------------------------------------------------------------------------- + +async function defaultStartServer(opts: { port: number; host: string }): Promise { + const { startServer } = await import('../../web-server'); + const { server } = await startServer({ port: opts.port, host: opts.host }); + const addr = server.address(); + const resolvedPort = addr && typeof addr === 'object' ? addr.port : opts.port; + const baseUrl = `http://127.0.0.1:${resolvedPort}`; + return { port: resolvedPort, baseUrl }; +} + +async function defaultGetPort(opts: { port: number[]; host: string }): Promise { + const getPort = (await import('get-port')).default; + return getPort(opts); +} + +function defaultWriteFile(filePath: string, content: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +function defaultRemoveFile(filePath: string): void { + try { + fs.unlinkSync(filePath); + } catch { + /* ignore — file may already be gone */ + } +} + +function defaultOnSignal(signal: 'SIGINT' | 'SIGTERM', handler: () => void): void { + process.on(signal, handler); +} + +function defaultExit(code: number): never { + process.exit(code); +} + +function defaultGetCcsDir(): string { + return getCcsDir(); +} + +// --------------------------------------------------------------------------- +// Argument parsing helpers +// --------------------------------------------------------------------------- + +function parsePortArg(args: string[]): number | null { + const idx = args.indexOf('--port'); + if (idx !== -1 && idx + 1 < args.length) { + const n = parseInt(args[idx + 1], 10); + return Number.isFinite(n) && n > 0 && n < 65536 ? n : null; + } + return null; +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +export async function handleBarServe(args: string[], deps: Partial = {}): Promise { + const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)(); + const findRunningServer = deps.findRunningServer ?? (() => defaultFindRunningServer(ccsDir)); + const startServerFn = deps.startServer ?? defaultStartServer; + const getPortFn = deps.getPort ?? defaultGetPort; + const writeFile = deps.writeFile ?? defaultWriteFile; + const removeFile = deps.removeFile ?? defaultRemoveFile; + const onSignal = deps.onSignal ?? defaultOnSignal; + const exit = deps.exit ?? defaultExit; + + const barJsonPath = getBarJsonPath(ccsDir); + const serverPidPath = getServerPidPath(ccsDir); + + // 1. Reuse-or-start: probe candidate ports first. + let running: DashboardInfo | null = null; + try { + running = await findRunningServer(); + } catch { + /* probe errors count as null */ + } + + if (running !== null) { + // A live server is already running — write bar.json and exit cleanly. + const barJson: BarDiscoveryJson = { + baseUrl: running.baseUrl, + port: running.port, + authMode: 'loopback', + }; + try { + writeFile(barJsonPath, 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}`); + exit(1); + } + console.log(`[OK] CCS server already running at ${running.baseUrl} — reusing.`); + exit(0); + } + + // 2. No live server found — start one. + // Honor --port N from the launcher (it pre-selected via getPort to avoid races). + const requestedPort = parsePortArg(args); + let port: number; + if (requestedPort !== null) { + port = requestedPort; + } else { + port = await getPortFn({ port: [3000, 3001, 3002, 8000, 8080], host: '127.0.0.1' }); + } + + // TypeScript cannot infer that exit(1) is `never` when it is injected as a dep, + // so we use a definite-assignment assertion on dashboardInfo. + + let dashboardInfo!: DashboardInfo; + try { + dashboardInfo = await startServerFn({ port, host: '127.0.0.1' }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[X] Failed to start CCS server: ${msg}`); + exit(1); + } + + // 3. Write bar.json and server.pid. + const barJson: BarDiscoveryJson = { + baseUrl: dashboardInfo.baseUrl, + port: dashboardInfo.port, + authMode: 'loopback', + }; + try { + writeFile(barJsonPath, 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}`); + exit(1); + } + + try { + writeFile(serverPidPath, String(process.pid)); + } catch (err) { + // Non-fatal — stop/status will just degrade gracefully. + const msg = err instanceof Error ? err.message : String(err); + console.error(`[!] Failed to write server.pid: ${msg}`); + } + + console.log(`[OK] CCS Bar server started at ${dashboardInfo.baseUrl}`); + console.log(`[i] PID ${process.pid} — stop with \`ccs bar stop\``); + + // 4. Clean shutdown on SIGINT / SIGTERM. + const shutdown = (): void => { + removeFile(serverPidPath); + // bar.json is intentionally left in place on clean shutdown so + // the Swift app self-heal poll can detect the server is gone via + // the liveness check, not a stale discovery file. + console.log('\n[OK] CCS Bar server stopped.'); + exit(0); + }; + + onSignal('SIGINT', shutdown); + onSignal('SIGTERM', shutdown); + + // 5. Stay alive — the startServer() HTTP server keeps the Node/Bun event loop + // alive while listening. Nothing else needed here. +} diff --git a/src/commands/bar/status-subcommand.ts b/src/commands/bar/status-subcommand.ts new file mode 100644 index 00000000..24e25602 --- /dev/null +++ b/src/commands/bar/status-subcommand.ts @@ -0,0 +1,142 @@ +/** + * `ccs bar status` — report whether the CCS Bar server is running. + * + * Checks server.pid for the PID, then verifies the process is alive and + * the server is reachable at GET /api/bar/summary. ASCII output only. + */ + +import * as fs from 'fs'; +import { getCcsDir } from '../../config/config-loader-facade'; +import { getBarJsonPath, getServerPidPath } from './bar-paths'; + +// --------------------------------------------------------------------------- +// Types — injectable deps +// --------------------------------------------------------------------------- + +export interface StatusDeps { + /** Returns ~/.ccs dir (respects CCS_HOME). */ + getCcsDir: () => string; + /** + * Read the server.pid file. Returns the raw string content, or null when + * absent or unreadable. + */ + readPidFile: (pidPath: string) => string | null; + /** + * Check whether a process is alive. + * Uses kill(pid, 0) semantics: no error = alive, ESRCH = gone. + * Returns true when alive, false otherwise. + */ + isProcessAlive: (pid: number) => boolean; + /** + * Probe whether the server is reachable at GET {baseUrl}/api/bar/summary. + * Returns true on HTTP 200, false otherwise. Never throws. + */ + probeServer: (baseUrl: string) => Promise; + /** + * Read bar.json and return the baseUrl field, or null when absent/malformed. + */ + readBarJsonBaseUrl: (barJsonPath: string) => string | null; +} + +// --------------------------------------------------------------------------- +// Default implementations +// --------------------------------------------------------------------------- + +function defaultGetCcsDir(): string { + return getCcsDir(); +} + +function defaultReadPidFile(pidPath: string): string | null { + try { + return fs.readFileSync(pidPath, 'utf8').trim(); + } catch { + return null; + } +} + +function defaultIsProcessAlive(pid: number): boolean { + try { + // kill(pid, 0) is a POSIX trick: sends no signal but checks if the + // process exists and is accessible. Throws ESRCH when gone. + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function defaultProbeServer(baseUrl: string): Promise { + try { + const { request } = await import('undici'); + const { statusCode, body } = await request(`${baseUrl}/api/bar/summary`, { + method: 'GET', + headersTimeout: 2000, + bodyTimeout: 2000, + }); + // Drain body to release the socket. + await body.text(); + return statusCode === 200; + } catch { + return false; + } +} + +function defaultReadBarJsonBaseUrl(barJsonPath: string): string | null { + try { + const raw = fs.readFileSync(barJsonPath, 'utf8'); + const parsed = JSON.parse(raw) as Partial<{ baseUrl: string }>; + return typeof parsed.baseUrl === 'string' ? parsed.baseUrl : null; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +export async function handleBarStatus( + _args: string[], + deps: Partial = {} +): Promise { + const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)(); + const readPidFile = deps.readPidFile ?? defaultReadPidFile; + const isProcessAlive = deps.isProcessAlive ?? defaultIsProcessAlive; + const probeServer = deps.probeServer ?? defaultProbeServer; + const readBarJsonBaseUrl = deps.readBarJsonBaseUrl ?? defaultReadBarJsonBaseUrl; + + const pidPath = getServerPidPath(ccsDir); + const barJsonPath = getBarJsonPath(ccsDir); + + // 1. Check PID file. + const pidRaw = readPidFile(pidPath); + if (pidRaw === null) { + console.log('[i] CCS Bar server: stopped (no server.pid)'); + return; + } + + const pid = parseInt(pidRaw, 10); + if (!Number.isFinite(pid) || pid <= 0) { + console.log(`[!] CCS Bar server: server.pid is invalid ("${pidRaw}")`); + return; + } + + // 2. Check process liveness. + const alive = isProcessAlive(pid); + if (!alive) { + console.log(`[!] CCS Bar server: PID ${pid} is no longer running (stale server.pid)`); + console.log('[i] Run `ccs bar stop` to clean up, then `ccs bar` to restart.'); + return; + } + + // 3. Probe HTTP reachability. + const baseUrl = readBarJsonBaseUrl(barJsonPath) ?? 'http://127.0.0.1:3000'; + const reachable = await probeServer(baseUrl); + + if (reachable) { + console.log(`[OK] CCS Bar server: running (PID ${pid}, ${baseUrl})`); + } else { + console.log(`[!] CCS Bar server: PID ${pid} alive but HTTP probe failed at ${baseUrl}`); + console.log('[i] The server may still be starting up. Try again in a moment.'); + } +} diff --git a/src/commands/bar/stop-subcommand.ts b/src/commands/bar/stop-subcommand.ts new file mode 100644 index 00000000..edca8033 --- /dev/null +++ b/src/commands/bar/stop-subcommand.ts @@ -0,0 +1,109 @@ +/** + * `ccs bar stop` — stop the detached CCS Bar server. + * + * Reads ~/.ccs/bar/server.pid, sends SIGTERM, then removes pid + bar.json. + * ASCII output only. Non-fatal if the server is already gone. + */ + +import * as fs from 'fs'; +import { getCcsDir } from '../../config/config-loader-facade'; +import { getBarJsonPath, getServerPidPath } from './bar-paths'; + +// --------------------------------------------------------------------------- +// Types — injectable deps +// --------------------------------------------------------------------------- + +export interface StopDeps { + /** Returns ~/.ccs dir (respects CCS_HOME). */ + getCcsDir: () => string; + /** + * Read the server.pid file. Returns the raw string content, or null when + * the file is absent or unreadable. + */ + readPidFile: (pidPath: string) => string | null; + /** + * Send SIGTERM to the given PID. + * Throws if the signal cannot be delivered (e.g. ESRCH — no such process). + */ + killProcess: (pid: number, signal: 'SIGTERM') => void; + /** Remove a file, ignoring errors if absent. */ + removeFile: (filePath: string) => void; +} + +// --------------------------------------------------------------------------- +// Default implementations +// --------------------------------------------------------------------------- + +function defaultGetCcsDir(): string { + return getCcsDir(); +} + +function defaultReadPidFile(pidPath: string): string | null { + try { + return fs.readFileSync(pidPath, 'utf8').trim(); + } catch { + return null; + } +} + +function defaultKillProcess(pid: number, signal: 'SIGTERM'): void { + process.kill(pid, signal); +} + +function defaultRemoveFile(filePath: string): void { + try { + fs.unlinkSync(filePath); + } catch { + /* ignore — file may already be gone */ + } +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +export async function handleBarStop(_args: string[], deps: Partial = {}): Promise { + const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)(); + const readPidFile = deps.readPidFile ?? defaultReadPidFile; + const killProcess = deps.killProcess ?? defaultKillProcess; + const removeFile = deps.removeFile ?? defaultRemoveFile; + + const pidPath = getServerPidPath(ccsDir); + const barJsonPath = getBarJsonPath(ccsDir); + + // 1. Read the PID file. + const pidRaw = readPidFile(pidPath); + if (pidRaw === null) { + console.log('[i] CCS Bar server is not running (no server.pid found).'); + return; + } + + const pid = parseInt(pidRaw, 10); + if (!Number.isFinite(pid) || pid <= 0) { + console.error(`[X] server.pid contains an invalid PID: "${pidRaw}"`); + // Clean up the corrupted file so subsequent runs start fresh. + removeFile(pidPath); + return; + } + + // 2. Send SIGTERM. + try { + killProcess(pid, 'SIGTERM'); + console.log(`[OK] Sent SIGTERM to CCS Bar server (PID ${pid}).`); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ESRCH') { + // Process no longer exists — stale PID file, clean up silently. + console.log(`[i] Server PID ${pid} is no longer running. Cleaning up stale files.`); + } else { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[X] Failed to stop server (PID ${pid}): ${msg}`); + // Still remove the pid file so the user is not blocked. + } + } + + // 3. Remove pid + bar.json regardless of kill result. + removeFile(pidPath); + removeFile(barJsonPath); + console.log('[i] Removed server.pid and bar.json.'); +} diff --git a/tests/unit/commands/bar-command.test.ts b/tests/unit/commands/bar-command.test.ts index 9c1f7c8d..cf6b102c 100644 --- a/tests/unit/commands/bar-command.test.ts +++ b/tests/unit/commands/bar-command.test.ts @@ -261,26 +261,39 @@ describe('root-command-router registers bar', () => { // 3. bar.json contract written by launch // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Detached-model launch dep helpers (shared by tests in this describe block) +// --------------------------------------------------------------------------- + +/** + * Build a minimal set of detached-model deps that behave like a successful + * launch for most tests. Individual tests override specific deps as needed. + */ +function makeDetachedDeps(ccsDir: string, port = 4242) { + return { + findRunningServer: async () => null, + getPort: async () => port, + spawnDetachedServer: (_p: number, _log: string) => { /* noop */ }, + waitForServerLive: async (_url: string) => { /* live immediately */ }, + writeLaunchDescriptor: () => { /* noop */ }, + openApp: async (_appPath: string) => { /* noop */ }, + getCcsDir: () => ccsDir, + appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + }; +} + describe('bar.json contract (launch subcommand)', () => { it('writes ~/.ccs/bar.json with correct shape when server starts', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); - // Mock dependencies injected into handleBarLaunch - const mockEnsureDashboard = async () => ({ port: 4242, baseUrl: 'http://127.0.0.1:4242' }); - const mockOpenApp = async (_appPath: string) => { - calls.push(`open:${_appPath}`); - }; - const mockGetCcsDir = () => ccsDir; - const { handleBarLaunch } = await loadLaunchSubcommand(); await handleBarLaunch([], { - findRunningServer: async () => null, - ensureDashboard: mockEnsureDashboard, - openApp: mockOpenApp, - getCcsDir: mockGetCcsDir, - appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + ...makeDetachedDeps(ccsDir, 4242), + openApp: async (_appPath: string) => { + calls.push(`open:${_appPath}`); + }, }); const barJsonPath = path.join(ccsDir, 'bar.json'); @@ -300,15 +313,7 @@ describe('bar.json contract (launch subcommand)', () => { const { handleBarLaunch } = await loadLaunchSubcommand(); - await handleBarLaunch([], { - findRunningServer: async () => null, - ensureDashboard: async () => ({ port: 9000, baseUrl: 'http://127.0.0.1:9000' }), - openApp: async () => { - /* noop */ - }, - getCcsDir: () => ccsDir, - appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), - }); + await handleBarLaunch([], makeDetachedDeps(ccsDir, 9000)); const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as { authMode: string; @@ -322,16 +327,14 @@ describe('bar.json contract (launch subcommand)', () => { const { handleBarLaunch } = await loadLaunchSubcommand(); - // App doesn't exist at appInstallPath + // App doesn't exist at appInstallPath — openApp throws so degraded path runs. const nonExistentApp = path.join(tempHome, 'Applications', 'CCS Bar.app'); await handleBarLaunch([], { - findRunningServer: async () => null, - ensureDashboard: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }), + ...makeDetachedDeps(ccsDir, 3000), openApp: async () => { throw new Error('App not found'); }, - getCcsDir: () => ccsDir, appInstallPath: nonExistentApp, }); @@ -347,13 +350,10 @@ describe('bar.json contract (launch subcommand)', () => { const { handleBarLaunch } = await loadLaunchSubcommand(); await handleBarLaunch([], { - findRunningServer: async () => null, - ensureDashboard: async () => ({ port: 3001, baseUrl: 'http://127.0.0.1:3001' }), + ...makeDetachedDeps(ccsDir, 3001), openApp: async () => { throw new Error('open failed'); }, - getCcsDir: () => ccsDir, - appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), }); // bar.json should still be written despite open failure @@ -361,22 +361,17 @@ describe('bar.json contract (launch subcommand)', () => { expect(fs.existsSync(barJsonPath)).toBe(true); }); - it('prints degraded-path warning when server cannot start', async () => { + it('prints degraded-path warning when spawnDetachedServer throws', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); const { handleBarLaunch } = await loadLaunchSubcommand(); await handleBarLaunch([], { - findRunningServer: async () => null, - ensureDashboard: async () => { - throw new Error('port busy'); + ...makeDetachedDeps(ccsDir, 3000), + spawnDetachedServer: () => { + throw new Error('spawn failed'); }, - openApp: async () => { - /* noop */ - }, - getCcsDir: () => ccsDir, - appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), }); const allOutput = consoleOutput.join('\n'); @@ -1588,27 +1583,25 @@ describe('bar command dispatcher: --help anywhere in args (Fix 2)', () => { // --------------------------------------------------------------------------- describe('launch: findRunningServer reuse-first (GH-1500)', () => { - it('reuses a running server: ensureDashboard NOT called; bar.json has reused port/baseUrl', async () => { + it('reuses a running server: spawnDetachedServer NOT called; bar.json has reused port/baseUrl', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); - let ensureCalled = false; + let spawnCalled = false; const { handleBarLaunch } = await loadLaunchSubcommand(); await handleBarLaunch([], { findRunningServer: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }), - ensureDashboard: async () => { - ensureCalled = true; - return { port: 9999, baseUrl: 'http://127.0.0.1:9999' }; - }, - openApp: async () => { - /* noop */ - }, + getPort: async () => 9999, + spawnDetachedServer: () => { spawnCalled = true; }, + waitForServerLive: async () => { /* noop */ }, + writeLaunchDescriptor: () => { /* noop */ }, + openApp: async () => { /* noop */ }, getCcsDir: () => ccsDir, appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), }); - expect(ensureCalled).toBe(false); + expect(spawnCalled).toBe(false); const barJson = JSON.parse( fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8') @@ -1624,48 +1617,44 @@ describe('launch: findRunningServer reuse-first (GH-1500)', () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); - let ensureCalled = false; + let spawnCalled = false; const { handleBarLaunch } = await loadLaunchSubcommand(); await handleBarLaunch([], { findRunningServer: async () => null, - ensureDashboard: async () => { - ensureCalled = true; - return { port: 4242, baseUrl: 'http://127.0.0.1:4242' }; - }, - openApp: async () => { - /* noop */ - }, + getPort: async () => 4242, + spawnDetachedServer: () => { spawnCalled = true; }, + waitForServerLive: async () => { /* live */ }, + writeLaunchDescriptor: () => { /* noop */ }, + openApp: async () => { /* noop */ }, getCcsDir: () => ccsDir, appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), }); - expect(ensureCalled).toBe(true); + expect(spawnCalled).toBe(true); }); - it('treats findRunningServer throw as null: ensureDashboard called; no crash', async () => { + it('treats findRunningServer throw as null: spawnDetachedServer called; no crash', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); - let ensureCalled = false; + let spawnCalled = false; const { handleBarLaunch } = await loadLaunchSubcommand(); await handleBarLaunch([], { findRunningServer: async () => { throw new Error('probe exploded'); }, - ensureDashboard: async () => { - ensureCalled = true; - return { port: 4242, baseUrl: 'http://127.0.0.1:4242' }; - }, - openApp: async () => { - /* noop */ - }, + getPort: async () => 4242, + spawnDetachedServer: () => { spawnCalled = true; }, + waitForServerLive: async () => { /* live */ }, + writeLaunchDescriptor: () => { /* noop */ }, + openApp: async () => { /* noop */ }, getCcsDir: () => ccsDir, appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), }); - expect(ensureCalled).toBe(true); + expect(spawnCalled).toBe(true); // Should not have printed any bind error const allOutput = consoleOutput.join('\n'); expect(allOutput).not.toMatch(/Could not start/); @@ -1685,7 +1674,10 @@ describe('launch: bar.json contract (deterministic — GH-1500 null probe)', () await handleBarLaunch([], { findRunningServer: async () => null, - ensureDashboard: async () => ({ port: 4242, baseUrl: 'http://127.0.0.1:4242' }), + getPort: async () => 4242, + spawnDetachedServer: () => { /* noop */ }, + waitForServerLive: async () => { /* live */ }, + writeLaunchDescriptor: () => { /* noop */ }, openApp: async (_appPath: string) => { calls.push(`open:${_appPath}`); }, diff --git a/tests/unit/commands/bar-lifecycle-subcommands.test.ts b/tests/unit/commands/bar-lifecycle-subcommands.test.ts new file mode 100644 index 00000000..97abd5e1 --- /dev/null +++ b/tests/unit/commands/bar-lifecycle-subcommands.test.ts @@ -0,0 +1,850 @@ +/** + * Tests for the new CCS Bar lifecycle subcommands: + * serve-subcommand.ts — reuse-vs-start, pid write, signal cleanup + * stop-subcommand.ts — SIGTERM, pid + bar.json removal, stale-pid handling + * status-subcommand.ts — running/stopped/alive-but-unreachable states + * launch-subcommand.ts — detached-spawn path (injects spawnDetachedServer) + * install-subcommand.ts — writeLaunchDescriptor called after install + * + * All deps are injected — never touches real ~/.ccs, real ports, or real processes. + * Follows the DI + platform-fork patterns from bar-command.test.ts. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let consoleOutput: string[] = []; +let tempHome: string; +let originalCcsHome: string | undefined; +let originalConsoleLog: typeof console.log; +let originalConsoleError: typeof console.error; + +function captureConsole(): void { + originalConsoleLog = console.log; + originalConsoleError = console.error; + console.log = (...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }; + console.error = (...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }; +} + +function restoreConsole(): void { + console.log = originalConsoleLog; + console.error = originalConsoleError; +} + +function allOutput(): string { + return consoleOutput.join('\n'); +} + +let moduleSeq = 0; + +async function loadServeSubcommand() { + moduleSeq++; + const mod = await import( + `../../../src/commands/bar/serve-subcommand?test=${Date.now()}-${moduleSeq}` + ); + return mod as { + handleBarServe: (args: string[], deps?: Record) => Promise; + }; +} + +async function loadStopSubcommand() { + moduleSeq++; + const mod = await import( + `../../../src/commands/bar/stop-subcommand?test=${Date.now()}-${moduleSeq}` + ); + return mod as { + handleBarStop: (args: string[], deps?: Record) => Promise; + }; +} + +async function loadStatusSubcommand() { + moduleSeq++; + const mod = await import( + `../../../src/commands/bar/status-subcommand?test=${Date.now()}-${moduleSeq}` + ); + return mod as { + handleBarStatus: (args: string[], deps?: Record) => Promise; + }; +} + +async function loadLaunchSubcommand() { + moduleSeq++; + const mod = await import( + `../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}` + ); + return mod as { + handleBarLaunch: (args: string[], deps?: Record) => Promise; + }; +} + +async function loadInstallSubcommand() { + moduleSeq++; + const mod = await import( + `../../../src/commands/bar/install-subcommand?test=${Date.now()}-${moduleSeq}` + ); + return mod as { + handleBarInstall: (args: string[], deps?: Record) => Promise; + }; +} + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- + +beforeEach(() => { + consoleOutput = []; + captureConsole(); + + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-lifecycle-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; +}); + +afterEach(() => { + restoreConsole(); + + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + try { + fs.rmSync(tempHome, { recursive: true, force: true }); + } catch { + /* ignore */ + } +}); + +// --------------------------------------------------------------------------- +// serve-subcommand: reuse-or-start +// --------------------------------------------------------------------------- + +describe('serve: reuse existing server', () => { + it('writes bar.json and exits 0 when a server is already live', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const exitCodes: number[] = []; + const writtenFiles: Record = {}; + + const { handleBarServe } = await loadServeSubcommand(); + + await handleBarServe([], { + getCcsDir: () => ccsDir, + findRunningServer: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }), + startServer: async () => { + throw new Error('startServer should NOT be called when reusing'); + }, + getPort: async () => 3000, + writeFile: (filePath: string, content: string) => { + writtenFiles[filePath] = content; + }, + removeFile: () => { + /* noop */ + }, + onSignal: () => { + /* noop */ + }, + exit: (code: number) => { + exitCodes.push(code); + throw new Error(`__EXIT_${code}__`); + }, + }).catch((e: Error) => { + if (!e.message.startsWith('__EXIT_')) throw e; + }); + + expect(exitCodes).toContain(0); + // bar.json must reference the reused server + const barJsonPath = path.join(ccsDir, 'bar.json'); + expect(writtenFiles[barJsonPath]).toBeDefined(); + const barJson = JSON.parse(writtenFiles[barJsonPath]) as { port: number; baseUrl: string }; + expect(barJson.port).toBe(3000); + expect(barJson.baseUrl).toBe('http://127.0.0.1:3000'); + expect(allOutput()).toMatch(/reusing/i); + }); +}); + +describe('serve: start new server', () => { + it('calls startServer, writes bar.json + server.pid, registers signal handlers', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const writtenFiles: Record = {}; + const signals: string[] = []; + + const { handleBarServe } = await loadServeSubcommand(); + + // serve does NOT exit after starting — it stays alive. So we just await it + // and assert side-effects. In tests the event loop will drain because + // startServer returns immediately (no real HTTP server binding). + await handleBarServe(['--port', '4242'], { + getCcsDir: () => ccsDir, + findRunningServer: async () => null, + startServer: async (opts: { port: number; host: string }) => ({ + port: opts.port, + baseUrl: `http://127.0.0.1:${opts.port}`, + }), + getPort: async () => 4242, + writeFile: (filePath: string, content: string) => { + writtenFiles[filePath] = content; + }, + removeFile: () => { + /* noop */ + }, + onSignal: (signal: string) => { + signals.push(signal); + }, + exit: (code: number) => { + throw new Error(`__EXIT_${code}__`); + }, + }); + + // bar.json must be written + const barJsonPath = path.join(ccsDir, 'bar.json'); + expect(writtenFiles[barJsonPath]).toBeDefined(); + const barJson = JSON.parse(writtenFiles[barJsonPath]) as { port: number; authMode: string }; + expect(barJson.port).toBe(4242); + expect(barJson.authMode).toBe('loopback'); + + // server.pid must be written + const pidPath = path.join(ccsDir, 'bar', 'server.pid'); + expect(writtenFiles[pidPath]).toBeDefined(); + expect(writtenFiles[pidPath]).toBe(String(process.pid)); + + // Both SIGINT and SIGTERM handlers registered + expect(signals).toContain('SIGINT'); + expect(signals).toContain('SIGTERM'); + + expect(allOutput()).toMatch(/\[OK\].*started/i); + }); + + it('honors --port N arg passed by the launcher', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const usedPorts: number[] = []; + const { handleBarServe } = await loadServeSubcommand(); + + await handleBarServe(['--port', '8080'], { + getCcsDir: () => ccsDir, + findRunningServer: async () => null, + startServer: async (opts: { port: number; host: string }) => { + usedPorts.push(opts.port); + return { port: opts.port, baseUrl: `http://127.0.0.1:${opts.port}` }; + }, + getPort: async () => 3000, // should NOT be used when --port is given + writeFile: () => { + /* noop */ + }, + removeFile: () => { + /* noop */ + }, + onSignal: () => { + /* noop */ + }, + exit: (code: number) => { + throw new Error(`__EXIT_${code}__`); + }, + }); + + expect(usedPorts).toContain(8080); + expect(usedPorts).not.toContain(3000); + }); + + it('exits 1 when startServer throws', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const exitCodes: number[] = []; + const { handleBarServe } = await loadServeSubcommand(); + + await handleBarServe([], { + getCcsDir: () => ccsDir, + findRunningServer: async () => null, + startServer: async () => { + throw new Error('EADDRINUSE'); + }, + getPort: async () => 3000, + writeFile: () => { + /* noop */ + }, + removeFile: () => { + /* noop */ + }, + onSignal: () => { + /* noop */ + }, + exit: (code: number) => { + exitCodes.push(code); + throw new Error(`__EXIT_${code}__`); + }, + }).catch((e: Error) => { + if (!e.message.startsWith('__EXIT_')) throw e; + }); + + expect(exitCodes).toContain(1); + expect(allOutput()).toMatch(/\[X\]/); + }); +}); + +// --------------------------------------------------------------------------- +// stop-subcommand +// --------------------------------------------------------------------------- + +describe('stop: SIGTERM and cleanup', () => { + it('reads server.pid, sends SIGTERM, removes pid + bar.json', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const barDir = path.join(ccsDir, 'bar'); + fs.mkdirSync(barDir, { recursive: true }); + fs.writeFileSync(path.join(barDir, 'server.pid'), '12345'); + fs.writeFileSync(path.join(ccsDir, 'bar.json'), '{"baseUrl":"http://127.0.0.1:3000"}'); + + const killed: Array<{ pid: number; signal: string }> = []; + const removed: string[] = []; + + const { handleBarStop } = await loadStopSubcommand(); + + await handleBarStop([], { + getCcsDir: () => ccsDir, + readPidFile: (pidPath: string) => { + try { + return fs.readFileSync(pidPath, 'utf8').trim(); + } catch { + return null; + } + }, + killProcess: (pid: number, signal: string) => { + killed.push({ pid, signal }); + }, + removeFile: (filePath: string) => { + removed.push(filePath); + }, + }); + + expect(killed).toEqual([{ pid: 12345, signal: 'SIGTERM' }]); + // Both pid and bar.json must be removed + expect(removed.some((p) => p.includes('server.pid'))).toBe(true); + expect(removed.some((p) => p.includes('bar.json'))).toBe(true); + expect(allOutput()).toMatch(/\[OK\].*SIGTERM/i); + }); + + it('prints guidance and returns cleanly when no server.pid exists', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const { handleBarStop } = await loadStopSubcommand(); + + await expect( + handleBarStop([], { + getCcsDir: () => ccsDir, + readPidFile: () => null, + killProcess: () => { + throw new Error('should not be called'); + }, + removeFile: () => { + /* noop */ + }, + }) + ).resolves.toBeUndefined(); + + expect(allOutput()).toMatch(/not running|no server\.pid/i); + }); + + it('cleans up stale pid file when process does not exist (ESRCH)', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const removed: string[] = []; + const { handleBarStop } = await loadStopSubcommand(); + + await handleBarStop([], { + getCcsDir: () => ccsDir, + readPidFile: () => '99999', + killProcess: () => { + const err = new Error('no such process') as NodeJS.ErrnoException; + err.code = 'ESRCH'; + throw err; + }, + removeFile: (filePath: string) => { + removed.push(filePath); + }, + }); + + // pid file must still be cleaned up + expect(removed.some((p) => p.includes('server.pid'))).toBe(true); + expect(allOutput()).toMatch(/no longer running|stale/i); + }); + + it('handles invalid PID in server.pid gracefully', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const removed: string[] = []; + const { handleBarStop } = await loadStopSubcommand(); + + await handleBarStop([], { + getCcsDir: () => ccsDir, + readPidFile: () => 'not-a-number', + killProcess: () => { + throw new Error('should not be called'); + }, + removeFile: (filePath: string) => { + removed.push(filePath); + }, + }); + + expect(allOutput()).toMatch(/\[X\].*invalid/i); + // Corrupted pid file must be cleaned up + expect(removed.some((p) => p.includes('server.pid'))).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// status-subcommand +// --------------------------------------------------------------------------- + +describe('status: running state reporting', () => { + it('reports running + reachable when pid alive and HTTP probe succeeds', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const { handleBarStatus } = await loadStatusSubcommand(); + + await handleBarStatus([], { + getCcsDir: () => ccsDir, + readPidFile: () => '12345', + isProcessAlive: () => true, + probeServer: async () => true, + readBarJsonBaseUrl: () => 'http://127.0.0.1:3000', + }); + + expect(allOutput()).toMatch(/\[OK\].*running/i); + expect(allOutput()).toMatch(/12345/); + expect(allOutput()).toMatch(/127\.0\.0\.1:3000/); + }); + + it('reports stopped when no server.pid exists', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const { handleBarStatus } = await loadStatusSubcommand(); + + await handleBarStatus([], { + getCcsDir: () => ccsDir, + readPidFile: () => null, + isProcessAlive: () => false, + probeServer: async () => false, + readBarJsonBaseUrl: () => null, + }); + + expect(allOutput()).toMatch(/stopped|no server\.pid/i); + }); + + it('reports stale pid when process is no longer alive', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const { handleBarStatus } = await loadStatusSubcommand(); + + await handleBarStatus([], { + getCcsDir: () => ccsDir, + readPidFile: () => '99999', + isProcessAlive: () => false, + probeServer: async () => false, + readBarJsonBaseUrl: () => null, + }); + + expect(allOutput()).toMatch(/no longer running|stale/i); + expect(allOutput()).toMatch(/99999/); + }); + + it('reports alive-but-unreachable when PID is alive but HTTP probe fails', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const { handleBarStatus } = await loadStatusSubcommand(); + + await handleBarStatus([], { + getCcsDir: () => ccsDir, + readPidFile: () => '12345', + isProcessAlive: () => true, + probeServer: async () => false, + readBarJsonBaseUrl: () => 'http://127.0.0.1:3000', + }); + + expect(allOutput()).toMatch(/alive|running/i); + expect(allOutput()).toMatch(/probe failed|starting up|not reachable/i); + }); +}); + +// --------------------------------------------------------------------------- +// launch-subcommand: detached-spawn model +// --------------------------------------------------------------------------- + +describe('launch: detached-spawn model', () => { + it('does NOT call startServer in-process — uses spawnDetachedServer instead', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + let inProcessStartCalled = false; + const spawnCalls: Array<{ port: number; logPath: string }> = []; + + const { handleBarLaunch } = await loadLaunchSubcommand(); + + await handleBarLaunch([], { + getCcsDir: () => ccsDir, + findRunningServer: async () => null, + getPort: async () => 3001, + spawnDetachedServer: (port: number, logPath: string) => { + spawnCalls.push({ port, logPath }); + }, + waitForServerLive: async () => { + /* simulate server becoming live immediately */ + }, + writeLaunchDescriptor: () => { + /* noop */ + }, + openApp: async () => { + /* noop — app not installed in test env */ + throw new Error('app not found'); + }, + appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + }); + + // In-process startServer must never be called + expect(inProcessStartCalled).toBe(false); + // spawnDetachedServer must be called once + expect(spawnCalls.length).toBe(1); + expect(spawnCalls[0].port).toBe(3001); + // log path must be inside ccsDir/bar/ + expect(spawnCalls[0].logPath).toContain('serve.log'); + }); + + it('writes bar.json after server becomes live', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const { handleBarLaunch } = await loadLaunchSubcommand(); + + await handleBarLaunch([], { + getCcsDir: () => ccsDir, + findRunningServer: async () => null, + getPort: async () => 4242, + spawnDetachedServer: () => { + /* noop */ + }, + waitForServerLive: async () => { + /* live immediately */ + }, + writeLaunchDescriptor: () => { + /* noop */ + }, + openApp: async () => { + /* noop */ + }, + appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + }); + + const barJsonPath = path.join(ccsDir, 'bar.json'); + expect(fs.existsSync(barJsonPath)).toBe(true); + const barJson = JSON.parse(fs.readFileSync(barJsonPath, 'utf8')) as { + port: number; + baseUrl: string; + authMode: string; + }; + expect(barJson.port).toBe(4242); + expect(barJson.baseUrl).toBe('http://127.0.0.1:4242'); + expect(barJson.authMode).toBe('loopback'); + }); + + it('reuses live server without spawning — spawnDetachedServer NOT called', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + let spawnCalled = false; + const { handleBarLaunch } = await loadLaunchSubcommand(); + + await handleBarLaunch([], { + getCcsDir: () => ccsDir, + findRunningServer: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }), + getPort: async () => 3001, + spawnDetachedServer: () => { + spawnCalled = true; + }, + waitForServerLive: async () => { + /* noop */ + }, + writeLaunchDescriptor: () => { + /* noop */ + }, + openApp: async () => { + /* noop */ + }, + appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + }); + + expect(spawnCalled).toBe(false); + expect(allOutput()).toMatch(/reusing/i); + }); + + it('reports error and returns when waitForServerLive times out', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const { handleBarLaunch } = await loadLaunchSubcommand(); + + await handleBarLaunch([], { + getCcsDir: () => ccsDir, + findRunningServer: async () => null, + getPort: async () => 3000, + spawnDetachedServer: () => { + /* noop */ + }, + waitForServerLive: async () => { + throw new Error('timeout after 10s'); + }, + writeLaunchDescriptor: () => { + /* noop */ + }, + openApp: async () => { + /* noop */ + }, + appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + }); + + expect(allOutput()).toMatch(/\[X\]/); + expect(allOutput()).toMatch(/timeout|connect|server/i); + }); + + it('writes launch.json via writeLaunchDescriptor on the start path', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const descriptorCalls: Array<{ jsonPath: string; descriptor: unknown }> = []; + + const { handleBarLaunch } = await loadLaunchSubcommand(); + + await handleBarLaunch([], { + getCcsDir: () => ccsDir, + findRunningServer: async () => null, + getPort: async () => 3000, + spawnDetachedServer: () => { + /* noop */ + }, + waitForServerLive: async () => { + /* live */ + }, + writeLaunchDescriptor: (jsonPath: string, descriptor: unknown) => { + descriptorCalls.push({ jsonPath, descriptor }); + }, + openApp: async () => { + /* noop */ + }, + appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + }); + + expect(descriptorCalls.length).toBe(1); + expect(descriptorCalls[0].jsonPath).toContain('launch.json'); + const desc = descriptorCalls[0].descriptor as { + schema: number; + runtime: string; + args: string[]; + home: string; + }; + expect(desc.schema).toBe(1); + expect(desc.runtime).toBe(process.execPath); + expect(desc.args).toContain('bar'); + expect(desc.args).toContain('serve'); + expect(desc.home).toBe(os.homedir()); + }); + + it('does NOT call writeLaunchDescriptor on the reuse path', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + let descriptorWritten = false; + const { handleBarLaunch } = await loadLaunchSubcommand(); + + await handleBarLaunch([], { + getCcsDir: () => ccsDir, + findRunningServer: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }), + getPort: async () => 3001, + spawnDetachedServer: () => { + /* noop */ + }, + waitForServerLive: async () => { + /* noop */ + }, + writeLaunchDescriptor: () => { + descriptorWritten = true; + }, + openApp: async () => { + /* noop */ + }, + appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + }); + + // On the reuse path there is no need to refresh launch.json + expect(descriptorWritten).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// install-subcommand: writeLaunchDescriptor after successful install +// --------------------------------------------------------------------------- + +describe('install: writeLaunchDescriptor called after successful install', () => { + 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('calls writeLaunchDescriptor with correct schema/runtime/args/home after install', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const appsDir = path.join(tempHome, 'Applications'); + const descriptorCalls: Array<{ jsonPath: string; descriptor: unknown }> = []; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + downloadAndExtract: fakeExtract(appsDir), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: () => '1.2.3', + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, + getCcsDir: () => ccsDir, + getAppsDir: () => appsDir, + writeLaunchDescriptor: (jsonPath: string, descriptor: unknown) => { + descriptorCalls.push({ jsonPath, descriptor }); + }, + }); + + expect(descriptorCalls.length).toBe(1); + expect(descriptorCalls[0].jsonPath).toContain('launch.json'); + const desc = descriptorCalls[0].descriptor as { + schema: number; + runtime: string; + args: string[]; + home: string; + }; + expect(desc.schema).toBe(1); + expect(desc.runtime).toBe(process.execPath); + expect(desc.args).toContain('bar'); + expect(desc.args).toContain('serve'); + expect(desc.home).toBe(os.homedir()); + }); + + it('does NOT hard-fail install when writeLaunchDescriptor throws', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const appsDir = path.join(tempHome, 'Applications'); + + const { handleBarInstall } = await loadInstallSubcommand(); + + await expect( + handleBarInstall([], { + fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }), + downloadAndExtract: fakeExtract(appsDir), + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: () => '1.2.3', + clearQuarantine: async () => true, + isBarRunning: async () => false, + promptLaunch: async () => false, + getCcsDir: () => ccsDir, + getAppsDir: () => appsDir, + writeLaunchDescriptor: () => { + throw new Error('disk full'); + }, + }) + ).resolves.toBeUndefined(); + + // Install itself should still succeed + expect(allOutput()).toMatch(/\[OK\].*CCS Bar/); + // Warning about launch.json failure + expect(allOutput()).toMatch(/\[!\].*launch\.json/i); + }); + + it('does not call writeLaunchDescriptor when install fails at download step', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const appsDir = path.join(tempHome, 'Applications'); + let descriptorCalled = false; + + const { handleBarInstall } = await loadInstallSubcommand(); + + await handleBarInstall([], { + fetchReleaseAsset: async () => { + throw new Error('network error'); + }, + downloadAndExtract: async () => { + /* noop */ + }, + verifyCompat: async () => ({ compatible: true, reason: 'ok' }), + readAppBundleVersion: () => '1.0.0', + getCcsDir: () => ccsDir, + getAppsDir: () => appsDir, + writeLaunchDescriptor: () => { + descriptorCalled = true; + }, + }); + + expect(descriptorCalled).toBe(false); + expect(allOutput()).toMatch(/\[X\]/); + }); +}); + +// --------------------------------------------------------------------------- +// index.ts: dispatcher routes serve / stop / status +// --------------------------------------------------------------------------- + +describe('bar command dispatcher: serve / stop / status routing', () => { + it('routes `ccs bar serve` to serve subcommand', async () => { + const routed: string[] = []; + // We test routing by importing index and verifying serve-subcommand is called. + // Use dynamic import with cache-busting. + moduleSeq++; + const { mock } = await import('bun:test'); + + mock.module('../../../src/commands/bar/serve-subcommand', () => ({ + handleBarServe: async (subArgs: string[]) => { + routed.push(`serve:${subArgs.join(' ')}`); + }, + })); + mock.module('../../../src/commands/bar/stop-subcommand', () => ({ + handleBarStop: async (subArgs: string[]) => { + routed.push(`stop:${subArgs.join(' ')}`); + }, + })); + mock.module('../../../src/commands/bar/status-subcommand', () => ({ + handleBarStatus: async (subArgs: string[]) => { + routed.push(`status:${subArgs.join(' ')}`); + }, + })); + mock.module('../../../src/commands/bar/launch-subcommand', () => ({ + handleBarLaunch: async (subArgs: string[]) => { + routed.push(`launch:${subArgs.join(' ')}`); + }, + })); + + const { mock: _m, ...bunTest } = await import('bun:test'); + void _m; + void bunTest; + + moduleSeq++; + const indexMod = await import( + `../../../src/commands/bar/index?test=${Date.now()}-${moduleSeq}` + ); + const { handleBarCommand } = indexMod as { + handleBarCommand: (args: string[]) => Promise; + }; + + await handleBarCommand(['serve']); + await handleBarCommand(['stop']); + await handleBarCommand(['status']); + + expect(routed.some((r) => r.startsWith('serve:'))).toBe(true); + expect(routed.some((r) => r.startsWith('stop:'))).toBe(true); + expect(routed.some((r) => r.startsWith('status:'))).toBe(true); + + mock.restore(); + }); +});