From 34608ce291fa7d763c8a949b88cd79c8074385df Mon Sep 17 00:00:00 2001 From: poomsc Date: Sat, 8 Aug 2026 14:06:58 +0700 Subject: [PATCH] feat(bar): support --port for ccs bar with sticky port persistence ccs bar always forced the dashboard onto port 3000 (first free of a hardcoded candidate list), which collides with other local dev servers, and bar.json was rewritten to 3000 on every launch. - `ccs bar [launch] --port N` runs the server on exactly N: reuses a live server already on N, moves a running server from another port (SIGTERM via server.pid, wait for exit), errors clearly when N is busy or the value is invalid. - The chosen port is persisted into launch.json args, so the Swift app self-starts the server on the same port. - Without --port, launch and serve now try the port recorded in bar.json first (sticky), so the server keeps coming back on the port the user last chose instead of reverting to 3000. - Bare flags (`ccs bar --port N`) route to the launch subcommand; --port is documented in `ccs bar --help`. Co-Authored-By: Claude Fable 5 --- docs/reports/hardening-inventory.json | 14 +- docs/reports/hardening-inventory.md | 8 +- src/commands/bar/help-subcommand.ts | 2 + src/commands/bar/index.ts | 7 +- src/commands/bar/launch-descriptor.ts | 9 +- src/commands/bar/launch-subcommand.ts | 152 +++++++++++++++++---- src/commands/bar/port-arg.ts | 23 ++++ src/commands/bar/serve-subcommand.ts | 26 ++-- tests/unit/commands/bar-command.test.ts | 171 ++++++++++++++++++++++++ 9 files changed, 358 insertions(+), 54 deletions(-) create mode 100644 src/commands/bar/port-arg.ts diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index 976dbcc2..10095054 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -1,9 +1,9 @@ { "scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", "syncFs": { - "totalOccurrences": 2427, + "totalOccurrences": 2429, "filesAffected": 258, - "hotpathOccurrences": 1142, + "hotpathOccurrences": 1144, "hotpathFilesAffected": 152, "topHotpathFiles": [ { @@ -579,8 +579,8 @@ }, "loggerCoverage": { "filesWithCreateLogger": 65, - "totalSourceFiles": 759, - "coverageRatio": 0.0856, + "totalSourceFiles": 760, + "coverageRatio": 0.0855, "subdomainsWithZeroCreateLogger": [ "api", "bin", @@ -606,7 +606,7 @@ }, { "subdomain": "commands", - "count": 108, + "count": 109, "withLogger": 2 }, { @@ -652,8 +652,8 @@ ] }, "hotpathConsoleErrors": { - "totalOccurrences": 571, - "exemptOccurrences": 305, + "totalOccurrences": 576, + "exemptOccurrences": 310, "hotpathOccurrences": 266, "filesAffected": 82, "topFiles": [ diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 0b8a3082..806eba2f 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -6,9 +6,9 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | Metric | Value | |---|---:| -| Sync fs occurrences (all) | 2427 | +| Sync fs occurrences (all) | 2429 | | Sync fs files affected (all) | 258 | -| Sync fs occurrences (runtime hotpaths) | 1142 | +| Sync fs occurrences (runtime hotpaths) | 1144 | | Sync fs files affected (runtime hotpaths) | 152 | | Legacy shim markers | 458 | | Legacy shim files affected | 173 | @@ -58,9 +58,9 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` |---|---:| | typed-error adoption (typed/total throws) | 17.7% (80/452) | | typed-error adoption (P4 locked subdomains) | 93.3% (28/30), target 40% | -| hotpath console.error/warn occurrences | 266 (571 total, 305 CLI-UX exempt) | +| hotpath console.error/warn occurrences | 266 (576 total, 310 CLI-UX exempt) | | hotpath console.error/warn files | 82 | -| files with createLogger | 65/759 | +| files with createLogger | 65/760 | | subdomains with zero createLogger | 15 (api, bin, channels, cliproxy, cliproxy/accounts, cliproxy/ai-providers, cliproxy/binary, cliproxy/config, cliproxy/management, cliproxy/sync, cliproxy/types, config, dispatcher, shared, types) | | files > 400 LOC | 91 | | files > 600 LOC | 42 | diff --git a/src/commands/bar/help-subcommand.ts b/src/commands/bar/help-subcommand.ts index fbc22031..81c78a1e 100644 --- a/src/commands/bar/help-subcommand.ts +++ b/src/commands/bar/help-subcommand.ts @@ -25,6 +25,7 @@ export async function showHelp(): Promise { [ 'Options:', [ + ['--port ', 'Run the server on this port (persists; later launches keep the same port)'], ['--help, -h', 'Show this help message'], ['--version', 'Show CLI and installed app versions'], ], @@ -44,6 +45,7 @@ export async function showHelp(): Promise { 'Examples:', [ ['ccs bar', 'Start the server detached and open CCS Bar'], + ['ccs bar --port 3999', 'Start (or move) the server on port 3999 instead of 3000'], ['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'], diff --git a/src/commands/bar/index.ts b/src/commands/bar/index.ts index f12d8cd4..bb2cfd63 100644 --- a/src/commands/bar/index.ts +++ b/src/commands/bar/index.ts @@ -56,9 +56,10 @@ export async function handleBarCommand(args: string[]): Promise { }, }; - // Bare `ccs bar` → launch - if (!subcommand || subcommand === 'launch') { - await commandHandlers.launch(subcommand ? args.slice(1) : []); + // Bare `ccs bar` → launch. Bare flags (e.g. `ccs bar --port 3999`) also go to + // launch with the full arg list preserved (--help/--version were handled above). + if (!subcommand || subcommand === 'launch' || subcommand.startsWith('-')) { + await commandHandlers.launch(subcommand === 'launch' ? args.slice(1) : args); return; } diff --git a/src/commands/bar/launch-descriptor.ts b/src/commands/bar/launch-descriptor.ts index 71e3753e..64d4eefa 100644 --- a/src/commands/bar/launch-descriptor.ts +++ b/src/commands/bar/launch-descriptor.ts @@ -27,6 +27,8 @@ export interface LaunchDescriptorOptions { runtime?: string; home?: string; ccsHome?: string; + /** Server port; recorded in args so the Swift app self-starts on the same port. */ + port?: number; } export function getLaunchShimPath(home: string = os.homedir()): string { @@ -84,7 +86,12 @@ export function createBarLaunchDescriptor(options: LaunchDescriptorOptions = {}) return { schema: LAUNCH_JSON_SCHEMA, runtime: options.runtime ?? process.execPath, - args: [entrypoint, 'bar', 'serve'], + args: [ + entrypoint, + 'bar', + 'serve', + ...(options.port !== undefined ? ['--port', String(options.port)] : []), + ], home, ...(ccsHome ? { ccsHome } : {}), }; diff --git a/src/commands/bar/launch-subcommand.ts b/src/commands/bar/launch-subcommand.ts index 2d1f1897..63e9686b 100644 --- a/src/commands/bar/launch-subcommand.ts +++ b/src/commands/bar/launch-subcommand.ts @@ -7,7 +7,11 @@ * 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 + * With an explicit --port that differs from the running server's port, + * stop that server first and fall through to a fresh start instead. + * 3. Else → pick a port (--port exactly when given; otherwise bar.json's + * recorded port first, then the default candidates), refresh launch.json + * (including --port so the Swift app self-starts on the same 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. @@ -28,9 +32,16 @@ import { isMatchingBarAuthProof, getOrCreateBarAuthToken, } from '../../utils/bar-auth-token'; -import { getBarDir, getBarJsonPath, getLaunchJsonPath, getServeLogPath } from './bar-paths'; +import { + getBarDir, + getBarJsonPath, + getLaunchJsonPath, + getServeLogPath, + getServerPidPath, +} from './bar-paths'; import type { LaunchJson } from './bar-paths'; import { createBarLaunchDescriptor } from './launch-descriptor'; +import { parsePortFlag } from './port-arg'; import { defaultFindRunningServer as _defaultFindRunningServer, resolveBarPort as _resolveBarPort, @@ -83,10 +94,21 @@ export interface LaunchDeps { * Returns the live baseUrl on success, throws on timeout. */ waitForServerLive: (baseUrl: string) => Promise; + /** + * Build the launch.json descriptor (includes the chosen --port so the Swift + * app self-starts the server on the same port). + */ + createLaunchDescriptor: (opts?: { port?: number }) => LaunchJson; /** * Write launch.json so the Swift app can spawn the server independently. */ writeLaunchDescriptor: (jsonPath: string, descriptor: LaunchJson) => void; + /** + * Stop the detached CCS Bar server recorded in server.pid and wait briefly + * for the port to free. Used when an explicit --port differs from the port + * the running server occupies. + */ + stopDetachedServer: (ccsDir: string) => Promise; /** 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). */ @@ -236,6 +258,44 @@ function defaultWriteLaunchDescriptor(jsonPath: string, descriptor: LaunchJson): fs.writeFileSync(jsonPath, JSON.stringify(descriptor, null, 2)); } +/** + * SIGTERM the detached server from server.pid, then poll until the process is + * gone (up to ~3 s) so the port is free before we bind the replacement. + * Silently no-ops when no pid file exists or the process is already gone. + */ +async function defaultStopDetachedServer(ccsDir: string): Promise { + const pidPath = getServerPidPath(ccsDir); + let pid: number; + try { + pid = parseInt(fs.readFileSync(pidPath, 'utf8').trim(), 10); + } catch { + return; + } + if (!Number.isFinite(pid) || pid <= 0) return; + + try { + process.kill(pid, 'SIGTERM'); + } catch { + /* already gone */ + } + + const deadline = Date.now() + 3_000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); // still alive + } catch { + break; // exited + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + try { + fs.unlinkSync(pidPath); + } catch { + /* may already be gone */ + } +} + async function defaultOpenApp(appPath: string): Promise { const { execFile } = await import('child_process'); const { promisify } = await import('util'); @@ -264,7 +324,9 @@ export async function handleBarLaunch( const getPortFn = deps.getPort ?? defaultGetPort; const spawnDetachedServer = deps.spawnDetachedServer ?? defaultSpawnDetachedServer; const waitForServerLive = deps.waitForServerLive ?? defaultWaitForServerLive; + const createLaunchDescriptor = deps.createLaunchDescriptor ?? createBarLaunchDescriptor; const writeLaunchDescriptor = deps.writeLaunchDescriptor ?? defaultWriteLaunchDescriptor; + const stopDetachedServer = deps.stopDetachedServer ?? defaultStopDetachedServer; // Wire findRunningServer after ccsDir is resolved. const findRunningServer = deps.findRunningServer ?? (() => _defaultFindRunningServer(ccsDir)); @@ -272,6 +334,16 @@ export async function handleBarLaunch( const barJsonPath = getBarJsonPath(ccsDir); const launchJsonPath = getLaunchJsonPath(ccsDir); + // 0. Parse --port. A present-but-invalid value is a hard error (silently + // launching on a different port than the user asked for is worse). + const portFlag = parsePortFlag(_args); + if (portFlag.present && portFlag.port === null) { + console.error('[X] Invalid --port value. Use a number between 1 and 65535.'); + process.exitCode = 1; + return; + } + const requestedPort = portFlag.port; + // 1. Probe for an already-running server. let running: DashboardInfo | null = null; try { @@ -291,41 +363,75 @@ export async function handleBarLaunch( return; } - // Reuse the live server — write bar.json and open the app. - const barJson: BarDiscoveryJson = { - baseUrl: running.baseUrl, - port: running.port, - authMode: 'loopback', - }; - try { - 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] Failed to write bar.json: ${msg}`); + if (requestedPort === null || running.port === requestedPort) { + // Reuse the live server — write bar.json and open the app. + const barJson: BarDiscoveryJson = { + baseUrl: running.baseUrl, + port: running.port, + authMode: 'loopback', + }; + try { + 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] 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; } - console.log(`[OK] Reusing running CCS web-server at ${running.baseUrl}`); - console.log(`[i] Discovery file written: ${barJsonPath}`); - await _openAppWithFallback(appInstallPath, openApp); - return; + + // Explicit --port that differs from the running server: move the server. + console.log( + `[i] CCS Bar server is running on port ${running.port}; moving to port ${requestedPort}...` + ); + try { + await stopDetachedServer(ccsDir); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[!] Could not stop the running server: ${msg}`); + console.error('[i] Run `ccs bar stop` manually, then retry.'); + } + // Fall through to the fresh-start path below. } - // 2. No live server — pick a port, write/refresh launch.json, spawn detached. + // 2. No live server (or moving ports) — pick a port, write/refresh + // launch.json, spawn detached. - // 2a. Pick a free port. + // 2a. Pick a free port. An explicit --port must be honored exactly; without + // it, the port recorded in bar.json is preferred so the server keeps + // coming back on the port the user last chose (sticky port). let port: number; try { - port = await getPortFn({ port: [3000, 3001, 3002, 8000, 8080], host: '127.0.0.1' }); + if (requestedPort !== null) { + const got = await getPortFn({ port: [requestedPort], host: '127.0.0.1' }); + if (got !== requestedPort) { + console.error(`[X] Port ${requestedPort} is already in use by another process.`); + console.error('[i] Choose a different port or free it, then retry.'); + process.exitCode = 1; + return; + } + port = requestedPort; + } else { + const stickyPort = _resolveBarPort(ccsDir); + const base = [3000, 3001, 3002, 8000, 8080]; + const candidates = + stickyPort !== null ? [stickyPort, ...base.filter((p) => p !== stickyPort)] : base; + port = await getPortFn({ port: candidates, 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. + // 2b. Write/refresh launch.json so the Swift app can self-start next time — + // on the same port this launch chose. try { - const launchDescriptor = createBarLaunchDescriptor(); + const launchDescriptor = createLaunchDescriptor({ port }); writeLaunchDescriptor(launchJsonPath, launchDescriptor); } catch (err) { // Non-fatal — the Swift app falls back to resolving `ccs` via PATH. diff --git a/src/commands/bar/port-arg.ts b/src/commands/bar/port-arg.ts new file mode 100644 index 00000000..9cc1640d --- /dev/null +++ b/src/commands/bar/port-arg.ts @@ -0,0 +1,23 @@ +/** + * Shared `--port N` flag parsing for the `ccs bar` command family. + * + * `present` distinguishes "flag not given" from "flag given with a bad value" + * so launch can reject typos loudly instead of silently falling back to the + * default port list. + */ + +export interface PortFlag { + /** True when `--port` appears in args at all. */ + present: boolean; + /** The parsed port (1-65535), or null when absent or invalid. */ + port: number | null; +} + +export function parsePortFlag(args: string[]): PortFlag { + const idx = args.indexOf('--port'); + if (idx === -1) return { present: false, port: null }; + const raw = args[idx + 1]; + const n = raw === undefined ? NaN : parseInt(raw, 10); + const valid = Number.isFinite(n) && n > 0 && n < 65536; + return { present: true, port: valid ? n : null }; +} diff --git a/src/commands/bar/serve-subcommand.ts b/src/commands/bar/serve-subcommand.ts index e0769baa..a7ed3d40 100644 --- a/src/commands/bar/serve-subcommand.ts +++ b/src/commands/bar/serve-subcommand.ts @@ -16,7 +16,8 @@ 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 { defaultFindRunningServer, resolveBarPort } from './bar-server-probe'; +import { parsePortFlag } from './port-arg'; import type { DashboardInfo } from './bar-server-probe'; import type { BarDiscoveryJson } from './launch-subcommand'; @@ -93,19 +94,6 @@ 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 // --------------------------------------------------------------------------- @@ -151,12 +139,18 @@ export async function handleBarServe(args: string[], deps: Partial = // 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); + // Without it, prefer the port recorded in bar.json so the server keeps coming + // back on the port the user last chose (sticky port). + const requestedPort = parsePortFlag(args).port; let port: number; if (requestedPort !== null) { port = requestedPort; } else { - port = await getPortFn({ port: [3000, 3001, 3002, 8000, 8080], host: '127.0.0.1' }); + const stickyPort = resolveBarPort(ccsDir); + const base = [3000, 3001, 3002, 8000, 8080]; + const candidates = + stickyPort !== null ? [stickyPort, ...base.filter((p) => p !== stickyPort)] : base; + port = await getPortFn({ port: candidates, host: '127.0.0.1' }); } // TypeScript cannot infer that exit(1) is `never` when it is injected as a dep, diff --git a/tests/unit/commands/bar-command.test.ts b/tests/unit/commands/bar-command.test.ts index 5f649ef1..1c53e072 100644 --- a/tests/unit/commands/bar-command.test.ts +++ b/tests/unit/commands/bar-command.test.ts @@ -3643,3 +3643,174 @@ describe('bar install: --await-quit waits for the running app to quit (GH-1588)' expect(probes).toBeGreaterThanOrEqual(2); }); }); + +// --------------------------------------------------------------------------- +// --port support: `ccs bar [launch] --port N` (user-selectable dashboard port) +// --------------------------------------------------------------------------- + +describe('bar dispatcher: bare flags route to launch', () => { + beforeEach(() => { + mock.module('../../../src/commands/bar/launch-subcommand', () => ({ + handleBarLaunch: async (args: string[]) => { + calls.push(`launch:${args.join(' ')}`); + }, + })); + }); + + it('dispatches `ccs bar --port 3999` to launch with the flag preserved', async () => { + const handleBarCommand = await loadHandleBarCommand(); + await handleBarCommand(['--port', '3999']); + expect(calls).toEqual(['launch:--port 3999']); + }); + + it('dispatches `ccs bar launch --port 3999` to launch with the flag preserved', async () => { + const handleBarCommand = await loadHandleBarCommand(); + await handleBarCommand(['launch', '--port', '3999']); + expect(calls).toEqual(['launch:--port 3999']); + }); +}); + +describe('launch: --port selects the server port', () => { + function makePortDeps(ccsDir: string) { + const seen: { + spawnPort: number | null; + getPortCandidates: number[] | null; + stopped: boolean; + descriptorPort: number | null; + } = { spawnPort: null, getPortCandidates: null, stopped: false, descriptorPort: null }; + + const deps = { + findRunningServer: async () => null, + getPort: async (opts: { port: number[]; host: string }) => { + seen.getPortCandidates = opts.port; + return opts.port[0]; + }, + spawnDetachedServer: (p: number) => { + seen.spawnPort = p; + }, + waitForServerLive: async () => {}, + createLaunchDescriptor: (opts?: { port?: number }) => { + seen.descriptorPort = opts?.port ?? null; + return { + schema: 1 as const, + runtime: '/usr/bin/node', + args: ['/x/ccs.js', 'bar', 'serve'], + home: '/h', + }; + }, + writeLaunchDescriptor: () => {}, + stopDetachedServer: () => { + seen.stopped = true; + }, + openApp: async () => {}, + getCcsDir: () => ccsDir, + appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), + }; + return { deps, seen }; + } + + it('spawns the detached server on the requested port and records it in bar.json', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps, seen } = makePortDeps(ccsDir); + + await handleBarLaunch(['--port', '3999'], deps); + + expect(seen.spawnPort).toBe(3999); + const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as { + port: number; + baseUrl: string; + }; + expect(barJson.port).toBe(3999); + expect(barJson.baseUrl).toBe('http://127.0.0.1:3999'); + }); + + it('persists the chosen port into the launch descriptor for app self-start', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps, seen } = makePortDeps(ccsDir); + + await handleBarLaunch(['--port', '3999'], deps); + + expect(seen.descriptorPort).toBe(3999); + }); + + it('errors without spawning when the requested port is busy', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps, seen } = makePortDeps(ccsDir); + deps.getPort = async () => 4001; // get-port fell back: 3999 not free + + await handleBarLaunch(['--port', '3999'], deps); + + expect(seen.spawnPort).toBeNull(); + const allOutput = consoleOutput.join('\n'); + expect(allOutput).toMatch(/3999/); + expect(allOutput.toLowerCase()).toMatch(/in use|busy|not free|unavailable/); + }); + + it('errors on an invalid --port value', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps, seen } = makePortDeps(ccsDir); + + await handleBarLaunch(['--port', 'banana'], deps); + + expect(seen.spawnPort).toBeNull(); + expect(consoleOutput.join('\n').toLowerCase()).toMatch(/invalid.*port|port.*invalid/); + }); + + it('reuses a running server already on the requested port', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps, seen } = makePortDeps(ccsDir); + deps.findRunningServer = async () => ({ port: 3999, baseUrl: 'http://127.0.0.1:3999' }); + + await handleBarLaunch(['--port', '3999'], deps); + + expect(seen.spawnPort).toBeNull(); // reuse, no new spawn + expect(seen.stopped).toBe(false); + const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as { + port: number; + }; + expect(barJson.port).toBe(3999); + }); + + it('stops a running server on a different port, then starts on the requested one', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps, seen } = makePortDeps(ccsDir); + deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }); + + await handleBarLaunch(['--port', '3999'], deps); + + expect(seen.stopped).toBe(true); + expect(seen.spawnPort).toBe(3999); + const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as { + port: number; + }; + expect(barJson.port).toBe(3999); + }); + + it('without --port, prefers the port recorded in bar.json (sticky port)', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'bar.json'), + JSON.stringify({ baseUrl: 'http://127.0.0.1:3777', port: 3777, authMode: 'loopback' }) + ); + const { handleBarLaunch } = await loadLaunchSubcommand(); + const { deps, seen } = makePortDeps(ccsDir); + + await handleBarLaunch([], deps); + + expect(seen.getPortCandidates?.[0]).toBe(3777); + expect(seen.spawnPort).toBe(3777); + }); +});