mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-04 08:13:15 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
7fb56d62e1
commit
34608ce291
9 files changed
+358
-54
No files matched your search
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}",
|
"scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}",
|
||||||
"syncFs": {
|
"syncFs": {
|
||||||
"totalOccurrences": 2427,
|
"totalOccurrences": 2429,
|
||||||
"filesAffected": 258,
|
"filesAffected": 258,
|
||||||
"hotpathOccurrences": 1142,
|
"hotpathOccurrences": 1144,
|
||||||
"hotpathFilesAffected": 152,
|
"hotpathFilesAffected": 152,
|
||||||
"topHotpathFiles": [
|
"topHotpathFiles": [
|
||||||
{
|
{
|
||||||
@@ -579,8 +579,8 @@
|
|||||||
},
|
},
|
||||||
"loggerCoverage": {
|
"loggerCoverage": {
|
||||||
"filesWithCreateLogger": 65,
|
"filesWithCreateLogger": 65,
|
||||||
"totalSourceFiles": 759,
|
"totalSourceFiles": 760,
|
||||||
"coverageRatio": 0.0856,
|
"coverageRatio": 0.0855,
|
||||||
"subdomainsWithZeroCreateLogger": [
|
"subdomainsWithZeroCreateLogger": [
|
||||||
"api",
|
"api",
|
||||||
"bin",
|
"bin",
|
||||||
@@ -606,7 +606,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"subdomain": "commands",
|
"subdomain": "commands",
|
||||||
"count": 108,
|
"count": 109,
|
||||||
"withLogger": 2
|
"withLogger": 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -652,8 +652,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hotpathConsoleErrors": {
|
"hotpathConsoleErrors": {
|
||||||
"totalOccurrences": 571,
|
"totalOccurrences": 576,
|
||||||
"exemptOccurrences": 305,
|
"exemptOccurrences": 310,
|
||||||
"hotpathOccurrences": 266,
|
"hotpathOccurrences": 266,
|
||||||
"filesAffected": 82,
|
"filesAffected": 82,
|
||||||
"topFiles": [
|
"topFiles": [
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
|
|||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
| Sync fs occurrences (all) | 2427 |
|
| Sync fs occurrences (all) | 2429 |
|
||||||
| Sync fs files affected (all) | 258 |
|
| 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 |
|
| Sync fs files affected (runtime hotpaths) | 152 |
|
||||||
| Legacy shim markers | 458 |
|
| Legacy shim markers | 458 |
|
||||||
| Legacy shim files affected | 173 |
|
| 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 (typed/total throws) | 17.7% (80/452) |
|
||||||
| typed-error adoption (P4 locked subdomains) | 93.3% (28/30), target 40% |
|
| 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 |
|
| 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) |
|
| 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 > 400 LOC | 91 |
|
||||||
| files > 600 LOC | 42 |
|
| files > 600 LOC | 42 |
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export async function showHelp(): Promise<void> {
|
|||||||
[
|
[
|
||||||
'Options:',
|
'Options:',
|
||||||
[
|
[
|
||||||
|
['--port <n>', 'Run the server on this port (persists; later launches keep the same port)'],
|
||||||
['--help, -h', 'Show this help message'],
|
['--help, -h', 'Show this help message'],
|
||||||
['--version', 'Show CLI and installed app versions'],
|
['--version', 'Show CLI and installed app versions'],
|
||||||
],
|
],
|
||||||
@@ -44,6 +45,7 @@ export async function showHelp(): Promise<void> {
|
|||||||
'Examples:',
|
'Examples:',
|
||||||
[
|
[
|
||||||
['ccs bar', 'Start the server detached and open CCS Bar'],
|
['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 stop', 'Stop the detached CCS Bar server'],
|
||||||
['ccs bar status', 'Show server running state and PID'],
|
['ccs bar status', 'Show server running state and PID'],
|
||||||
['ccs bar install', 'Download and install CCS Bar, then prompt to launch'],
|
['ccs bar install', 'Download and install CCS Bar, then prompt to launch'],
|
||||||
|
|||||||
@@ -56,9 +56,10 @@ export async function handleBarCommand(args: string[]): Promise<void> {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Bare `ccs bar` → launch
|
// Bare `ccs bar` → launch. Bare flags (e.g. `ccs bar --port 3999`) also go to
|
||||||
if (!subcommand || subcommand === 'launch') {
|
// launch with the full arg list preserved (--help/--version were handled above).
|
||||||
await commandHandlers.launch(subcommand ? args.slice(1) : []);
|
if (!subcommand || subcommand === 'launch' || subcommand.startsWith('-')) {
|
||||||
|
await commandHandlers.launch(subcommand === 'launch' ? args.slice(1) : args);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface LaunchDescriptorOptions {
|
|||||||
runtime?: string;
|
runtime?: string;
|
||||||
home?: string;
|
home?: string;
|
||||||
ccsHome?: 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 {
|
export function getLaunchShimPath(home: string = os.homedir()): string {
|
||||||
@@ -84,7 +86,12 @@ export function createBarLaunchDescriptor(options: LaunchDescriptorOptions = {})
|
|||||||
return {
|
return {
|
||||||
schema: LAUNCH_JSON_SCHEMA,
|
schema: LAUNCH_JSON_SCHEMA,
|
||||||
runtime: options.runtime ?? process.execPath,
|
runtime: options.runtime ?? process.execPath,
|
||||||
args: [entrypoint, 'bar', 'serve'],
|
args: [
|
||||||
|
entrypoint,
|
||||||
|
'bar',
|
||||||
|
'serve',
|
||||||
|
...(options.port !== undefined ? ['--port', String(options.port)] : []),
|
||||||
|
],
|
||||||
home,
|
home,
|
||||||
...(ccsHome ? { ccsHome } : {}),
|
...(ccsHome ? { ccsHome } : {}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,11 @@
|
|||||||
* Detached model (replaces the old in-process model):
|
* Detached model (replaces the old in-process model):
|
||||||
* 1. Probe candidate ports (bar.json port first, then 3000/3001/3002/8000/8080).
|
* 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.
|
* 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
|
* `ccs bar serve --port N` detached with stdio → serve.log, poll
|
||||||
* /api/bar/summary until 200 (timeout ~10 s), write bar.json, open app,
|
* /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.
|
* return. The CLI process exits; the server continues as a detached child.
|
||||||
@@ -28,9 +32,16 @@ import {
|
|||||||
isMatchingBarAuthProof,
|
isMatchingBarAuthProof,
|
||||||
getOrCreateBarAuthToken,
|
getOrCreateBarAuthToken,
|
||||||
} from '../../utils/bar-auth-token';
|
} 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 type { LaunchJson } from './bar-paths';
|
||||||
import { createBarLaunchDescriptor } from './launch-descriptor';
|
import { createBarLaunchDescriptor } from './launch-descriptor';
|
||||||
|
import { parsePortFlag } from './port-arg';
|
||||||
import {
|
import {
|
||||||
defaultFindRunningServer as _defaultFindRunningServer,
|
defaultFindRunningServer as _defaultFindRunningServer,
|
||||||
resolveBarPort as _resolveBarPort,
|
resolveBarPort as _resolveBarPort,
|
||||||
@@ -83,10 +94,21 @@ export interface LaunchDeps {
|
|||||||
* Returns the live baseUrl on success, throws on timeout.
|
* Returns the live baseUrl on success, throws on timeout.
|
||||||
*/
|
*/
|
||||||
waitForServerLive: (baseUrl: string) => Promise<void>;
|
waitForServerLive: (baseUrl: string) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* 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.
|
* Write launch.json so the Swift app can spawn the server independently.
|
||||||
*/
|
*/
|
||||||
writeLaunchDescriptor: (jsonPath: string, descriptor: LaunchJson) => void;
|
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<void>;
|
||||||
/** Open the installed .app bundle. Throws if the app is not found. */
|
/** Open the installed .app bundle. Throws if the app is not found. */
|
||||||
openApp: (appPath: string) => Promise<void>;
|
openApp: (appPath: string) => Promise<void>;
|
||||||
/** Returns path to ~/.ccs (respects CCS_HOME for test isolation). */
|
/** 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));
|
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<void> {
|
||||||
|
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<void>((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(pidPath);
|
||||||
|
} catch {
|
||||||
|
/* may already be gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function defaultOpenApp(appPath: string): Promise<void> {
|
async function defaultOpenApp(appPath: string): Promise<void> {
|
||||||
const { execFile } = await import('child_process');
|
const { execFile } = await import('child_process');
|
||||||
const { promisify } = await import('util');
|
const { promisify } = await import('util');
|
||||||
@@ -264,7 +324,9 @@ export async function handleBarLaunch(
|
|||||||
const getPortFn = deps.getPort ?? defaultGetPort;
|
const getPortFn = deps.getPort ?? defaultGetPort;
|
||||||
const spawnDetachedServer = deps.spawnDetachedServer ?? defaultSpawnDetachedServer;
|
const spawnDetachedServer = deps.spawnDetachedServer ?? defaultSpawnDetachedServer;
|
||||||
const waitForServerLive = deps.waitForServerLive ?? defaultWaitForServerLive;
|
const waitForServerLive = deps.waitForServerLive ?? defaultWaitForServerLive;
|
||||||
|
const createLaunchDescriptor = deps.createLaunchDescriptor ?? createBarLaunchDescriptor;
|
||||||
const writeLaunchDescriptor = deps.writeLaunchDescriptor ?? defaultWriteLaunchDescriptor;
|
const writeLaunchDescriptor = deps.writeLaunchDescriptor ?? defaultWriteLaunchDescriptor;
|
||||||
|
const stopDetachedServer = deps.stopDetachedServer ?? defaultStopDetachedServer;
|
||||||
|
|
||||||
// Wire findRunningServer after ccsDir is resolved.
|
// Wire findRunningServer after ccsDir is resolved.
|
||||||
const findRunningServer = deps.findRunningServer ?? (() => _defaultFindRunningServer(ccsDir));
|
const findRunningServer = deps.findRunningServer ?? (() => _defaultFindRunningServer(ccsDir));
|
||||||
@@ -272,6 +334,16 @@ export async function handleBarLaunch(
|
|||||||
const barJsonPath = getBarJsonPath(ccsDir);
|
const barJsonPath = getBarJsonPath(ccsDir);
|
||||||
const launchJsonPath = getLaunchJsonPath(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.
|
// 1. Probe for an already-running server.
|
||||||
let running: DashboardInfo | null = null;
|
let running: DashboardInfo | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -291,41 +363,75 @@ export async function handleBarLaunch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reuse the live server — write bar.json and open the app.
|
if (requestedPort === null || running.port === requestedPort) {
|
||||||
const barJson: BarDiscoveryJson = {
|
// Reuse the live server — write bar.json and open the app.
|
||||||
baseUrl: running.baseUrl,
|
const barJson: BarDiscoveryJson = {
|
||||||
port: running.port,
|
baseUrl: running.baseUrl,
|
||||||
authMode: 'loopback',
|
port: running.port,
|
||||||
};
|
authMode: 'loopback',
|
||||||
try {
|
};
|
||||||
fs.mkdirSync(ccsDir, { recursive: true });
|
try {
|
||||||
fs.writeFileSync(barJsonPath, JSON.stringify(barJson, null, 2));
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
} catch (err) {
|
fs.writeFileSync(barJsonPath, JSON.stringify(barJson, null, 2));
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
} catch (err) {
|
||||||
console.error(`[X] Failed to write bar.json: ${msg}`);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
console.log(`[OK] Reusing running CCS web-server at ${running.baseUrl}`);
|
|
||||||
console.log(`[i] Discovery file written: ${barJsonPath}`);
|
// Explicit --port that differs from the running server: move the server.
|
||||||
await _openAppWithFallback(appInstallPath, openApp);
|
console.log(
|
||||||
return;
|
`[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;
|
let port: number;
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
console.error(`[X] Could not find a free port: ${msg}`);
|
console.error(`[X] Could not find a free port: ${msg}`);
|
||||||
return;
|
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 {
|
try {
|
||||||
const launchDescriptor = createBarLaunchDescriptor();
|
const launchDescriptor = createLaunchDescriptor({ port });
|
||||||
writeLaunchDescriptor(launchJsonPath, launchDescriptor);
|
writeLaunchDescriptor(launchJsonPath, launchDescriptor);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Non-fatal — the Swift app falls back to resolving `ccs` via PATH.
|
// Non-fatal — the Swift app falls back to resolving `ccs` via PATH.
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -16,7 +16,8 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { getCcsDir } from '../../config/config-loader-facade';
|
import { getCcsDir } from '../../config/config-loader-facade';
|
||||||
import { getBarJsonPath, getServerPidPath } from './bar-paths';
|
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 { DashboardInfo } from './bar-server-probe';
|
||||||
import type { BarDiscoveryJson } from './launch-subcommand';
|
import type { BarDiscoveryJson } from './launch-subcommand';
|
||||||
|
|
||||||
@@ -93,19 +94,6 @@ function defaultGetCcsDir(): string {
|
|||||||
return getCcsDir();
|
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
|
// Implementation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -151,12 +139,18 @@ export async function handleBarServe(args: string[], deps: Partial<ServeDeps> =
|
|||||||
|
|
||||||
// 2. No live server found — start one.
|
// 2. No live server found — start one.
|
||||||
// Honor --port N from the launcher (it pre-selected via getPort to avoid races).
|
// 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;
|
let port: number;
|
||||||
if (requestedPort !== null) {
|
if (requestedPort !== null) {
|
||||||
port = requestedPort;
|
port = requestedPort;
|
||||||
} else {
|
} 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,
|
// TypeScript cannot infer that exit(1) is `never` when it is injected as a dep,
|
||||||
|
|||||||
@@ -3643,3 +3643,174 @@ describe('bar install: --await-quit waits for the running app to quit (GH-1588)'
|
|||||||
expect(probes).toBeGreaterThanOrEqual(2);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user