diff --git a/tools/dev/src/config.ts b/tools/dev/src/config.ts index 59f53bc60e..040c21769b 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 ee14752da9..b4599494db 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, @@ -99,22 +100,164 @@ function colorizeLink(url: string): string { return `${cyan}${underline}${url}${reset}`; } -function formatRunForegroundOutput(started: Partial>>>): string { - const webUrl = started.web?.status.url; - const daemonUrl = started.daemon?.status.url; - const lines = ["", " Open Design dev server ready", ""]; +function asRecord(value: unknown): Record | null { + return value != null && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} - if (webUrl != null) { - const displayUrl = normalizeDisplayUrl(webUrl); - lines.push(` ➜ Web: ${colorizeLink(displayUrl)}`); +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`); } - if (daemonUrl != null) { - const displayUrl = normalizeDisplayUrl(daemonUrl); - lines.push(` ➜ Daemon: ${colorizeLink(displayUrl)}`); +} + +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; } - lines.push("", " Press Ctrl+C to stop", ""); - return lines.join("\n"); + 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; + } + + const webStatus = asRecord(asRecord(started.web)?.status); + const daemonStatus = asRecord(asRecord(started.daemon)?.status); + const webUrl = stringField(webStatus ?? {}, "url"); + const daemonUrl = stringField(daemonStatus ?? {}, "url"); + + if (webUrl != null || daemonUrl != null) { + process.stdout.write("\n Open Design dev server ready\n\n"); + if (webUrl != null) process.stdout.write(` ➜ Web: ${colorizeLink(normalizeDisplayUrl(webUrl))}\n`); + if (daemonUrl != null) process.stdout.write(` ➜ Daemon: ${colorizeLink(normalizeDisplayUrl(daemonUrl))}\n`); + process.stdout.write("\n Press Ctrl+C to stop\n\n"); + return; + } + + printStartSection(started, "tools-dev run"); + process.stdout.write("Foreground loop is active. Press Ctrl+C to stop.\n"); } function runtimeLookup(config: ToolDevConfig) { @@ -576,6 +719,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); @@ -646,7 +812,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(options.json === true ? { mode: "foreground", started } : formatRunForegroundOutput(started), options); + printRunForegroundResult(started, options); let shuttingDown = false; const keepAlive = setInterval(() => undefined, 60_000); @@ -683,7 +849,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); }, ); @@ -695,7 +861,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); }, ); @@ -704,13 +870,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); }, ); @@ -746,7 +912,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); }, );