@gmickel/gno 1.0.5 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -14
- package/assets/skill/cli-reference.md +105 -5
- package/package.json +1 -1
- package/src/cli/commands/collection/clear-embeddings.ts +6 -1
- package/src/cli/commands/publish.ts +6 -6
- package/src/cli/detach.ts +986 -0
- package/src/cli/errors.ts +42 -5
- package/src/cli/program.ts +687 -30
- package/src/cli/run.ts +11 -3
- package/src/core/user-dirs.ts +86 -0
package/src/cli/program.ts
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
* @module src/cli/program
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { Command } from "commander";
|
|
8
|
+
import { Command, Option } from "commander";
|
|
9
|
+
// node:fs sync unlink — Bun has no equivalent; used only in the detached-
|
|
10
|
+
// child signal handler where we need a synchronous cleanup on the signal
|
|
11
|
+
// path.
|
|
12
|
+
import { unlinkSync } from "node:fs";
|
|
9
13
|
|
|
10
14
|
import {
|
|
11
15
|
CLI_NAME,
|
|
@@ -22,6 +26,7 @@ import {
|
|
|
22
26
|
type GlobalOptions,
|
|
23
27
|
parseGlobalOptions,
|
|
24
28
|
} from "./context";
|
|
29
|
+
import { DETACHED_CHILD_FLAG } from "./detach";
|
|
25
30
|
import { CliError } from "./errors";
|
|
26
31
|
import {
|
|
27
32
|
assertFormatSupported,
|
|
@@ -59,6 +64,33 @@ export function resetGlobals(): void {
|
|
|
59
64
|
setColorsEnabled(true);
|
|
60
65
|
}
|
|
61
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the user-facing argv slice (everything after `[execPath, scriptPath]`)
|
|
69
|
+
* from a Commander Command instance. Walks up to the root via `.parent` so we
|
|
70
|
+
* get the original argv passed to `parseAsync()` regardless of which
|
|
71
|
+
* sub-command's action handler invoked us.
|
|
72
|
+
*
|
|
73
|
+
* Exported for tests. Production callers (runDaemonDetach / runServeDetach)
|
|
74
|
+
* call this with `cmd` from their action handler.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveCliArgv(cmd: Command): string[] {
|
|
77
|
+
let root: Command = cmd;
|
|
78
|
+
while (root.parent) {
|
|
79
|
+
root = root.parent;
|
|
80
|
+
}
|
|
81
|
+
// Commander's Command.rawArgs is the full argv passed into parseAsync()
|
|
82
|
+
// (including [execPath, scriptPath]). Drop the first two so the slice
|
|
83
|
+
// matches what we'd build from `process.argv.slice(2)` in the legacy
|
|
84
|
+
// path — that's what the detach child re-exec wants.
|
|
85
|
+
//
|
|
86
|
+
// Note: rawArgs is documented in Commander's source
|
|
87
|
+
// (`node_modules/commander/lib/command.js:1028` — `this.rawArgs =
|
|
88
|
+
// argv.slice()`) but is not on its public TypeScript type, hence the
|
|
89
|
+
// narrow cast.
|
|
90
|
+
const rawArgs = (root as unknown as { rawArgs: string[] }).rawArgs;
|
|
91
|
+
return rawArgs.slice(2);
|
|
92
|
+
}
|
|
93
|
+
|
|
62
94
|
/**
|
|
63
95
|
* Select output format with explicit precedence.
|
|
64
96
|
* Precedence: local non-json format > local --json > global --json > terminal
|
|
@@ -2193,51 +2225,676 @@ function wireGraphCommand(program: Command): void {
|
|
|
2193
2225
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2194
2226
|
|
|
2195
2227
|
function wireDaemonCommand(program: Command): void {
|
|
2196
|
-
program
|
|
2228
|
+
const daemonCmd = program
|
|
2197
2229
|
.command("daemon")
|
|
2198
2230
|
.description("Start headless continuous indexing")
|
|
2199
2231
|
.option(
|
|
2200
2232
|
"--no-sync-on-start",
|
|
2201
2233
|
"skip initial sync and only watch future file changes"
|
|
2202
2234
|
)
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2235
|
+
// Local `--json` so `gno daemon --status --json` parses cleanly in any
|
|
2236
|
+
// argv position. Mirrors the serve wiring: gated to --status only.
|
|
2237
|
+
.option(
|
|
2238
|
+
"--json",
|
|
2239
|
+
"JSON output (applies to --status; see process-status schema)"
|
|
2240
|
+
);
|
|
2241
|
+
|
|
2242
|
+
// --detach / --status / --stop are mutually exclusive. Use Commander's
|
|
2243
|
+
// Option API so the conflict error is the native "option '--status'
|
|
2244
|
+
// cannot be used with option '--detach'" surface.
|
|
2245
|
+
daemonCmd.addOption(
|
|
2246
|
+
new Option(
|
|
2247
|
+
"--detach",
|
|
2248
|
+
"run as a detached background process (macOS/Linux only)"
|
|
2249
|
+
).conflicts(["status", "stop"])
|
|
2250
|
+
);
|
|
2251
|
+
daemonCmd.addOption(
|
|
2252
|
+
new Option(
|
|
2253
|
+
"--status",
|
|
2254
|
+
"show status of the detached daemon process (use --json for machine output)"
|
|
2255
|
+
).conflicts(["detach", "stop"])
|
|
2256
|
+
);
|
|
2257
|
+
daemonCmd.addOption(
|
|
2258
|
+
new Option(
|
|
2259
|
+
"--stop",
|
|
2260
|
+
"stop the detached daemon process (SIGTERM then SIGKILL fallback)"
|
|
2261
|
+
).conflicts(["detach", "status"])
|
|
2262
|
+
);
|
|
2263
|
+
daemonCmd.addOption(
|
|
2264
|
+
new Option("--pid-file <path>", "override pid-file path")
|
|
2265
|
+
);
|
|
2266
|
+
daemonCmd.addOption(
|
|
2267
|
+
new Option("--log-file <path>", "override log-file path (append-only)")
|
|
2268
|
+
);
|
|
2269
|
+
// Sentinel flag set by the parent when re-exec'ing the detached child.
|
|
2270
|
+
// Hidden from --help; Commander consumes it via addOption so it doesn't
|
|
2271
|
+
// leak into user-visible argv.
|
|
2272
|
+
daemonCmd.addOption(
|
|
2273
|
+
new Option(
|
|
2274
|
+
`${DETACHED_CHILD_FLAG}`,
|
|
2275
|
+
"internal detached-child marker"
|
|
2276
|
+
).hideHelp()
|
|
2277
|
+
);
|
|
2278
|
+
|
|
2279
|
+
daemonCmd.action(async (cmdOpts: Record<string, unknown>, cmd: Command) => {
|
|
2280
|
+
await handleDaemonAction(cmdOpts, cmd);
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2285
|
+
// Daemon lifecycle branching (detach / status / stop / detached-child / fg)
|
|
2286
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2287
|
+
|
|
2288
|
+
/**
|
|
2289
|
+
* Route `gno daemon` through the detach helpers based on which mutex flag the
|
|
2290
|
+
* user set. The four early branches return without touching `daemon()`; only
|
|
2291
|
+
* the foreground + detached-child paths boot the runtime.
|
|
2292
|
+
*
|
|
2293
|
+
* Mirrors `handleServeAction` exactly — same helper imports, same JSON
|
|
2294
|
+
* gating, same detached-child verification + pid-file cleanup. Daemon has no
|
|
2295
|
+
* `--port`, so the foreground path skips port validation entirely.
|
|
2296
|
+
*/
|
|
2297
|
+
async function handleDaemonAction(
|
|
2298
|
+
cmdOpts: Record<string, unknown>,
|
|
2299
|
+
cmd: Command
|
|
2300
|
+
): Promise<void> {
|
|
2301
|
+
const globals = getGlobals();
|
|
2302
|
+
|
|
2303
|
+
const {
|
|
2304
|
+
resolveProcessPaths,
|
|
2305
|
+
statusProcess,
|
|
2306
|
+
stopProcess,
|
|
2307
|
+
spawnDetached,
|
|
2308
|
+
inspectForeignLive,
|
|
2309
|
+
verifyPidFileMatchesSelf,
|
|
2310
|
+
DETACHED_CHILD_FLAG: childFlag,
|
|
2311
|
+
} = await import("./detach.js");
|
|
2312
|
+
|
|
2313
|
+
const paths = resolveProcessPaths("daemon", {
|
|
2314
|
+
pidFile: cmdOpts.pidFile as string | undefined,
|
|
2315
|
+
logFile: cmdOpts.logFile as string | undefined,
|
|
2316
|
+
cwd: process.cwd(),
|
|
2317
|
+
});
|
|
2318
|
+
|
|
2319
|
+
// Per `spec/cli.md`, `--json` is only defined for `gno daemon --status`.
|
|
2320
|
+
// Silently accepting `--json` on --detach / --stop / foreground would let
|
|
2321
|
+
// users think they'll get structured output; fail fast instead. Mirrors
|
|
2322
|
+
// the serve wiring at handleServeAction.
|
|
2323
|
+
const jsonRequested = Boolean(cmdOpts.json) || globals.json;
|
|
2324
|
+
if (jsonRequested && !cmdOpts.status) {
|
|
2325
|
+
throw new CliError(
|
|
2326
|
+
"VALIDATION",
|
|
2327
|
+
"--json is only supported with `gno daemon --status`"
|
|
2328
|
+
);
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
if (cmdOpts.status) {
|
|
2332
|
+
const json = Boolean(cmdOpts.json) || globals.json;
|
|
2333
|
+
await runDaemonStatus({
|
|
2334
|
+
paths,
|
|
2335
|
+
json,
|
|
2336
|
+
statusProcess,
|
|
2337
|
+
inspectForeignLive,
|
|
2217
2338
|
});
|
|
2339
|
+
return;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
if (cmdOpts.stop) {
|
|
2343
|
+
await runDaemonStop({ pidFile: paths.pidFile, stopProcess });
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
if (cmdOpts.detach) {
|
|
2348
|
+
await runDaemonDetach({
|
|
2349
|
+
paths,
|
|
2350
|
+
spawnDetached,
|
|
2351
|
+
argv: resolveCliArgv(cmd),
|
|
2352
|
+
});
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
// Detached-child path: the parent spawned us with DETACHED_CHILD_FLAG.
|
|
2357
|
+
// Confirm the pid-file points at us before booting; if the parent never
|
|
2358
|
+
// registered us, exit cleanly rather than run unmanaged. On success,
|
|
2359
|
+
// install a shutdown hook that unlinks the pid-file. The daemon's own
|
|
2360
|
+
// `createSignalPromise` (in commands/daemon.ts) already drains SIGINT/
|
|
2361
|
+
// SIGTERM cleanly; `installPidFileCleanup` adds the unlink on top so the
|
|
2362
|
+
// pid-file disappears even if the runtime teardown misbehaves.
|
|
2363
|
+
const isDetachedChild = Boolean(
|
|
2364
|
+
(cmdOpts as Record<string, unknown>)[toCamelCase(childFlag)]
|
|
2365
|
+
);
|
|
2366
|
+
if (isDetachedChild) {
|
|
2367
|
+
const matched = await verifyPidFileMatchesSelf({ pidFile: paths.pidFile });
|
|
2368
|
+
if (!matched) {
|
|
2369
|
+
// Parent crashed before registering us, or another racer won.
|
|
2370
|
+
return;
|
|
2371
|
+
}
|
|
2372
|
+
installPidFileCleanup(paths.pidFile);
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
const { daemon } = await import("./commands/daemon.js");
|
|
2376
|
+
const result = await daemon({
|
|
2377
|
+
configPath: globals.config,
|
|
2378
|
+
index: globals.index,
|
|
2379
|
+
offline: globals.offline,
|
|
2380
|
+
verbose: globals.verbose,
|
|
2381
|
+
quiet: globals.quiet,
|
|
2382
|
+
noSyncOnStart: cmdOpts.syncOnStart === false,
|
|
2383
|
+
});
|
|
2384
|
+
if (!result.success) {
|
|
2385
|
+
throw new CliError("RUNTIME", result.error);
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
interface DaemonStatusDeps {
|
|
2390
|
+
paths: { pidFile: string; logFile: string };
|
|
2391
|
+
json: boolean;
|
|
2392
|
+
statusProcess: typeof import("./detach.js").statusProcess;
|
|
2393
|
+
inspectForeignLive: typeof import("./detach.js").inspectForeignLive;
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
async function runDaemonStatus(deps: DaemonStatusDeps): Promise<void> {
|
|
2397
|
+
const status = await deps.statusProcess({
|
|
2398
|
+
kind: "daemon",
|
|
2399
|
+
pidFile: deps.paths.pidFile,
|
|
2400
|
+
logFile: deps.paths.logFile,
|
|
2401
|
+
});
|
|
2402
|
+
const foreign = await deps.inspectForeignLive({
|
|
2403
|
+
kind: "daemon",
|
|
2404
|
+
pidFile: deps.paths.pidFile,
|
|
2405
|
+
});
|
|
2406
|
+
|
|
2407
|
+
if (deps.json) {
|
|
2408
|
+
process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
|
|
2409
|
+
// In JSON mode, foreign-live metadata flows into the NOT_RUNNING
|
|
2410
|
+
// envelope's `details` payload below so stderr stays a single JSON
|
|
2411
|
+
// object that machine clients can parse deterministically.
|
|
2412
|
+
} else {
|
|
2413
|
+
process.stdout.write("gno daemon status\n");
|
|
2414
|
+
process.stdout.write(`${"─".repeat(50)}\n`);
|
|
2415
|
+
if (status.running) {
|
|
2416
|
+
// Daemon is headless — no port to print.
|
|
2417
|
+
process.stdout.write(` running yes (pid ${status.pid})\n`);
|
|
2418
|
+
if (status.version) {
|
|
2419
|
+
process.stdout.write(` version ${status.version}\n`);
|
|
2420
|
+
}
|
|
2421
|
+
if (status.started_at) {
|
|
2422
|
+
process.stdout.write(
|
|
2423
|
+
` started ${status.started_at} (uptime ${status.uptime_seconds ?? 0}s)\n`
|
|
2424
|
+
);
|
|
2425
|
+
}
|
|
2426
|
+
} else {
|
|
2427
|
+
process.stdout.write(" running no\n");
|
|
2428
|
+
}
|
|
2429
|
+
process.stdout.write(` pid-file ${status.pid_file}\n`);
|
|
2430
|
+
process.stdout.write(` log-file ${status.log_file}`);
|
|
2431
|
+
if (status.log_size_bytes === null) {
|
|
2432
|
+
process.stdout.write(" (missing)\n");
|
|
2433
|
+
} else {
|
|
2434
|
+
process.stdout.write(` (${status.log_size_bytes} bytes)\n`);
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
if (foreign) {
|
|
2438
|
+
// Terminal mode: emit the operator-facing warning on stderr. JSON
|
|
2439
|
+
// clients get the same data via the NOT_RUNNING envelope's details.
|
|
2440
|
+
process.stderr.write(
|
|
2441
|
+
`Warning: pid ${foreign.pid} is live but recorded gno version ${foreign.recordedVersion} differs from current ${foreign.currentVersion}; refusing to claim ownership.\n`
|
|
2442
|
+
);
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
// Spec: `--status` exits 3 (NOT_RUNNING) when no live matching process is
|
|
2447
|
+
// found. The stdout payload stays schema-clean — we've already written it —
|
|
2448
|
+
// so throw a NOT_RUNNING *after* output so the envelope only hits stderr
|
|
2449
|
+
// and the exit code propagates through `runCli -> exitCodeFor`.
|
|
2450
|
+
if (!status.running) {
|
|
2451
|
+
throw new CliError(
|
|
2452
|
+
"NOT_RUNNING",
|
|
2453
|
+
`gno daemon is not running (pid-file ${status.pid_file}${foreign ? `; live-foreign pid ${foreign.pid}` : ""})`,
|
|
2454
|
+
{
|
|
2455
|
+
details: foreign
|
|
2456
|
+
? {
|
|
2457
|
+
foreign_live: {
|
|
2458
|
+
pid: foreign.pid,
|
|
2459
|
+
recorded_version: foreign.recordedVersion,
|
|
2460
|
+
current_version: foreign.currentVersion,
|
|
2461
|
+
},
|
|
2462
|
+
}
|
|
2463
|
+
: undefined,
|
|
2464
|
+
}
|
|
2465
|
+
);
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
interface DaemonStopDeps {
|
|
2470
|
+
pidFile: string;
|
|
2471
|
+
stopProcess: typeof import("./detach.js").stopProcess;
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
async function runDaemonStop(deps: DaemonStopDeps): Promise<void> {
|
|
2475
|
+
const outcome = await deps.stopProcess({
|
|
2476
|
+
kind: "daemon",
|
|
2477
|
+
pidFile: deps.pidFile,
|
|
2478
|
+
});
|
|
2479
|
+
|
|
2480
|
+
switch (outcome.kind) {
|
|
2481
|
+
case "stopped":
|
|
2482
|
+
process.stdout.write(
|
|
2483
|
+
`Stopped gno daemon (pid ${outcome.pid}, ${outcome.signal})\n`
|
|
2484
|
+
);
|
|
2485
|
+
return;
|
|
2486
|
+
case "not-running":
|
|
2487
|
+
// Per spec/cli.md: `--stop` with no pid-file exits 3 silently (no
|
|
2488
|
+
// error envelope on either stream, no `--json` support).
|
|
2489
|
+
throw new CliError(
|
|
2490
|
+
"NOT_RUNNING",
|
|
2491
|
+
`gno daemon is not running (pid-file ${outcome.pidFile} missing or stale)`,
|
|
2492
|
+
{ silent: true }
|
|
2493
|
+
);
|
|
2494
|
+
case "timeout":
|
|
2495
|
+
throw new CliError(
|
|
2496
|
+
"RUNTIME",
|
|
2497
|
+
`gno daemon (pid ${outcome.pid}) did not exit after SIGTERM + SIGKILL. Investigate manually.`
|
|
2498
|
+
);
|
|
2499
|
+
case "foreign-live":
|
|
2500
|
+
throw new CliError(
|
|
2501
|
+
"VALIDATION",
|
|
2502
|
+
`gno daemon (pid ${outcome.pid}) is live but was started by gno ${outcome.payload.version}; this binary is ${VERSION}. Refusing to signal pid ${outcome.pid}; terminate it manually and delete ${deps.pidFile}.`
|
|
2503
|
+
);
|
|
2504
|
+
default: {
|
|
2505
|
+
const exhaustive: never = outcome;
|
|
2506
|
+
throw new Error(`unreachable stop outcome: ${String(exhaustive)}`);
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
interface DaemonDetachDeps {
|
|
2512
|
+
paths: { pidFile: string; logFile: string };
|
|
2513
|
+
spawnDetached: typeof import("./detach.js").spawnDetached;
|
|
2514
|
+
/**
|
|
2515
|
+
* The user-facing argv slice (everything after `[execPath, scriptPath]`)
|
|
2516
|
+
* sourced from `Command.rawArgs` via `resolveCliArgv()`. Per-invocation,
|
|
2517
|
+
* not process-global, so back-to-back `runCli([...])` calls in the same
|
|
2518
|
+
* process don't taint each other.
|
|
2519
|
+
*/
|
|
2520
|
+
argv: string[];
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
async function runDaemonDetach(deps: DaemonDetachDeps): Promise<void> {
|
|
2524
|
+
// Strip --detach from the re-exec argv so the child takes the foreground /
|
|
2525
|
+
// detached-child branch instead of re-spawning itself in an infinite loop.
|
|
2526
|
+
const childArgv = stripDetachFlag(deps.argv);
|
|
2527
|
+
const result = await deps.spawnDetached({
|
|
2528
|
+
kind: "daemon",
|
|
2529
|
+
argv: childArgv,
|
|
2530
|
+
pidFile: deps.paths.pidFile,
|
|
2531
|
+
logFile: deps.paths.logFile,
|
|
2532
|
+
});
|
|
2533
|
+
// Daemon is headless — print pid only (no URL).
|
|
2534
|
+
process.stdout.write(`PID ${result.pid}\n`);
|
|
2218
2535
|
}
|
|
2219
2536
|
|
|
2220
2537
|
function wireServeCommand(program: Command): void {
|
|
2221
|
-
program
|
|
2538
|
+
const serveCmd = program
|
|
2222
2539
|
.command("serve")
|
|
2223
2540
|
.description("Start web UI server")
|
|
2224
2541
|
.option("-p, --port <num>", "port to listen on", "3000")
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2542
|
+
// Local `--json` so `gno serve --status --json` parses cleanly in any
|
|
2543
|
+
// argv position. Global `--json` already exists on the root program
|
|
2544
|
+
// (see line 208); resolution goes through `getFormat()` /
|
|
2545
|
+
// `getGlobals()` which precedence-merges local over global.
|
|
2546
|
+
.option(
|
|
2547
|
+
"--json",
|
|
2548
|
+
"JSON output (applies to --status; see process-status schema)"
|
|
2549
|
+
);
|
|
2228
2550
|
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2551
|
+
// --detach / --status / --stop are mutually exclusive. Use Commander's
|
|
2552
|
+
// Option API so the conflict error is the native "option '--status'
|
|
2553
|
+
// cannot be used with option '--detach'" surface.
|
|
2554
|
+
serveCmd.addOption(
|
|
2555
|
+
new Option(
|
|
2556
|
+
"--detach",
|
|
2557
|
+
"run as a detached background process (macOS/Linux only)"
|
|
2558
|
+
).conflicts(["status", "stop"])
|
|
2559
|
+
);
|
|
2560
|
+
serveCmd.addOption(
|
|
2561
|
+
new Option(
|
|
2562
|
+
"--status",
|
|
2563
|
+
"show status of the detached serve process (use --json for machine output)"
|
|
2564
|
+
).conflicts(["detach", "stop"])
|
|
2565
|
+
);
|
|
2566
|
+
serveCmd.addOption(
|
|
2567
|
+
new Option(
|
|
2568
|
+
"--stop",
|
|
2569
|
+
"stop the detached serve process (SIGTERM then SIGKILL fallback)"
|
|
2570
|
+
).conflicts(["detach", "status"])
|
|
2571
|
+
);
|
|
2572
|
+
serveCmd.addOption(new Option("--pid-file <path>", "override pid-file path"));
|
|
2573
|
+
serveCmd.addOption(
|
|
2574
|
+
new Option("--log-file <path>", "override log-file path (append-only)")
|
|
2575
|
+
);
|
|
2576
|
+
// Sentinel flag set by the parent when re-exec'ing the detached child.
|
|
2577
|
+
// Hidden from --help; Commander consumes it via addOption so it doesn't
|
|
2578
|
+
// leak into user-visible argv.
|
|
2579
|
+
serveCmd.addOption(
|
|
2580
|
+
new Option(
|
|
2581
|
+
`${DETACHED_CHILD_FLAG}`,
|
|
2582
|
+
"internal detached-child marker"
|
|
2583
|
+
).hideHelp()
|
|
2584
|
+
);
|
|
2235
2585
|
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2586
|
+
serveCmd.action(async (cmdOpts: Record<string, unknown>, cmd: Command) => {
|
|
2587
|
+
await handleServeAction(cmdOpts, cmd);
|
|
2588
|
+
});
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2592
|
+
// Serve lifecycle branching (detach / status / stop / detached-child / fg)
|
|
2593
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2594
|
+
|
|
2595
|
+
/**
|
|
2596
|
+
* Route `gno serve` through the detach helpers based on which mutex flag the
|
|
2597
|
+
* user set. The four early branches return without touching `startServer()`;
|
|
2598
|
+
* only the foreground + detached-child paths boot the runtime.
|
|
2599
|
+
*/
|
|
2600
|
+
async function handleServeAction(
|
|
2601
|
+
cmdOpts: Record<string, unknown>,
|
|
2602
|
+
cmd: Command
|
|
2603
|
+
): Promise<void> {
|
|
2604
|
+
const globals = getGlobals();
|
|
2605
|
+
// NB: do NOT parse --port here. The status/stop branches don't need it
|
|
2606
|
+
// and rejecting `gno serve --status --port nope` on irrelevant input
|
|
2607
|
+
// would break management commands. parsePositiveInt is called inside
|
|
2608
|
+
// the foreground + detach paths where the port actually matters.
|
|
2609
|
+
|
|
2610
|
+
const {
|
|
2611
|
+
resolveProcessPaths,
|
|
2612
|
+
statusProcess,
|
|
2613
|
+
stopProcess,
|
|
2614
|
+
spawnDetached,
|
|
2615
|
+
inspectForeignLive,
|
|
2616
|
+
verifyPidFileMatchesSelf,
|
|
2617
|
+
DETACHED_CHILD_FLAG: childFlag,
|
|
2618
|
+
} = await import("./detach.js");
|
|
2619
|
+
|
|
2620
|
+
const paths = resolveProcessPaths("serve", {
|
|
2621
|
+
pidFile: cmdOpts.pidFile as string | undefined,
|
|
2622
|
+
logFile: cmdOpts.logFile as string | undefined,
|
|
2623
|
+
cwd: process.cwd(),
|
|
2624
|
+
});
|
|
2625
|
+
|
|
2626
|
+
// Per `spec/cli.md`, `--json` is only defined for `gno serve --status`.
|
|
2627
|
+
// Silently accepting `--json` on --detach / --stop / foreground would let
|
|
2628
|
+
// users think they'll get structured output; fail fast instead. Commander
|
|
2629
|
+
// hoists `--json` to the root when both root and sub-command declare it
|
|
2630
|
+
// (verified against commander@14.0.2), so we consult the local flag first
|
|
2631
|
+
// and fall back to the global flag only if the user is mixing `serve`
|
|
2632
|
+
// with anything but `--status`.
|
|
2633
|
+
const jsonRequested = Boolean(cmdOpts.json) || globals.json;
|
|
2634
|
+
if (jsonRequested && !cmdOpts.status) {
|
|
2635
|
+
throw new CliError(
|
|
2636
|
+
"VALIDATION",
|
|
2637
|
+
"--json is only supported with `gno serve --status`"
|
|
2638
|
+
);
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
if (cmdOpts.status) {
|
|
2642
|
+
// Local --json wins over global so `gno serve --status --json` and
|
|
2643
|
+
// `gno --json serve --status` both produce the same result.
|
|
2644
|
+
const json = Boolean(cmdOpts.json) || globals.json;
|
|
2645
|
+
await runServeStatus({
|
|
2646
|
+
paths,
|
|
2647
|
+
json,
|
|
2648
|
+
statusProcess,
|
|
2649
|
+
inspectForeignLive,
|
|
2240
2650
|
});
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
if (cmdOpts.stop) {
|
|
2655
|
+
await runServeStop({ pidFile: paths.pidFile, stopProcess });
|
|
2656
|
+
return;
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
if (cmdOpts.detach) {
|
|
2660
|
+
const port = parsePositiveInt("port", cmdOpts.port);
|
|
2661
|
+
await runServeDetach({
|
|
2662
|
+
port,
|
|
2663
|
+
paths,
|
|
2664
|
+
spawnDetached,
|
|
2665
|
+
argv: resolveCliArgv(cmd),
|
|
2666
|
+
});
|
|
2667
|
+
return;
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
// Detached-child path: the parent spawned us with DETACHED_CHILD_FLAG.
|
|
2671
|
+
// Confirm the pid-file points at us before booting; if the parent never
|
|
2672
|
+
// registered us, exit cleanly rather than run unmanaged. On success,
|
|
2673
|
+
// install a shutdown hook that unlinks the pid-file.
|
|
2674
|
+
const isDetachedChild = Boolean(
|
|
2675
|
+
(cmdOpts as Record<string, unknown>)[toCamelCase(childFlag)]
|
|
2676
|
+
);
|
|
2677
|
+
if (isDetachedChild) {
|
|
2678
|
+
const matched = await verifyPidFileMatchesSelf({ pidFile: paths.pidFile });
|
|
2679
|
+
if (!matched) {
|
|
2680
|
+
// Parent crashed before registering us, or another racer won.
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
installPidFileCleanup(paths.pidFile);
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
// Foreground + detached-child both need to actually bind a port; validate
|
|
2687
|
+
// here (after the management branches have returned) so a bad --port only
|
|
2688
|
+
// breaks invocations that would have used it.
|
|
2689
|
+
const port = parsePositiveInt("port", cmdOpts.port);
|
|
2690
|
+
|
|
2691
|
+
const { serve } = await import("./commands/serve.js");
|
|
2692
|
+
const result = await serve({
|
|
2693
|
+
port,
|
|
2694
|
+
configPath: globals.config,
|
|
2695
|
+
index: globals.index,
|
|
2696
|
+
});
|
|
2697
|
+
|
|
2698
|
+
if (!result.success) {
|
|
2699
|
+
throw new CliError("RUNTIME", result.error ?? "Server failed to start");
|
|
2700
|
+
}
|
|
2701
|
+
// Server runs until SIGINT/SIGTERM - no output needed here
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
interface ServeStatusDeps {
|
|
2705
|
+
paths: { pidFile: string; logFile: string };
|
|
2706
|
+
json: boolean;
|
|
2707
|
+
statusProcess: typeof import("./detach.js").statusProcess;
|
|
2708
|
+
inspectForeignLive: typeof import("./detach.js").inspectForeignLive;
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
async function runServeStatus(deps: ServeStatusDeps): Promise<void> {
|
|
2712
|
+
const status = await deps.statusProcess({
|
|
2713
|
+
kind: "serve",
|
|
2714
|
+
pidFile: deps.paths.pidFile,
|
|
2715
|
+
logFile: deps.paths.logFile,
|
|
2716
|
+
});
|
|
2717
|
+
const foreign = await deps.inspectForeignLive({
|
|
2718
|
+
kind: "serve",
|
|
2719
|
+
pidFile: deps.paths.pidFile,
|
|
2720
|
+
});
|
|
2721
|
+
|
|
2722
|
+
if (deps.json) {
|
|
2723
|
+
process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
|
|
2724
|
+
// In JSON mode, foreign-live metadata flows into the NOT_RUNNING
|
|
2725
|
+
// envelope's `details` payload below so stderr stays a single JSON
|
|
2726
|
+
// object that machine clients can parse deterministically.
|
|
2727
|
+
} else {
|
|
2728
|
+
process.stdout.write("gno serve status\n");
|
|
2729
|
+
process.stdout.write(`${"─".repeat(50)}\n`);
|
|
2730
|
+
if (status.running) {
|
|
2731
|
+
const portText =
|
|
2732
|
+
status.port === null ? "(unknown port)" : `port ${status.port}`;
|
|
2733
|
+
process.stdout.write(` running yes (pid ${status.pid}, ${portText})\n`);
|
|
2734
|
+
if (status.version) {
|
|
2735
|
+
process.stdout.write(` version ${status.version}\n`);
|
|
2736
|
+
}
|
|
2737
|
+
if (status.started_at) {
|
|
2738
|
+
process.stdout.write(
|
|
2739
|
+
` started ${status.started_at} (uptime ${status.uptime_seconds ?? 0}s)\n`
|
|
2740
|
+
);
|
|
2741
|
+
}
|
|
2742
|
+
} else {
|
|
2743
|
+
process.stdout.write(" running no\n");
|
|
2744
|
+
}
|
|
2745
|
+
process.stdout.write(` pid-file ${status.pid_file}\n`);
|
|
2746
|
+
process.stdout.write(` log-file ${status.log_file}`);
|
|
2747
|
+
if (status.log_size_bytes === null) {
|
|
2748
|
+
process.stdout.write(" (missing)\n");
|
|
2749
|
+
} else {
|
|
2750
|
+
process.stdout.write(` (${status.log_size_bytes} bytes)\n`);
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
if (foreign) {
|
|
2754
|
+
// Terminal mode: emit the operator-facing warning on stderr. JSON
|
|
2755
|
+
// clients get the same data via the NOT_RUNNING envelope's details.
|
|
2756
|
+
process.stderr.write(
|
|
2757
|
+
`Warning: pid ${foreign.pid} is live but recorded gno version ${foreign.recordedVersion} differs from current ${foreign.currentVersion}; refusing to claim ownership.\n`
|
|
2758
|
+
);
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
// Spec: `--status` exits 3 (NOT_RUNNING) when no live matching process is
|
|
2763
|
+
// found. The stdout payload stays schema-clean — we've already written it —
|
|
2764
|
+
// so throw a NOT_RUNNING *after* output so the envelope only hits stderr
|
|
2765
|
+
// and the exit code propagates through `runCli -> exitCodeFor`. A
|
|
2766
|
+
// live-foreign pid is also "not ours", so we cannot claim running:true for
|
|
2767
|
+
// it; `statusProcess` already reports `running:false` in that case.
|
|
2768
|
+
if (!status.running) {
|
|
2769
|
+
throw new CliError(
|
|
2770
|
+
"NOT_RUNNING",
|
|
2771
|
+
`gno serve is not running (pid-file ${status.pid_file}${foreign ? `; live-foreign pid ${foreign.pid}` : ""})`,
|
|
2772
|
+
{
|
|
2773
|
+
details: foreign
|
|
2774
|
+
? {
|
|
2775
|
+
foreign_live: {
|
|
2776
|
+
pid: foreign.pid,
|
|
2777
|
+
recorded_version: foreign.recordedVersion,
|
|
2778
|
+
current_version: foreign.currentVersion,
|
|
2779
|
+
},
|
|
2780
|
+
}
|
|
2781
|
+
: undefined,
|
|
2782
|
+
}
|
|
2783
|
+
);
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
interface ServeStopDeps {
|
|
2788
|
+
pidFile: string;
|
|
2789
|
+
stopProcess: typeof import("./detach.js").stopProcess;
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
async function runServeStop(deps: ServeStopDeps): Promise<void> {
|
|
2793
|
+
const outcome = await deps.stopProcess({
|
|
2794
|
+
kind: "serve",
|
|
2795
|
+
pidFile: deps.pidFile,
|
|
2796
|
+
});
|
|
2797
|
+
|
|
2798
|
+
switch (outcome.kind) {
|
|
2799
|
+
case "stopped":
|
|
2800
|
+
process.stdout.write(
|
|
2801
|
+
`Stopped gno serve (pid ${outcome.pid}, ${outcome.signal})\n`
|
|
2802
|
+
);
|
|
2803
|
+
return;
|
|
2804
|
+
case "not-running":
|
|
2805
|
+
// Per spec/cli.md: `--stop` with no pid-file exits 3 silently (no
|
|
2806
|
+
// error envelope on either stream, no `--json` support).
|
|
2807
|
+
throw new CliError(
|
|
2808
|
+
"NOT_RUNNING",
|
|
2809
|
+
`gno serve is not running (pid-file ${outcome.pidFile} missing or stale)`,
|
|
2810
|
+
{ silent: true }
|
|
2811
|
+
);
|
|
2812
|
+
case "timeout":
|
|
2813
|
+
throw new CliError(
|
|
2814
|
+
"RUNTIME",
|
|
2815
|
+
`gno serve (pid ${outcome.pid}) did not exit after SIGTERM + SIGKILL. Investigate manually.`
|
|
2816
|
+
);
|
|
2817
|
+
case "foreign-live":
|
|
2818
|
+
throw new CliError(
|
|
2819
|
+
"VALIDATION",
|
|
2820
|
+
`gno serve (pid ${outcome.pid}) is live but was started by gno ${outcome.payload.version}; this binary is ${VERSION}. Refusing to signal pid ${outcome.pid}; terminate it manually and delete ${deps.pidFile}.`
|
|
2821
|
+
);
|
|
2822
|
+
default: {
|
|
2823
|
+
const exhaustive: never = outcome;
|
|
2824
|
+
throw new Error(`unreachable stop outcome: ${String(exhaustive)}`);
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
interface ServeDetachDeps {
|
|
2830
|
+
port: number;
|
|
2831
|
+
paths: { pidFile: string; logFile: string };
|
|
2832
|
+
spawnDetached: typeof import("./detach.js").spawnDetached;
|
|
2833
|
+
/**
|
|
2834
|
+
* The user-facing argv slice from `Command.rawArgs` via `resolveCliArgv()`.
|
|
2835
|
+
* Per-invocation so back-to-back `runCli([...])` calls in the same process
|
|
2836
|
+
* can't taint each other's child argv.
|
|
2837
|
+
*/
|
|
2838
|
+
argv: string[];
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
async function runServeDetach(deps: ServeDetachDeps): Promise<void> {
|
|
2842
|
+
// Strip --detach from the re-exec argv so the child takes the foreground /
|
|
2843
|
+
// detached-child branch instead of re-spawning itself in an infinite loop.
|
|
2844
|
+
const childArgv = stripDetachFlag(deps.argv);
|
|
2845
|
+
const result = await deps.spawnDetached({
|
|
2846
|
+
kind: "serve",
|
|
2847
|
+
argv: childArgv,
|
|
2848
|
+
pidFile: deps.paths.pidFile,
|
|
2849
|
+
logFile: deps.paths.logFile,
|
|
2850
|
+
port: deps.port,
|
|
2851
|
+
});
|
|
2852
|
+
process.stdout.write(
|
|
2853
|
+
`PID ${result.pid} listening on http://localhost:${deps.port}\n`
|
|
2854
|
+
);
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
/**
|
|
2858
|
+
* Remove `--detach` (and its short/long variants) from an argv slice while
|
|
2859
|
+
* preserving the order and value of every other argument. We only strip the
|
|
2860
|
+
* literal long form because that's the only form we register.
|
|
2861
|
+
*/
|
|
2862
|
+
function stripDetachFlag(argv: string[]): string[] {
|
|
2863
|
+
return argv.filter((a) => a !== "--detach");
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
/**
|
|
2867
|
+
* Install a one-shot SIGINT/SIGTERM handler that unlinks the pid-file before
|
|
2868
|
+
* the default signal handling (startServer's own shutdown) runs. Uses a sync
|
|
2869
|
+
* unlink so the pid-file is gone even if the subsequent async teardown
|
|
2870
|
+
* misbehaves.
|
|
2871
|
+
*/
|
|
2872
|
+
function installPidFileCleanup(pidFile: string): void {
|
|
2873
|
+
const cleanup = () => {
|
|
2874
|
+
try {
|
|
2875
|
+
unlinkSync(pidFile);
|
|
2876
|
+
} catch {
|
|
2877
|
+
// Already gone or permission-denied — nothing actionable here.
|
|
2878
|
+
}
|
|
2879
|
+
};
|
|
2880
|
+
process.once("SIGINT", cleanup);
|
|
2881
|
+
process.once("SIGTERM", cleanup);
|
|
2882
|
+
// Also run on a clean (`exit(0)`) path so crashes leave a stale pid-file
|
|
2883
|
+
// that `--status` can detect via liveness check, but orderly shutdown
|
|
2884
|
+
// (e.g. startServer returned) still cleans up.
|
|
2885
|
+
process.once("beforeExit", cleanup);
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
/**
|
|
2889
|
+
* Commander lowercases a long option name into camelCase for `cmdOpts`. The
|
|
2890
|
+
* sentinel flag `--__detached-child` maps to `__detachedChild` on the opts
|
|
2891
|
+
* bag; we derive that programmatically so the constant stays the single
|
|
2892
|
+
* source of truth.
|
|
2893
|
+
*/
|
|
2894
|
+
function toCamelCase(longFlag: string): string {
|
|
2895
|
+
return longFlag
|
|
2896
|
+
.replace(/^-+/, "")
|
|
2897
|
+
.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
2241
2898
|
}
|
|
2242
2899
|
|
|
2243
2900
|
// ─────────────────────────────────────────────────────────────────────────────
|