Fix tools-dev default startup usability (#127)

Allow sidecar port zero for auto allocation and make lifecycle command output easier to read.
This commit is contained in:
PerishFire 2026-04-30 14:50:44 +08:00 committed by GitHub
parent c6d11018a0
commit a19c866d5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 188 additions and 13 deletions

View file

@ -27,8 +27,8 @@ export type DaemonSidecarHandle = {
function parsePort(value: string | undefined): number { function parsePort(value: string | undefined): number {
if (value == null || value.trim().length === 0) return 0; if (value == null || value.trim().length === 0) return 0;
const port = Number(value); const port = Number(value);
if (!Number.isInteger(port) || port <= 0 || port > 65535) { if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error(`${DAEMON_PORT_ENV} must be an integer between 1 and 65535`); throw new Error(`${DAEMON_PORT_ENV} must be an integer between 0 and 65535`);
} }
return port; return port;
} }

View file

@ -58,8 +58,8 @@ function resolveWebRoot(): string {
function parsePort(value: string | undefined): number { function parsePort(value: string | undefined): number {
if (value == null || value.trim().length === 0) return 0; if (value == null || value.trim().length === 0) return 0;
const port = Number(value); const port = Number(value);
if (!Number.isInteger(port) || port <= 0 || port > 65535) { if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error(`${WEB_PORT_ENV} must be an integer between 1 and 65535`); throw new Error(`${WEB_PORT_ENV} must be an integer between 0 and 65535`);
} }
return port; return port;
} }

View file

@ -103,15 +103,19 @@ export function isToolDevAppName(value: string): value is ToolDevAppName {
return ALL_APPS.includes(value as ToolDevAppName); return ALL_APPS.includes(value as ToolDevAppName);
} }
function unsupportedAppError(value: string): Error {
return new Error(`unsupported tools-dev app: ${value} (expected one of: ${ALL_APPS.join(", ")})`);
}
export function resolveTargetApps(appName: string | undefined, defaults: readonly ToolDevAppName[]): ToolDevAppName[] { export function resolveTargetApps(appName: string | undefined, defaults: readonly ToolDevAppName[]): ToolDevAppName[] {
if (appName == null) return [...defaults]; if (appName == null) return [...defaults];
if (!isToolDevAppName(appName)) throw new Error(`unsupported tools-dev app: ${appName}`); if (!isToolDevAppName(appName)) throw unsupportedAppError(appName);
return [appName]; return [appName];
} }
export function resolveStartApps(appName: string | undefined): ToolDevAppName[] { export function resolveStartApps(appName: string | undefined): ToolDevAppName[] {
if (appName == null) return [...DEFAULT_START_APPS]; if (appName == null) return [...DEFAULT_START_APPS];
if (!isToolDevAppName(appName)) throw new Error(`unsupported tools-dev app: ${appName}`); if (!isToolDevAppName(appName)) throw unsupportedAppError(appName);
if (appName === APP_KEYS.WEB) return [APP_KEYS.DAEMON, APP_KEYS.WEB]; if (appName === APP_KEYS.WEB) return [APP_KEYS.DAEMON, APP_KEYS.WEB];
if (appName === APP_KEYS.DESKTOP) return [APP_KEYS.DAEMON, APP_KEYS.WEB, APP_KEYS.DESKTOP]; if (appName === APP_KEYS.DESKTOP) return [APP_KEYS.DAEMON, APP_KEYS.WEB, APP_KEYS.DESKTOP];
return [APP_KEYS.DAEMON]; return [APP_KEYS.DAEMON];
@ -124,7 +128,7 @@ export function resolveRunApps(appName: string | undefined): ToolDevAppName[] {
export function resolveStopApps(appName: string | undefined): ToolDevAppName[] { export function resolveStopApps(appName: string | undefined): ToolDevAppName[] {
if (appName == null) return [...DEFAULT_STOP_APPS]; if (appName == null) return [...DEFAULT_STOP_APPS];
if (!isToolDevAppName(appName)) throw new Error(`unsupported tools-dev app: ${appName}`); if (!isToolDevAppName(appName)) throw unsupportedAppError(appName);
if (appName === APP_KEYS.WEB) return [APP_KEYS.WEB, APP_KEYS.DAEMON]; if (appName === APP_KEYS.WEB) return [APP_KEYS.WEB, APP_KEYS.DAEMON];
if (appName === APP_KEYS.DESKTOP) return [APP_KEYS.DESKTOP]; if (appName === APP_KEYS.DESKTOP) return [APP_KEYS.DESKTOP];
return [APP_KEYS.DAEMON]; return [APP_KEYS.DAEMON];

View file

@ -32,6 +32,7 @@ import {
} from "@open-design/platform"; } from "@open-design/platform";
import { import {
ALL_APPS,
DEFAULT_START_APPS, DEFAULT_START_APPS,
DEFAULT_STOP_APPS, DEFAULT_STOP_APPS,
parsePortOption, parsePortOption,
@ -87,6 +88,153 @@ function output(payload: unknown, options: CliOptions = {}): void {
printJson(payload); printJson(payload);
} }
function asRecord(value: unknown): Record<string, unknown> | null {
return value != null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function stringField(record: Record<string, unknown>, key: string): string | null {
const value = record[key];
return typeof value === "string" && value.length > 0 ? value : null;
}
function numberField(record: Record<string, unknown>, key: string): number | null {
const value = record[key];
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function numberArrayField(record: Record<string, unknown> | null, key: string): number[] {
const value = record?.[key];
return Array.isArray(value) ? value.filter((entry): entry is number => typeof entry === "number" && Number.isFinite(entry)) : [];
}
function formatProcessList(pids: readonly number[]): string | null {
if (pids.length === 0) return null;
const visible = pids.slice(0, 5).join(", ");
return pids.length > 5 ? `${visible}, +${pids.length - 5} more` : visible;
}
function formatStatusSummary(status: unknown): string {
const record = asRecord(status);
if (record == null) return "status unavailable";
const parts = [stringField(record, "state") ?? "unknown"];
const url = stringField(record, "url");
const pid = numberField(record, "pid");
const title = stringField(record, "title");
const windowVisible = record.windowVisible;
if (url != null) parts.push(url);
if (pid != null) parts.push(`pid ${pid}`);
if (title != null) parts.push(`title ${JSON.stringify(title)}`);
if (typeof windowVisible === "boolean") parts.push(`window ${windowVisible ? "visible" : "hidden"}`);
return parts.join(" · ");
}
function printStatusEntries(apps: Record<string, unknown>): void {
for (const [appName, appStatus] of Object.entries(apps)) {
process.stdout.write(`- ${appName}: ${formatStatusSummary(appStatus)}\n`);
}
}
function printStartSection(result: Partial<Record<ToolDevAppName, unknown>>, heading: string): void {
process.stdout.write(`${heading}\n`);
const entries = Object.entries(result);
if (entries.length === 0) {
process.stdout.write("(no apps)\n");
return;
}
for (const [appName, rawEntry] of entries) {
const entry = asRecord(rawEntry);
const created = entry?.created;
const action = created === true ? "started" : created === false ? "already running" : "ready";
process.stdout.write(`- ${appName}: ${action} · ${formatStatusSummary(entry?.status)}\n`);
const logPath = entry == null ? null : stringField(entry, "logPath");
if (logPath != null) process.stdout.write(` log: ${logPath}\n`);
}
}
function printStartResult(result: Partial<Record<ToolDevAppName, unknown>>, options: CliOptions, heading = "tools-dev start"): void {
if (options.json === true) {
printJson(result);
return;
}
printStartSection(result, heading);
}
function printStopSection(result: Partial<Record<ToolDevAppName, unknown>>, heading: string): void {
process.stdout.write(`${heading}\n`);
const entries = Object.entries(result);
if (entries.length === 0) {
process.stdout.write("(no apps)\n");
return;
}
for (const [appName, rawEntry] of entries) {
const entry = asRecord(rawEntry);
const stop = asRecord(entry?.stop);
const stoppedPids = formatProcessList(numberArrayField(stop, "stoppedPids"));
const remainingPids = formatProcessList(numberArrayField(stop, "remainingPids"));
const parts = [entry == null ? "unknown" : stringField(entry, "status") ?? "unknown"];
const via = entry == null ? null : stringField(entry, "via");
if (via != null) parts.push(`via ${via}`);
if (stoppedPids != null) parts.push(`stopped pids ${stoppedPids}`);
if (remainingPids != null) parts.push(`remaining pids ${remainingPids}`);
process.stdout.write(`- ${appName}: ${parts.join(" · ")}\n`);
}
}
function printStopResult(result: Partial<Record<ToolDevAppName, unknown>>, options: CliOptions, heading = "tools-dev stop"): void {
if (options.json === true) {
printJson(result);
return;
}
printStopSection(result, heading);
}
function printRestartResult(result: unknown, options: CliOptions): void {
if (options.json === true) {
printJson(result);
return;
}
const record = asRecord(result);
process.stdout.write("tools-dev restart\n");
printStopSection((asRecord(record?.stop) ?? {}) as Partial<Record<ToolDevAppName, unknown>>, "Stop");
printStartSection((asRecord(record?.start) ?? {}) as Partial<Record<ToolDevAppName, unknown>>, "Start");
}
function printStatusResult(result: unknown, options: CliOptions, appName: string | undefined): void {
if (options.json === true) {
printJson(result);
return;
}
const record = asRecord(result);
const apps = asRecord(record?.apps);
if (apps != null) {
const namespace = stringField(record ?? {}, "namespace");
const statusLabel = stringField(record ?? {}, "status");
const details = [namespace == null ? null : `namespace ${namespace}`, statusLabel].filter((entry): entry is string => entry != null);
process.stdout.write(`tools-dev status${details.length > 0 ? ` (${details.join(" · ")})` : ""}\n`);
printStatusEntries(apps);
return;
}
process.stdout.write("tools-dev status\n");
process.stdout.write(`- ${appName ?? ALL_APPS.join("/")}: ${formatStatusSummary(result)}\n`);
}
function printRunForegroundResult(started: Partial<Record<ToolDevAppName, unknown>>, options: CliOptions): void {
if (options.json === true) {
printJson({ mode: "foreground", started });
return;
}
printStartSection(started, "tools-dev run");
process.stdout.write("Foreground loop is active. Press Ctrl+C to stop.\n");
}
function runtimeLookup(config: ToolDevConfig) { function runtimeLookup(config: ToolDevConfig) {
return { base: config.toolsDevRoot, namespace: config.namespace }; return { base: config.toolsDevRoot, namespace: config.namespace };
} }
@ -524,6 +672,29 @@ function printLogs(result: LogResult | Record<string, LogResult>, options: CliOp
} }
} }
function printCheckResult(result: unknown, options: CliOptions): void {
if (options.json === true) {
printJson(result);
return;
}
const record = asRecord(result);
const namespace = record == null ? null : stringField(record, "namespace");
process.stdout.write(`tools-dev check${namespace == null ? "" : ` (namespace ${namespace})`}\n`);
const apps = asRecord(record?.apps);
if (apps != null) {
process.stdout.write("Status\n");
printStatusEntries(apps);
}
const logs = asRecord(record?.logs);
if (logs != null) {
process.stdout.write("\nLogs\n");
printLogs(logs as Record<string, LogResult>, options);
}
}
function parseTimeoutMs(value: string | undefined): number | undefined { function parseTimeoutMs(value: string | undefined): number | undefined {
if (value == null) return undefined; if (value == null) return undefined;
const seconds = Number(value); const seconds = Number(value);
@ -594,7 +765,7 @@ async function runForeground(config: ToolDevConfig, appName: string | undefined,
const targets = resolveRunApps(appName); const targets = resolveRunApps(appName);
const foregroundOptions = { ...options, parentPid: process.pid }; const foregroundOptions = { ...options, parentPid: process.pid };
const started = await runSequential(targets, (target) => startApp(config, target, foregroundOptions)); const started = await runSequential(targets, (target) => startApp(config, target, foregroundOptions));
output({ mode: "foreground", started }, options); printRunForegroundResult(started, options);
let shuttingDown = false; let shuttingDown = false;
const keepAlive = setInterval(() => undefined, 60_000); const keepAlive = setInterval(() => undefined, 60_000);
@ -631,7 +802,7 @@ addPortOptions(addSharedOptions(cli.command("start [app]", "Start daemon, web, d
const config = resolveToolDevConfig(options); const config = resolveToolDevConfig(options);
const targets = resolveStartApps(appName); const targets = resolveStartApps(appName);
const result = await runSequential(targets, (target) => startApp(config, target, options)); const result = await runSequential(targets, (target) => startApp(config, target, options));
output(result, options); printStartResult(result, options);
}, },
); );
@ -643,7 +814,7 @@ addPortOptions(addSharedOptions(cli.command("run [app]", "Start apps and keep th
addSharedOptions(cli.command("status [app]", "Show app status for daemon, web, desktop, or all")).action( addSharedOptions(cli.command("status [app]", "Show app status for daemon, web, desktop, or all")).action(
async (appName: string | undefined, options: CliOptions) => { async (appName: string | undefined, options: CliOptions) => {
output(await status(resolveToolDevConfig(options), appName), options); printStatusResult(await status(resolveToolDevConfig(options), appName), options, appName);
}, },
); );
@ -652,13 +823,13 @@ addSharedOptions(cli.command("stop [app]", "Stop daemon, web, desktop, or all wh
const config = resolveToolDevConfig(options); const config = resolveToolDevConfig(options);
const targets = resolveStopApps(appName); const targets = resolveStopApps(appName);
const result = await runSequential(targets, (target) => stopApp(config, target)); const result = await runSequential(targets, (target) => stopApp(config, target));
output(result, options); printStopResult(result, options);
}, },
); );
addPortOptions(addSharedOptions(cli.command("restart [app]", "Restart daemon, web, desktop, or all when app is omitted"))).action( addPortOptions(addSharedOptions(cli.command("restart [app]", "Restart daemon, web, desktop, or all when app is omitted"))).action(
async (appName: string | undefined, options: CliOptions) => { async (appName: string | undefined, options: CliOptions) => {
output(await restartTargets(resolveToolDevConfig(options), appName, options), options); printRestartResult(await restartTargets(resolveToolDevConfig(options), appName, options), options);
}, },
); );
@ -694,7 +865,7 @@ addSharedOptions(cli.command("check [app]", "Print status and recent logs for qu
const logs = Object.fromEntries( const logs = Object.fromEntries(
await Promise.all(targets.map(async (target) => [target, await readLogs(config, target)] as const)), await Promise.all(targets.map(async (target) => [target, await readLogs(config, target)] as const)),
); );
output({ apps, logs, namespace: config.namespace }, options); printCheckResult({ apps, logs, namespace: config.namespace }, options);
}, },
); );