mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
c6d11018a0
commit
a19c866d5b
4 changed files with 188 additions and 13 deletions
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue