@gmickel/gno 2.9.1 → 2.10.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.
Files changed (49) hide show
  1. package/README.md +2 -2
  2. package/assets/spa-production.json.gz +0 -0
  3. package/browser-extension/artifacts/{gno-browser-clipper-v2.9.1.zip → gno-browser-clipper-v2.10.0.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v2.10.0.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/package.json +3 -2
  7. package/spec/cli.md +73 -2
  8. package/spec/output-schemas/collection-list.schema.json +5 -0
  9. package/src/app/bun-runtime.ts +41 -0
  10. package/src/cli/commands/daemon.ts +12 -0
  11. package/src/cli/commands/doctor.ts +61 -0
  12. package/src/cli/commands/mcp/config-editors.ts +56 -8
  13. package/src/cli/commands/mcp/install.ts +159 -24
  14. package/src/cli/commands/mcp/launch-path.ts +87 -0
  15. package/src/cli/commands/mcp/paths.ts +30 -6
  16. package/src/cli/commands/mcp/repair-entry.ts +160 -0
  17. package/src/cli/commands/mcp/status.ts +55 -15
  18. package/src/cli/commands/mcp.ts +4 -0
  19. package/src/cli/commands/serve.ts +3 -0
  20. package/src/cli/commands/status.ts +24 -0
  21. package/src/cli/detach.ts +52 -5
  22. package/src/cli/program.ts +19 -5
  23. package/src/cli/runtime-health.ts +328 -0
  24. package/src/config/types.ts +3 -0
  25. package/src/core/network-boundary-inventory.ts +16 -0
  26. package/src/core/project-profile-apply-state.ts +3 -0
  27. package/src/core/resident-shutdown-handler.ts +23 -0
  28. package/src/core/sighup-disposition.ts +66 -0
  29. package/src/core/time-slice.ts +34 -0
  30. package/src/ingestion/file-processor.ts +118 -17
  31. package/src/ingestion/graph-reconciliation.ts +18 -4
  32. package/src/ingestion/prepare-worker.ts +20 -2
  33. package/src/ingestion/sync.ts +70 -4
  34. package/src/serve/parent-lifeline.ts +44 -0
  35. package/src/serve/public/components/BootstrapStatus.tsx +3 -3
  36. package/src/serve/public/globals.built.css +1 -1
  37. package/src/serve/resident-runtime.ts +24 -1
  38. package/src/serve/resident-shutdown.ts +4 -0
  39. package/src/serve/server.ts +22 -1
  40. package/src/serve/status.ts +2 -1
  41. package/src/serve/watch-reconciliation.ts +134 -1
  42. package/src/serve/watch-service-flush-generation.ts +77 -3
  43. package/src/serve/watch-service.ts +27 -3
  44. package/src/sessions/import-child.ts +79 -11
  45. package/src/store/migrations/035-vector-length-index.ts +30 -0
  46. package/src/store/migrations/index.ts +2 -0
  47. package/src/store/sqlite/adapter.ts +4 -1
  48. package/src/store/vector/freshness.ts +13 -6
  49. package/browser-extension/artifacts/gno-browser-clipper-v2.9.1.zip.sha256 +0 -1
@@ -5,6 +5,8 @@
5
5
  * @module src/cli/commands/serve
6
6
  */
7
7
 
8
+ import { warnIfBunBelowFloor } from "../../app/bun-runtime";
9
+
8
10
  export type { ServeOptions, ServeResult } from "../../serve";
9
11
 
10
12
  /**
@@ -14,6 +16,7 @@ export type { ServeOptions, ServeResult } from "../../serve";
14
16
  export async function serve(
15
17
  options: import("../../serve").ServeOptions = {}
16
18
  ): Promise<import("../../serve").ServeResult> {
19
+ warnIfBunBelowFloor("serve");
17
20
  const { startServer } = await import("../../serve");
18
21
  return startServer(options);
19
22
  }
@@ -44,6 +44,12 @@ import {
44
44
  resolveProcessPaths,
45
45
  statusProcess,
46
46
  } from "../detach";
47
+ import {
48
+ checkRegistrationRuntimes,
49
+ checkResidentRuntimes,
50
+ formatRuntimeIssue,
51
+ type RuntimeIssue,
52
+ } from "../runtime-health";
47
53
 
48
54
  /**
49
55
  * Options for status command.
@@ -70,6 +76,8 @@ export type StatusResult =
70
76
  contentTypeBoost: ContentTypeBoostStatus;
71
77
  memory: MemoryStatus;
72
78
  backgroundIssues: ResidentBackgroundIssue[];
79
+ /** Registrations and residents on a missing, old or other Bun (fn-213). */
80
+ runtimeIssues?: RuntimeIssue[];
73
81
  /** Files whose latest conversion stopped at the per-file budget. */
74
82
  budgetStops: BudgetStop[];
75
83
  }
