diff --git a/apps/daemon/sidecar/server.ts b/apps/daemon/sidecar/server.ts index aef683d7c..36067a677 100644 --- a/apps/daemon/sidecar/server.ts +++ b/apps/daemon/sidecar/server.ts @@ -27,8 +27,8 @@ export type DaemonSidecarHandle = { function parsePort(value: string | undefined): number { if (value == null || value.trim().length === 0) return 0; const port = Number(value); - if (!Number.isInteger(port) || port <= 0 || port > 65535) { - throw new Error(`${DAEMON_PORT_ENV} must be an integer between 1 and 65535`); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`${DAEMON_PORT_ENV} must be an integer between 0 and 65535`); } return port; } diff --git a/apps/web/sidecar/server.ts b/apps/web/sidecar/server.ts index c2eb8e042..f239a36ac 100644 --- a/apps/web/sidecar/server.ts +++ b/apps/web/sidecar/server.ts @@ -58,8 +58,8 @@ function resolveWebRoot(): string { function parsePort(value: string | undefined): number { if (value == null || value.trim().length === 0) return 0; const port = Number(value); - if (!Number.isInteger(port) || port <= 0 || port > 65535) { - throw new Error(`${WEB_PORT_ENV} must be an integer between 1 and 65535`); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`${WEB_PORT_ENV} must be an integer between 0 and 65535`); } return port; } diff --git a/tools/dev/src/config.ts b/tools/dev/src/config.ts index 59f53bc60..040c21769 100644 --- a/tools/dev/src/config.ts +++ b/tools/dev/src/config.ts @@ -103,15 +103,19 @@ export function isToolDevAppName(value: string): value is 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[] { if (appName == null) return [...defaults]; - if (!isToolDevAppName(appName)) throw new Error(`unsupported tools-dev app: ${appName}`); + if (!isToolDevAppName(appName)) throw unsupportedAppError(appName); return [appName]; } export function resolveStartApps(appName: string | undefined): ToolDevAppName[] { 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.DESKTOP) return [APP_KEYS.DAEMON, APP_KEYS.WEB, APP_KEYS.DESKTOP]; return [APP_KEYS.DAEMON]; @@ -124,7 +128,7 @@ export function resolveRunApps(appName: string | undefined): ToolDevAppName[] { export function resolveStopApps(appName: string | undefined): ToolDevAppName[] { 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.DESKTOP) return [APP_KEYS.DESKTOP]; return [APP_KEYS.DAEMON]; diff --git a/tools/dev/src/index.ts b/tools/dev/src/index.ts index 6b94e3061..c4884b891 100644 --- a/tools/dev/src/index.ts +++ b/tools/dev/src/index.ts @@ -32,6 +32,7 @@ import { } from "@open-design/platform"; import { + ALL_APPS, DEFAULT_START_APPS, DEFAULT_STOP_APPS, parsePortOption, @@ -87,6 +88,153 @@ function output(payload: unknown, options: CliOptions = {}): void { printJson(payload); } +function asRecord(value: unknown): Record | null { + return value != null && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function stringField(record: Record, key: string): string | null { + const value = record[key]; + return typeof value === "string" && value.length > 0 ? value : null; +} + +function numberField(record: Record, key: string): number | null { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function numberArrayField(record: Record | 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): void { + for (const [appName, appStatus] of Object.entries(apps)) { + process.stdout.write(`- ${appName}: ${formatStatusSummary(appStatus)}\n`); + } +} + +function printStartSection(result: Partial>, 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>, options: CliOptions, heading = "tools-dev start"): void { + if (options.json === true) { + printJson(result); + return; + } + printStartSection(result, heading); +} + +function printStopSection(result: Partial>, 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>, 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>, "Stop"); + printStartSection((asRecord(record?.start) ?? {}) as Partial>, "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>, 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) { return { base: config.toolsDevRoot, namespace: config.namespace }; } @@ -524,6 +672,29 @@ function printLogs(result: LogResult | Record, 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, options); + } +} + function parseTimeoutMs(value: string | undefined): number | undefined { if (value == null) return undefined; const seconds = Number(value); @@ -594,7 +765,7 @@ async function runForeground(config: ToolDevConfig, appName: string | undefined, const targets = resolveRunApps(appName); const foregroundOptions = { ...options, parentPid: process.pid }; const started = await runSequential(targets, (target) => startApp(config, target, foregroundOptions)); - output({ mode: "foreground", started }, options); + printRunForegroundResult(started, options); let shuttingDown = false; 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 targets = resolveStartApps(appName); 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( 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 targets = resolveStopApps(appName); 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( 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( 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); }, );