mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 22:21:20 +00:00
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
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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<DashboardInfo | null> {
|
||||
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;
|
||||
}
|
||||
@@ -13,7 +13,9 @@ export async function showHelp(): Promise<void> {
|
||||
[
|
||||
'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<void> {
|
||||
[
|
||||
'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<void> {
|
||||
}
|
||||
|
||||
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,'
|
||||
|
||||
@@ -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<void> {
|
||||
const subcommand = args[0];
|
||||
|
||||
// --help / -h anywhere in args (e.g. `ccs bar install --help`) → show help.
|
||||
// Mirrors docker/index.ts: hasAnyFlag(normalizedArgs, ['--help', '-h']).
|
||||
// Also dispatch bare `help` subcommand for symmetry.
|
||||
if (hasAnyFlag(args, ['--help', '-h']) || subcommand === 'help') {
|
||||
const { showHelp } = await import('./help-subcommand');
|
||||
@@ -31,6 +34,18 @@ export async function handleBarCommand(args: string[]): Promise<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
|
||||
@@ -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<DashboardInfo | null>;
|
||||
/**
|
||||
* 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<DashboardInfo>;
|
||||
getPort: (opts: { port: number[]; host: string }) => Promise<number>;
|
||||
/**
|
||||
* 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<void>;
|
||||
/**
|
||||
* 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<void>;
|
||||
/** 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<BarDiscoveryJson>;
|
||||
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<DashboardInfo | null> {
|
||||
const { request } = await import('undici');
|
||||
async function defaultGetPort(opts: { port: number[]; host: string }): Promise<number> {
|
||||
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<void> {
|
||||
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<void>((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<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');
|
||||
|
||||
// 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<void> {
|
||||
@@ -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<LaunchDeps> = {}
|
||||
): Promise<void> {
|
||||
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<void>
|
||||
): Promise<void> {
|
||||
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.');
|
||||
|
||||
@@ -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<DashboardInfo | null>;
|
||||
/**
|
||||
* 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<DashboardInfo>;
|
||||
/**
|
||||
* Find a free port from the candidate list.
|
||||
* Returns one of the candidates or another free port.
|
||||
*/
|
||||
getPort: (opts: { port: number[]; host: string }) => Promise<number>;
|
||||
/** 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<DashboardInfo> {
|
||||
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<number> {
|
||||
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<ServeDeps> = {}): Promise<void> {
|
||||
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.
|
||||
}
|
||||
@@ -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<boolean>;
|
||||
/**
|
||||
* 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<boolean> {
|
||||
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<StatusDeps> = {}
|
||||
): Promise<void> {
|
||||
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.');
|
||||
}
|
||||
}
|
||||
@@ -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<StopDeps> = {}): Promise<void> {
|
||||
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.');
|
||||
}
|
||||
@@ -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}`);
|
||||
},
|
||||
|
||||
@@ -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<string, unknown>) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
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<string, unknown>) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
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<string, unknown>) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
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<string, unknown>) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
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<string, unknown>) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<string, string> = {};
|
||||
|
||||
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<string, string> = {};
|
||||
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<void>;
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user