@@ -433,6 +441,10 @@ export async function status(
433
441
  contentTypeBoost: buildContentTypeBoostStatus(config.contentTypes ?? []),
434
442
  memory: await buildMemoryStatus(store, config.collections),
435
443
  backgroundIssues: await collectResidentIssues(),
444
+ runtimeIssues: [
445
+ ...(await checkRegistrationRuntimes()),
446
+ ...(await checkResidentRuntimes()),
447
+ ],
436
448
  budgetStops: await collectBudgetStops(store),
437
449
  };
438
450
  } finally {
@@ -486,6 +498,9 @@ export function formatStatus(
486
498
  ...(result.backgroundIssues.length
487
499
  ? { backgroundIssues: result.backgroundIssues }
488
500
  : {}),
501
+ ...(result.runtimeIssues?.length
502
+ ? { runtimeIssues: result.runtimeIssues }
503
+ : {}),
489
504
  },
490
505
  null,
491
506
  2
@@ -524,5 +539,14 @@ export function formatStatus(
524
539
  ]
525
540
  : []),
526
541
  ...(issueLines.length ? ["", "Background issues:", ...issueLines] : []),
542
+ ...(result.runtimeIssues?.length
543
+ ? [
544
+ "",
545
+ "Bun runtime:",
546
+ ...result.runtimeIssues.map(
547
+ (issue) => ` [${issue.severity}] ${formatRuntimeIssue(issue)}`
548
+ ),
549
+ ]
550
+ : []),
527
551
  ].join("\n");
528
552
  }
package/src/cli/detach.ts CHANGED
@@ -56,6 +56,8 @@ export interface PidFilePayload {
56
56
  version: string;
57
57
  started_at: string;
58
58
  port?: number | null;
59
+ /** Bun the resident runs on (fn-213); absent in older pid-files. */
60
+ bun_version?: string;
59
61
  }
60
62
 
61
63
  /** Result of a `--status` call (matches process-status@1.0). */
@@ -246,6 +248,9 @@ export async function readPidFile(
246
248
  version: record.version,
247
249
  started_at: record.started_at,
248
250
  port,
251
+ ...(typeof record.bun_version === "string" && record.bun_version
252
+ ? { bun_version: record.bun_version }
253
+ : {}),
249
254
  };
250
255
  }
251
256
 
@@ -510,6 +515,17 @@ function acquireStartLock(pidFile: string, kind: DetachKind): string {
510
515
  );
511
516
  }
512
517
 
