@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.
@@ -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
- .action(async (cmdOpts: Record<string, unknown>) => {
2204
- const globals = getGlobals();
2205
- const { daemon } = await import("./commands/daemon.js");
2206
- const result = await daemon({
2207
- configPath: globals.config,
2208
- index: globals.index,
2209
- offline: globals.offline,
2210
- verbose: globals.verbose,
2211
- quiet: globals.quiet,
2212
- noSyncOnStart: cmdOpts.syncOnStart === false,
2213
- });
2214
- if (!result.success) {
2215
- throw new CliError("RUNTIME", result.error);
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
- .action(async (cmdOpts: Record<string, unknown>) => {
2226
- const globals = getGlobals();
2227
- const port = parsePositiveInt("port", cmdOpts.port);
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
- const { serve } = await import("./commands/serve.js");
2230
- const result = await serve({
2231
- port,
2232
- configPath: globals.config,
2233
- index: globals.index,
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
- if (!result.success) {
2237
- throw new CliError("RUNTIME", result.error ?? "Server failed to start");
2238
- }
2239
- // Server runs until SIGINT/SIGTERM - no output needed here
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
  // ─────────────────────────────────────────────────────────────────────────────