518
+ /** Take the start lock if it is free right now; null when someone holds it. */
519
+ function tryAcquireStartLock(pidFile: string): string | null {
520
+ const lockPath = startLockPath(pidFile);
521
+ try {
522
+ closeSync(openSync(lockPath, "wx", 0o600));
523
+ return lockPath;
524
+ } catch {
525
+ return null;
526
+ }
527
+ }
528
+
513
529
  function releaseStartLock(lockPath: string): void {
514
530
  try {
515
531
  unlinkSync(lockPath);
@@ -599,6 +615,11 @@ export async function spawnDetached(
599
615
  version: VERSION,
600
616
  started_at: new Date().toISOString(),
601
617
  port: options.port ?? null,
618
+ // The child runs on this process's Bun unless a caller replaced the
619
+ // command.
620
+ ...(options.cmdPrefix || options.execPath
621
+ ? {}
622
+ : { bun_version: Bun.version }),
602
623
  };
603
624
 
604
625
  // If the pid-file write fails (disk full, permission race, bad path on
@@ -943,12 +964,35 @@ async function waitForExit(
943
964
  /**
944
965
  * Stop a detached process: SIGTERM → poll → SIGKILL → poll → error.
945
966
  *
946
- * The pid-file is **not** unlinked on success by this helper — we let the
947
- * target's own signal handler clean it up (see `createSignalPromise` in
948
- * `src/cli/commands/daemon.ts`). Callers may unlink the file as a fallback
949
- * when liveness is `false` after the kill sequence; `stopProcess` itself only
950
- * unlinks stale pid-files it discovers on entry.
967
+ * The target's own signal handler normally removes the pid-file. A target
968
+ * killed before that handler existed (SIGTERM during early startup, or the
969
+ * SIGKILL escalation) cannot, so once the exit is confirmed this helper
970
+ * removes the pid-file if it still names the pid it stopped.
971
+ */
972
+ /**
973
+ * Remove the pid-file only if it still names `pid` (never a newer instance's).
974
+ * The check and the unlink run under the start lock, so a concurrent
975
+ * `--detach` cannot write its pid-file in between. If a start holds the lock,
976
+ * the file is left alone: `guardDoubleStart` already treats a dead pid as
977
+ * stale.
951
978
  */
979
+ async function removePidFileIfOwnedBy(
980
+ pidFile: string,
981
+ pid: number
982
+ ): Promise<void> {
983
+ const lockPath = tryAcquireStartLock(pidFile);
984
+ if (!lockPath) return;
985
+ try {
986
+ const current = await readPidFile(pidFile).catch(() => null);
987
+ if (current?.pid !== pid) return;
988
+ await unlink(pidFile).catch(() => {
989
+ /* already gone */
990
+ });
991
+ } finally {
992
+ releaseStartLock(lockPath);
993
+ }
994
+ }
995
+
952
996
  export async function stopProcess(options: StopOptions): Promise<StopOutcome> {
953
997
  const timeoutMs = options.timeoutMs ?? RESIDENT_STOP_GRACE_MS;
954
998
  const pollIntervalMs = options.pollIntervalMs ?? 100;
@@ -1004,6 +1048,7 @@ export async function stopProcess(options: StopOptions): Promise<StopOutcome> {
1004
1048
  sleep
1005
1049
  );
1006
1050
  if (exitedOnSigterm) {
1051
+ await removePidFileIfOwnedBy(options.pidFile, payload.pid);
1007
1052
  return { kind: "stopped", pid: payload.pid, signal: "SIGTERM" };
1008
1053
  }
1009
1054
 
@@ -1011,6 +1056,7 @@ export async function stopProcess(options: StopOptions): Promise<StopOutcome> {
1011
1056
  kill(payload.pid, "SIGKILL");
1012
1057
  } catch (error) {
1013
1058
  if (isErrnoException(error) && error.code === "ESRCH") {
1059
+ await removePidFileIfOwnedBy(options.pidFile, payload.pid);
1014
1060
  return { kind: "stopped", pid: payload.pid, signal: "SIGTERM" };
1015
1061
  }
1016
1062
  throw error;
@@ -1025,6 +1071,7 @@ export async function stopProcess(options: StopOptions): Promise<StopOutcome> {
1025
1071
  sleep
1026
1072
  );
1027
1073
  if (exitedOnSigkill) {
1074
+ await removePidFileIfOwnedBy(options.pidFile, payload.pid);
1028
1075
  return { kind: "stopped", pid: payload.pid, signal: "SIGKILL" };
1029
1076
  }
1030
1077
 
@@ -25,6 +25,7 @@ import {
25
25
  formatFindingsRunStatusLine,
26
26
  readFindingsRunStatus,
27
27
  } from "../core/findings-run-state";
28
+ import { residentShutdownHandlerInstalled } from "../core/resident-shutdown-handler";
28
29
  import { parseAndValidateTagFilter } from "../core/tags";
29
30
  import {
30
31
  formatWriteLeaseBusyJson,
@@ -2643,7 +2644,7 @@ function wireMcpCommand(program: Command): void {
2643
2644
  "advertised tool set the registration starts with: core (7 read tools + capture/remember with --enable-write) or full (default)"
2644
2645
  )
2645
2646
  .option("--json", "JSON output")
2646
- .action(async (cmdOpts: Record<string, unknown>) => {
2647
+ .action(async (cmdOpts: Record<string, unknown>, command: Command) => {
2647
2648
  const target = cmdOpts.target as string;
2648
2649
  const toolProfile = parseToolProfileOption(cmdOpts.toolProfile);
2649
2650
  const requestedScope = cmdOpts.scope;
@@ -2686,9 +2687,14 @@ function wireMcpCommand(program: Command): void {
2686
2687
  scope: scope as "user" | "project",
2687
2688
  force: Boolean(cmdOpts.force),
2688
2689
  dryRun: Boolean(cmdOpts.dryRun),
2689
- enableWrite: Boolean(cmdOpts.enableWrite),
2690
+ // Only options the user passed: --force keeps the rest of an
2691
+ // existing registration (--index has a default, so check its source).
2692
+ enableWrite: cmdOpts.enableWrite === true ? true : undefined,
2690
2693
  toolProfile,
2691
- indexName: globals.index,
2694
+ indexName:
2695
+ command.getOptionValueSourceWithGlobals("index") === "cli"
2696
+ ? globals.index
2697
+ : undefined,
2692
2698
  configPath: globals.config,
2693
2699
  // Pass undefined if not set, so global --json can take effect
2694
2700
  json: cmdOpts.json === true ? true : undefined,
@@ -5564,8 +5570,16 @@ function installPidFileCleanup(pidFile: string): void {
5564
5570
  // Already gone or permission-denied — nothing actionable here.
5565
5571
  }
5566
5572
  };
5567
- process.once("SIGINT", cleanup);
5568
- process.once("SIGTERM", cleanup);
5573
+ // Registering a listener disables the default terminate action. A signal
5574
+ // that arrives before the daemon/server installs its own shutdown handler
5575
+ // (still starting up) would otherwise be swallowed, and `--stop` would wait
5576
+ // out its grace period and SIGKILL. Until that handler exists, exit now.
5577
+ const onSignal = (exitCode: number) => () => {
5578
+ cleanup();
5579
+ if (!residentShutdownHandlerInstalled()) process.exit(exitCode);
5580
+ };
5581
+ process.once("SIGINT", onSignal(130));
5582
+ process.once("SIGTERM", onSignal(143));
5569
5583
  // Also run on a clean (`exit(0)`) path so crashes leave a stale pid-file
5570
5584
  // that `--status` can detect via liveness check, but orderly shutdown
5571
5585
  // (e.g. startServer returned) still cleans up.
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Which Bun the GNO processes on this machine run on (fn-213).
3
+ *
4
+ * Two read-only checks for `gno doctor` and `gno status`:
5
+ * - client registrations whose interpreter or entrypoint is missing, below
6
+ * the supported Bun, or a different Bun than the one running the check;
7
+ * - running residents (`serve`, `daemon`) on a Bun below the floor or older
8
+ * than the one running the check, including residents started by another
9
+ * GNO version, whose runtime is read from their `/api/status`.
10
+ *
11
+ * Nothing here starts a registered command or signals a process; an
12
+ * interpreter is only asked for `--version`, under a short timeout.
13
+ *
14
+ * @module src/cli/runtime-health
15
+ */
16
+
17
+ // node:fs realpathSync: synchronous link resolution; Bun has no realpath API.
18
+ import { realpathSync } from "node:fs";
19
+ // node:path basename: no Bun path utilities.
20
+ import { basename } from "node:path";
21
+
22
+ import type { McpScope, McpTarget } from "./commands/mcp/paths";
23
+
24
+ import { BUN_FLOOR, bunMeetsFloor } from "../app/bun-runtime";
25
+ import { VERSION } from "../app/constants";
26
+ import { getServersKey } from "./commands/mcp/config";
27
+ import { resolveMcpConfigLocation } from "./commands/mcp/config-discovery";
28
+ import {
29
+ getJsoncServerEntry,
30
+ getTomlServerEntry,
31
+ getYamlServerEntry,
32
+ } from "./commands/mcp/config-editors";
33
+ import { getTargetDisplayName, resolveAllMcpPaths } from "./commands/mcp/paths";
34
+ import { readExistingRegistration } from "./commands/mcp/repair-entry";
35
+ import {
36
+ type DetachKind,
37
+ isProcessAlive,
38
+ readPidFile,
39
+ resolveProcessPaths,
40
+ } from "./detach";
41
+
42
+ export type RuntimeIssueSeverity = "error" | "warn" | "info";
43
+
44
+ export interface RuntimeIssue {
45
+ kind: "registration" | "resident";
46
+ severity: RuntimeIssueSeverity;
47
+ /** What it is about: a client registration or a resident process. */
48
+ subject: string;
49
+ message: string;
50
+ /** Command or steps that repair it. */
51
+ repair: string;
52
+ }
53
+
54
+ /** How long an interpreter may take to answer `--version`. */
55
+ const VERSION_PROBE_TIMEOUT_MS = 2000;
56
+ /** How long a resident may take to answer `/api/status`. */
57
+ const RESIDENT_PROBE_TIMEOUT_MS = 2000;
58
+ const SEMVER = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/;
59
+ /** A Bun version in a version manager's install path. */
60
+ const VERSION_IN_PATH =
61
+ /[\\/](?:installs|tools|Cellar)[\\/]bun[\\/](\d+\.\d+\.\d+[^\\/]*)[\\/]/;
62
+
63
+ const realpathOrNull = (path: string): string | null => {
64
+ try {
65
+ return realpathSync(path);
66
+ } catch {
67
+ return null;
68
+ }
69
+ };
70
+
71
+ /** The Bun version of an interpreter: from its install path, else `--version`. */
72
+ async function interpreterVersion(path: string): Promise<string | null> {
73
+ const resolved = realpathOrNull(path) ?? path;
74
+ const fromPath = VERSION_IN_PATH.exec(resolved)?.[1];
75
+ if (fromPath) {
76
+ return fromPath;
77
+ }
78
+ let timer: ReturnType<typeof setTimeout> | undefined;
79
+ try {
80
+ const probe = Bun.spawn([path, "--version"], {
81
+ stdin: "ignore",
82
+ stdout: "pipe",
83
+ stderr: "ignore",
84
+ });
85
+ // Bound the read, not only the process: a child of the probe can hold
86
+ // stdout open after the probe itself is killed.
87
+ const timedOut = new Promise<null>((resolve) => {
88
+ timer = setTimeout(() => resolve(null), VERSION_PROBE_TIMEOUT_MS);
89
+ });
90
+ const output = await Promise.race([
91
+ new Response(probe.stdout).text(),
92
+ timedOut,
93
+ ]);
94
+ if (output === null) {
95
+ probe.kill(9);
96
+ return null;
97
+ }
98
+ const version = output.trim();
99
+ return SEMVER.test(version) ? version : null;
100
+ } catch {
101
+ return null;
102
+ } finally {
103
+ clearTimeout(timer);
104
+ }
105
+ }
106
+
107
+ const isGnoExecutable = (command: string): boolean =>
108
+ ["gno", "gno.exe", "gno.cmd"].includes(basename(command).toLowerCase());
109
+
110
+ async function readRegistrationEntry(
111
+ configPath: string,
112
+ format: string
113
+ ): Promise<unknown> {
114
+ const file = Bun.file(configPath);
115
+ if (!(await file.exists())) {
116
+ return undefined;
117
+ }
118
+ const content = await file.text();
119
+ if (!content.trim()) {
120
+ return undefined;
121
+ }
122
+ const parsed =
123
+ format === "codex_toml"
124
+ ? getTomlServerEntry(content, configPath)
125
+ : format === "yaml_standard"
126
+ ? getYamlServerEntry(
127
+ content,
128
+ configPath,
129
+ getServersKey("yaml_standard")
130
+ )
131
+ : getJsoncServerEntry(
132
+ content,
133
+ configPath,
134
+ getServersKey(format as Parameters<typeof getServersKey>[0])
135
+ );
136
+ return parsed.exists ? parsed.entry : undefined;
137
+ }
138
+
139
+ const repairCommand = (target: McpTarget, scope: McpScope): string =>
140
+ `gno mcp install --target ${target} --scope ${scope} --force`;
141
+
142
+ /**
143
+ * Registrations whose launch is broken or on another Bun. Unreadable or
144
+ * malformed configs are left to `gno mcp status`.
145
+ */
146
+ export async function checkRegistrationRuntimes(
147
+ overrides: { cwd?: string; homeDir?: string } = {}
148
+ ): Promise<RuntimeIssue[]> {
149
+ const issues: RuntimeIssue[] = [];
150
+ const runningBun = realpathOrNull(process.execPath);
151
+ const versions = new Map<string, Promise<string | null>>();
152
+ for (const { target, scope, paths } of resolveAllMcpPaths(
153
+ "all",
154
+ "all",
155
+ overrides
156
+ )) {
157
+ let entry: unknown;
158
+ let configPath = paths.configPath;
159
+ try {
160
+ configPath = await resolveMcpConfigLocation(paths);
161
+ entry = await readRegistrationEntry(configPath, paths.configFormat);
162
+ } catch {
163
+ continue;
164
+ }
165
+ const registration =
166
+ entry === undefined
167
+ ? null
168
+ : readExistingRegistration(entry, paths.configFormat);
169
+ if (
170
+ !registration ||
171
+ registration.extra.enabled === false ||
172
+ registration.extra.disabled === true
173
+ ) {
174
+ continue;
175
+ }
176
+ const { command, args } = registration;
177
+ const viaBun = args[0] === "run" && args.length >= 2;
178
+ if (!viaBun && !isGnoExecutable(command)) {
179
+ continue;
180
+ }
181
+ const subject = `${getTargetDisplayName(target)} (${scope}) ${configPath}`;
182
+ const repair = repairCommand(target, scope);
183
+ const launchFiles = viaBun ? [command, args[1] ?? ""] : [command];
184
+ const missing = launchFiles.filter((path) => !realpathOrNull(path));
185
+ if (missing.length > 0) {
186
+ issues.push({
187
+ kind: "registration",
188
+ severity: "error",
189
+ subject,
190
+ message: `launches ${missing.join(" and ")}, which no longer exists`,
191
+ repair,
192
+ });
193
+ continue;
194
+ }
195
+ if (!viaBun) {
196
+ // The gno launcher finds Bun on PATH when it starts.
197
+ continue;
198
+ }
199
+ const resolved = realpathOrNull(command) ?? command;
200
+ if (!versions.has(resolved)) {
201
+ versions.set(resolved, interpreterVersion(command));
202
+ }
203
+ const version = await versions.get(resolved);
204
+ if (version && !bunMeetsFloor(version)) {
205
+ issues.push({
206
+ kind: "registration",
207
+ severity: "error",
208
+ subject,
209
+ message: `runs Bun ${version} (${command}), older than the supported minimum ${BUN_FLOOR}`,
210
+ repair,
211
+ });
212
+ } else if (resolved !== runningBun) {
213
+ issues.push({
214
+ kind: "registration",
215
+ severity: "info",
216
+ subject,
217
+ message: `runs ${version ? `Bun ${version}` : "a Bun of unknown version"} (${command}), not this Bun ${Bun.version}`,
218
+ repair,
219
+ });
220
+ }
221
+ }
222
+ return issues;
223
+ }
224
+
225
+ /** The Bun a resident reports through `/api/status`, if it is GNO. */
226
+ async function residentBunVersion(port: number): Promise<string | null> {
227
+ try {
228
+ const response = await fetch(`http://127.0.0.1:${port}/api/status`, {
229
+ signal: AbortSignal.timeout(RESIDENT_PROBE_TIMEOUT_MS),
230
+ });
231
+ if (!response.ok) {
232
+ return null;
233
+ }
234
+ const body = (await response.json()) as {
235
+ indexName?: unknown;
236
+ bootstrap?: { runtime?: { kind?: unknown; currentVersion?: unknown } };
237
+ };
238
+ const runtime = body?.bootstrap?.runtime;
239
+ // Only a GNO status payload counts; anything else on the port is ignored.
240
+ if (
241
+ typeof body?.indexName !== "string" ||
242
+ runtime?.kind !== "bun" ||
243
+ typeof runtime.currentVersion !== "string" ||
244
+ !SEMVER.test(runtime.currentVersion)
245
+ ) {
246
+ return null;
247
+ }
248
+ return runtime.currentVersion;
249
+ } catch {
250
+ return null;
251
+ }
252
+ }
253
+
254
+ function restartSteps(
255
+ kind: DetachKind,
256
+ pid: number,
257
+ sameVersion: boolean
258
+ ): string {
259
+ if (sameVersion) {
260
+ return `restart it: gno ${kind} --stop, then start it again (for example gno ${kind} --detach)`;
261
+ }
262
+ const stop =
263
+ process.platform === "win32" ? `taskkill /PID ${pid}` : `kill ${pid}`;
264
+ return `it was started by another GNO version, so gno ${kind} --stop leaves it alone; stop it with ${stop}, then start it again (for example gno ${kind} --detach)`;
265
+ }
266
+
267
+ /**
268
+ * Running residents on an unsupported or older Bun. A resident started by
269
+ * another GNO version is read through its HTTP status; it is never signalled.
270
+ */
271
+ export async function checkResidentRuntimes(): Promise<RuntimeIssue[]> {
272
+ const issues: RuntimeIssue[] = [];
273
+ for (const kind of ["serve", "daemon"] as const) {
274
+ const { pidFile } = resolveProcessPaths(kind);
275
+ let payload: Awaited<ReturnType<typeof readPidFile>>;
276
+ try {
277
+ payload = await readPidFile(pidFile);
278
+ } catch {
279
+ continue;
280
+ }
281
+ if (!payload || payload.cmd !== kind || !isProcessAlive(payload.pid)) {
282
+ continue;
283
+ }
284
+ const sameVersion = payload.version === VERSION;
285
+ let version = payload.bun_version ?? null;
286
+ if (!version && typeof payload.port === "number") {
287
+ version = await residentBunVersion(payload.port);
288
+ }
289
+ const subject = `gno ${kind} (pid ${payload.pid}, GNO ${payload.version})`;
290
+ const repair = restartSteps(kind, payload.pid, sameVersion);
291
+ if (!version) {
292
+ if (!sameVersion) {
293
+ issues.push({
294
+ kind: "resident",
295
+ severity: "info",
296
+ subject,
297
+ message:
298
+ "is running from another GNO version; its Bun version is unknown",
299
+ repair,
300
+ });
301
+ }
302
+ continue;
303
+ }
304
+ if (!bunMeetsFloor(version)) {
305
+ issues.push({
306
+ kind: "resident",
307
+ severity: "error",
308
+ subject,
309
+ message: `runs Bun ${version}, older than the supported minimum ${BUN_FLOOR}`,
310
+ repair,
311
+ });
312
+ } else if (Bun.semver.order(version, Bun.version) < 0) {
313
+ issues.push({
314
+ kind: "resident",
315
+ severity: "warn",
316
+ subject,
317
+ message: `runs Bun ${version}, older than this Bun ${Bun.version}`,
318
+ repair,
319
+ });
320
+ }
321
+ }
322
+ return issues;
323
+ }
324
+
325
+ /** One line per issue for terminal output. */
326
+ export function formatRuntimeIssue(issue: RuntimeIssue): string {
327
+ return `${issue.subject}: ${issue.message}. Repair: ${issue.repair}`;
328
+ }
@@ -138,6 +138,9 @@ export const CollectionSchema = z.object({
138
138
  /** Optional shell command to run before indexing */
139
139
  updateCmd: z.string().optional(),
140
140
 
141
+ /** Kill `updateCmd` (and anything it started) after this long; default 10 min */
142
+ updateCmdTimeoutMs: z.number().int().positive().optional(),
143
+
141
144
  /** Optional BCP-47 language hint */
142
145
  languageHint: z
143
146
  .string()
@@ -61,6 +61,22 @@ export const NETWORK_BOUNDARY_INVENTORY = [
61
61
  action: null,
62
62
  enforcement: "no_collection_data",
63
63
  },
64
+ {
65
+ id: "registration-bun-version-probe",
66
+ key: "src/cli/runtime-health.ts::child_process#1",
67
+ path: "src/cli/runtime-health.ts",
68
+ primitive: "child_process",
69
+ action: null,
70
+ enforcement: "no_collection_data",
71
+ },
72
+ {
73
+ id: "resident-bun-version-probe",
74
+ key: "src/cli/runtime-health.ts::fetch#1",
75
+ path: "src/cli/runtime-health.ts",
76
+ primitive: "fetch",
77
+ action: null,
78
+ enforcement: "no_collection_data",
79
+ },
64
80
  {
65
81
  id: "terminal-pager",
66
82
  key: "src/cli/pager.ts::child_process#1",
@@ -78,6 +78,9 @@ export function applyProjectProfileDesiredState(
78
78
  include: [],
79
79
  exclude: desired.collection.exclude,
80
80
  ...(existing?.updateCmd ? { updateCmd: existing.updateCmd } : {}),
81
+ ...(existing?.updateCmdTimeoutMs
82
+ ? { updateCmdTimeoutMs: existing.updateCmdTimeoutMs }
83
+ : {}),
81
84
  ...(desired.collection.languageHint
82
85
  ? { languageHint: desired.collection.languageHint }
83
86
  : existing?.languageHint
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Whether this process's resident (serve or daemon) has installed its own
3
+ * SIGINT/SIGTERM shutdown handler.
4
+ *
5
+ * A detached resident registers a pid-file cleanup listener before startup,
6
+ * and registering any listener disables the default terminate action. Until
7
+ * the resident's handler exists, that cleanup listener must exit the process
8
+ * itself, or a stop sent during startup is swallowed. Counting listeners
9
+ * cannot tell: the CLI bootstrap and the file processor add their own.
10
+ *
11
+ * @module src/core/resident-shutdown-handler
12
+ */
13
+
14
+ let installed = false;
15
+
16
+ /** Called by serve and the daemon right after registering their handlers. */
17
+ export function markResidentShutdownHandlerInstalled(): void {
18
+ installed = true;
19
+ }
20
+
21
+ export function residentShutdownHandlerInstalled(): boolean {
22
+ return installed;
23
+ }