@stigmer/cli 3.12.9 → 3.14.0-dev.20260910084630

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 (141) hide show
  1. package/auth/login.d.ts.map +1 -1
  2. package/auth/login.js +30 -5
  3. package/auth/login.js.map +1 -1
  4. package/auth/token.d.ts +10 -5
  5. package/auth/token.d.ts.map +1 -1
  6. package/auth/token.js +30 -21
  7. package/auth/token.js.map +1 -1
  8. package/commands/apikey/index.d.ts.map +1 -1
  9. package/commands/apikey/index.js +11 -5
  10. package/commands/apikey/index.js.map +1 -1
  11. package/commands/auth/index.d.ts.map +1 -1
  12. package/commands/auth/index.js +19 -8
  13. package/commands/auth/index.js.map +1 -1
  14. package/commands/config/backend.d.ts.map +1 -1
  15. package/commands/config/backend.js +134 -12
  16. package/commands/config/backend.js.map +1 -1
  17. package/commands/connect.d.ts.map +1 -1
  18. package/commands/connect.js +6 -3
  19. package/commands/connect.js.map +1 -1
  20. package/commands/share.d.ts.map +1 -1
  21. package/commands/share.js +8 -6
  22. package/commands/share.js.map +1 -1
  23. package/commands/up.d.ts.map +1 -1
  24. package/commands/up.js +22 -6
  25. package/commands/up.js.map +1 -1
  26. package/config/config.d.ts +56 -7
  27. package/config/config.d.ts.map +1 -1
  28. package/config/config.js +115 -16
  29. package/config/config.js.map +1 -1
  30. package/config/index.d.ts +1 -1
  31. package/config/index.d.ts.map +1 -1
  32. package/config/index.js +1 -1
  33. package/config/index.js.map +1 -1
  34. package/config/keys.d.ts +1 -1
  35. package/config/keys.d.ts.map +1 -1
  36. package/config/keys.js +29 -6
  37. package/config/keys.js.map +1 -1
  38. package/config/resolve.d.ts +32 -21
  39. package/config/resolve.d.ts.map +1 -1
  40. package/config/resolve.js +80 -39
  41. package/config/resolve.js.map +1 -1
  42. package/local/constants.d.ts +8 -3
  43. package/local/constants.d.ts.map +1 -1
  44. package/local/constants.js +8 -3
  45. package/local/constants.js.map +1 -1
  46. package/local/daemon/components.d.ts.map +1 -1
  47. package/local/daemon/components.js +4 -2
  48. package/local/daemon/components.js.map +1 -1
  49. package/local/daemon/env.d.ts +16 -3
  50. package/local/daemon/env.d.ts.map +1 -1
  51. package/local/daemon/env.js +16 -3
  52. package/local/daemon/env.js.map +1 -1
  53. package/local/daemon/launch.d.ts.map +1 -1
  54. package/local/daemon/launch.js +5 -4
  55. package/local/daemon/launch.js.map +1 -1
  56. package/local/daemon/process.d.ts.map +1 -1
  57. package/local/daemon/process.js +8 -5
  58. package/local/daemon/process.js.map +1 -1
  59. package/local/runtime/index.d.ts +2 -2
  60. package/local/runtime/index.d.ts.map +1 -1
  61. package/local/runtime/index.js +4 -2
  62. package/local/runtime/index.js.map +1 -1
  63. package/local/runtime/node.d.ts +8 -0
  64. package/local/runtime/node.d.ts.map +1 -1
  65. package/local/runtime/node.js +74 -23
  66. package/local/runtime/node.js.map +1 -1
  67. package/local/runtime/runner.d.ts +2 -1
  68. package/local/runtime/runner.d.ts.map +1 -1
  69. package/local/runtime/runner.js +4 -31
  70. package/local/runtime/runner.js.map +1 -1
  71. package/local/runtime/runtimes-install.d.ts +13 -0
  72. package/local/runtime/runtimes-install.d.ts.map +1 -0
  73. package/local/runtime/runtimes-install.js +60 -0
  74. package/local/runtime/runtimes-install.js.map +1 -0
  75. package/local/runtime/server.d.ts +26 -30
  76. package/local/runtime/server.d.ts.map +1 -1
  77. package/local/runtime/server.js +106 -108
  78. package/local/runtime/server.js.map +1 -1
  79. package/local/seedpack/content.d.ts +3 -3
  80. package/local/seedpack/content.d.ts.map +1 -1
  81. package/local/seedpack/content.js +19 -13
  82. package/local/seedpack/content.js.map +1 -1
  83. package/local/status.d.ts.map +1 -1
  84. package/local/status.js +4 -2
  85. package/local/status.js.map +1 -1
  86. package/local/webconsole/index.d.ts +2 -3
  87. package/local/webconsole/index.d.ts.map +1 -1
  88. package/local/webconsole/index.js +24 -10
  89. package/local/webconsole/index.js.map +1 -1
  90. package/package.json +5 -5
  91. package/resources/connect/connect.d.ts +4 -3
  92. package/resources/connect/connect.d.ts.map +1 -1
  93. package/resources/connect/connect.js +17 -5
  94. package/resources/connect/connect.js.map +1 -1
  95. package/resources/connect/oauth.d.ts +5 -2
  96. package/resources/connect/oauth.d.ts.map +1 -1
  97. package/resources/connect/oauth.js +5 -4
  98. package/resources/connect/oauth.js.map +1 -1
  99. package/src/auth/login.ts +54 -9
  100. package/src/auth/token.test.ts +69 -13
  101. package/src/auth/token.ts +46 -22
  102. package/src/client/client.test.ts +10 -2
  103. package/src/commands/apikey/index.ts +42 -11
  104. package/src/commands/auth/index.ts +52 -14
  105. package/src/commands/config/backend.test.ts +122 -0
  106. package/src/commands/config/backend.ts +195 -14
  107. package/src/commands/connect.test.ts +22 -4
  108. package/src/commands/connect.ts +37 -13
  109. package/src/commands/share.ts +36 -16
  110. package/src/commands/up.ts +24 -6
  111. package/src/config/config.test.ts +62 -6
  112. package/src/config/config.ts +165 -24
  113. package/src/config/index.ts +6 -0
  114. package/src/config/keys.test.ts +27 -4
  115. package/src/config/keys.ts +46 -9
  116. package/src/config/resolve.test.ts +135 -34
  117. package/src/config/resolve.ts +88 -38
  118. package/src/local/constants.ts +8 -4
  119. package/src/local/daemon/components.test.ts +9 -1
  120. package/src/local/daemon/components.ts +4 -2
  121. package/src/local/daemon/daemon.integration.test.ts +1 -1
  122. package/src/local/daemon/env.test.ts +15 -4
  123. package/src/local/daemon/env.ts +33 -5
  124. package/src/local/daemon/launch.ts +5 -4
  125. package/src/local/daemon/process.ts +7 -4
  126. package/src/local/runtime/index.ts +6 -5
  127. package/src/local/runtime/node.ts +99 -23
  128. package/src/local/runtime/runner.ts +5 -36
  129. package/src/local/runtime/runtime.test.ts +58 -14
  130. package/src/local/runtime/runtimes-install.test.ts +56 -0
  131. package/src/local/runtime/runtimes-install.ts +72 -0
  132. package/src/local/runtime/server.test.ts +178 -114
  133. package/src/local/runtime/server.ts +149 -129
  134. package/src/local/seedpack/content.ts +52 -21
  135. package/src/local/status.ts +4 -2
  136. package/src/local/webconsole/index.ts +27 -10
  137. package/src/resources/connect/connect.integration.test.ts +79 -26
  138. package/src/resources/connect/connect.ts +72 -19
  139. package/src/resources/connect/oauth.test.ts +38 -12
  140. package/src/resources/connect/oauth.ts +27 -11
  141. package/src/resources/share.test.ts +2 -2
@@ -8,7 +8,11 @@ const baseInputs: DaemonEnvInputs = {
8
8
  temporalAddress: "127.0.0.1:7233",
9
9
  serverOnly: false,
10
10
  noWeb: false,
11
- serverBin: "/usr/local/bin/stigmer-server",
11
+ server: {
12
+ nodeBin: "/usr/bin/node",
13
+ entryPath: "/repo/server/dist/main.js",
14
+ appDir: "/repo/server",
15
+ },
12
16
  runner: { nodeBin: "/usr/bin/node", entryPath: "/repo/runner/dist/main.js", appDir: "/repo/runner" },
13
17
  };
14
18
 
@@ -23,7 +27,11 @@ describe("buildDaemonEnv + readDaemonConfig", () => {
23
27
  temporalAddress: "127.0.0.1:7233",
24
28
  serverOnly: false,
25
29
  noWeb: false,
26
- serverBin: "/usr/local/bin/stigmer-server",
30
+ server: {
31
+ nodeBin: "/usr/bin/node",
32
+ entryPath: "/repo/server/dist/main.js",
33
+ appDir: "/repo/server",
34
+ },
27
35
  runner: { nodeBin: "/usr/bin/node", entryPath: "/repo/runner/dist/main.js", appDir: "/repo/runner" },
28
36
  cursorApiKey: undefined,
29
37
  anthropicApiKey: undefined,
@@ -90,8 +98,11 @@ describe("buildDaemonEnv + readDaemonConfig", () => {
90
98
  expect(readDaemonConfig(env)).not.toHaveProperty("openaiApiKey");
91
99
  });
92
100
 
93
- it("requires the data dir and server binary", () => {
101
+ it("requires the data dir and the full server launch triple", () => {
94
102
  expect(() => readDaemonConfig({})).toThrow(/STIGMER_DATA_DIR/);
95
- expect(() => readDaemonConfig({ STIGMER_DATA_DIR: "/x" })).toThrow(/STIGMER_SERVER_BIN/);
103
+ expect(() => readDaemonConfig({ STIGMER_DATA_DIR: "/x" })).toThrow(/STIGMER_SERVER_NODE_BIN/);
104
+ expect(() => readDaemonConfig({ STIGMER_DATA_DIR: "/x", STIGMER_SERVER_NODE_BIN: "/usr/bin/node" })).toThrow(
105
+ /STIGMER_SERVER_ENTRY/,
106
+ );
96
107
  });
97
108
  });
@@ -12,7 +12,9 @@ export const DaemonEnvVar = {
12
12
  TemporalAddress: "TEMPORAL_SERVICE_ADDRESS",
13
13
  ServerOnly: "STIGMER_SERVER_ONLY",
14
14
  NoWeb: "STIGMER_NO_WEB",
15
- ServerBin: "STIGMER_SERVER_BIN",
15
+ ServerNodeBin: "STIGMER_SERVER_NODE_BIN",
16
+ ServerEntry: "STIGMER_SERVER_ENTRY",
17
+ ServerAppDir: "STIGMER_SERVER_APP_DIR",
16
18
  RunnerNodeBin: "STIGMER_RUNNER_NODE_BIN",
17
19
  RunnerEntry: "STIGMER_RUNNER_ENTRY",
18
20
  RunnerAppDir: "STIGMER_RUNNER_APP_DIR",
@@ -30,6 +32,18 @@ export interface RunnerLaunch {
30
32
  appDir: string;
31
33
  }
32
34
 
35
+ /**
36
+ * Resolved server launch coordinates: a node binary + bundled entry — the
37
+ * same launch shape as the runner. (Until #25 go-server-retirement this was
38
+ * a discriminated union whose "binary" variant carried the Go rollback
39
+ * executable.)
40
+ */
41
+ export interface ServerLaunch {
42
+ nodeBin: string;
43
+ entryPath: string;
44
+ appDir: string;
45
+ }
46
+
33
47
  /** The daemon's resolved configuration, parsed from the environment. */
34
48
  export interface DaemonConfig {
35
49
  dataDir: string;
@@ -38,7 +52,7 @@ export interface DaemonConfig {
38
52
  temporalAddress: string;
39
53
  serverOnly: boolean;
40
54
  noWeb: boolean;
41
- serverBin: string;
55
+ server: ServerLaunch;
42
56
  runner?: RunnerLaunch;
43
57
  cursorApiKey?: string;
44
58
  anthropicApiKey?: string;
@@ -55,7 +69,7 @@ export interface DaemonEnvInputs {
55
69
  temporalAddress: string;
56
70
  serverOnly: boolean;
57
71
  noWeb: boolean;
58
- serverBin: string;
72
+ server: ServerLaunch;
59
73
  runner?: RunnerLaunch;
60
74
  // Anthropic API key resolved by the launcher (env > config file). Must be
61
75
  // written into the daemon env explicitly: unlike a shell-exported key, a key
@@ -83,7 +97,9 @@ export function buildDaemonEnv(inputs: DaemonEnvInputs, base: NodeJS.ProcessEnv
83
97
  env[DaemonEnvVar.LogDir] = inputs.logDir;
84
98
  env[DaemonEnvVar.TemporalManaged] = String(inputs.temporalManaged);
85
99
  env[DaemonEnvVar.TemporalAddress] = inputs.temporalAddress;
86
- env[DaemonEnvVar.ServerBin] = inputs.serverBin;
100
+ env[DaemonEnvVar.ServerNodeBin] = inputs.server.nodeBin;
101
+ env[DaemonEnvVar.ServerEntry] = inputs.server.entryPath;
102
+ env[DaemonEnvVar.ServerAppDir] = inputs.server.appDir;
87
103
  if (inputs.serverOnly) env[DaemonEnvVar.ServerOnly] = "true";
88
104
  if (inputs.noWeb) env[DaemonEnvVar.NoWeb] = "1";
89
105
  if (inputs.runner !== undefined && !inputs.serverOnly) {
@@ -112,7 +128,7 @@ export function readDaemonConfig(env: NodeJS.ProcessEnv = process.env): DaemonCo
112
128
  temporalAddress: env[DaemonEnvVar.TemporalAddress] ?? "127.0.0.1:7233",
113
129
  serverOnly,
114
130
  noWeb: env[DaemonEnvVar.NoWeb] === "1",
115
- serverBin: required(env, DaemonEnvVar.ServerBin),
131
+ server: readServer(env),
116
132
  runner: serverOnly ? undefined : runner,
117
133
  cursorApiKey: nonEmpty(env[DaemonEnvVar.CursorApiKey]),
118
134
  anthropicApiKey: nonEmpty(env[DaemonEnvVar.AnthropicApiKey]),
@@ -122,6 +138,18 @@ export function readDaemonConfig(env: NodeJS.ProcessEnv = process.env): DaemonCo
122
138
  };
123
139
  }
124
140
 
141
+ function readServer(env: NodeJS.ProcessEnv): ServerLaunch {
142
+ const nodeBin = env[DaemonEnvVar.ServerNodeBin];
143
+ const entryPath = env[DaemonEnvVar.ServerEntry];
144
+ const appDir = env[DaemonEnvVar.ServerAppDir];
145
+ if (!nodeBin || !entryPath || !appDir) {
146
+ throw new Error(
147
+ `the ${DaemonEnvVar.ServerNodeBin}/${DaemonEnvVar.ServerEntry}/${DaemonEnvVar.ServerAppDir} triple is required for the daemon process`,
148
+ );
149
+ }
150
+ return { nodeBin, entryPath, appDir };
151
+ }
152
+
125
153
  function readRunner(env: NodeJS.ProcessEnv): RunnerLaunch | undefined {
126
154
  const nodeBin = env[DaemonEnvVar.RunnerNodeBin];
127
155
  const entryPath = env[DaemonEnvVar.RunnerEntry];
@@ -2,7 +2,7 @@
2
2
  // `down` (signal + wait + safety-net cleanup), and a liveness check.
3
3
  //
4
4
  // `up` resolves every heavy dependency in the foreground (Temporal binary,
5
- // server binary, runner entry) so failures surface with a clear message before
5
+ // server launch, runner entry) so failures surface with a clear message before
6
6
  // a detached daemon is spawned, then re-execs this same CLI as the hidden
7
7
  // `internal-daemon` and waits until the server's gRPC port answers.
8
8
 
@@ -24,7 +24,7 @@ import { rotateLogs } from "../state/log-rotation.js";
24
24
  import { resolveApiKey, resolveProvider } from "../llm-config.js";
25
25
  import { resolveOperatorIdentity } from "../operator-config.js";
26
26
  import { ensureRunner } from "../runtime/runner.js";
27
- import { ensureServerBinary } from "../runtime/server.js";
27
+ import { ensureServer } from "../runtime/server.js";
28
28
  import { TemporalManager } from "../temporal/manager.js";
29
29
  import { buildDaemonEnv, type DaemonEnvInputs } from "./env.js";
30
30
 
@@ -64,7 +64,8 @@ export async function up(options: UpOptions = {}, home: string = homedir()): Pro
64
64
  await temporal.ensureInstalled();
65
65
  }
66
66
 
67
- const serverBin = await ensureServerBinary({ home });
67
+ // Resolves the server (repo tree or the acquired @stigmer/server-slim).
68
+ const server = ensureServer({ home });
68
69
  const runner = options.serverOnly === true ? undefined : ensureRunner({ home });
69
70
 
70
71
  const env = buildDaemonEnv(
@@ -75,7 +76,7 @@ export async function up(options: UpOptions = {}, home: string = homedir()): Pro
75
76
  temporalAddress: temporal.address,
76
77
  serverOnly: options.serverOnly === true,
77
78
  noWeb: options.noWeb === true,
78
- serverBin,
79
+ server,
79
80
  runner,
80
81
  ...resolveLlmKeyInputs(config),
81
82
  ...resolveOperatorIdentityInputs(config),
@@ -103,15 +103,18 @@ export async function runInternalDaemon(deps: InternalDaemonDeps): Promise<numbe
103
103
  await clock.sleep(SETTLE_DELAY_MS);
104
104
  supervisor.settleCheck();
105
105
 
106
- // Web console: detect-and-skip until T06 wires real serving.
106
+ // Web console: the SERVER serves it from its unified port (DD-012); the
107
+ // daemon probes and records what a browser would actually find. pid 0 is
108
+ // truthful — there is no separate console process to supervise.
109
+ const consoleAvailable = config.noWeb ? false : await isWebConsoleAvailable();
107
110
  healthState.components["web-console"] = {
108
111
  pid: 0,
109
- state: config.noWeb || !isWebConsoleAvailable() ? "stopped" : "running",
112
+ state: consoleAvailable ? "running" : "stopped",
110
113
  started_at: "",
111
114
  restart_count: 0,
112
115
  };
113
- if (config.noWeb) log.info("web console disabled via --no-web");
114
- else if (!isWebConsoleAvailable()) log.debug("web console not bundled in this build, skipping");
116
+ if (config.noWeb) log.info("web console suppressed via --no-web");
117
+ else if (!consoleAvailable) log.debug("server did not answer the console probe (no export bundled), skipping");
115
118
  persist();
116
119
 
117
120
  // --- Health monitor: sync Temporal + drive one supervisor tick per interval. ---
@@ -1,6 +1,6 @@
1
1
  // Public surface of the runtime-acquisition seams (DD-002/003/007).
2
2
 
3
- export { resolveNode } from "./node.js";
3
+ export { resolveNode, resolveServerNode } from "./node.js";
4
4
  export {
5
5
  type EnsureRunnerOptions,
6
6
  type RunnerResolution,
@@ -8,11 +8,12 @@ export {
8
8
  ensureRunner,
9
9
  resolveRunner,
10
10
  } from "./runner.js";
11
+ // The TS server — the served implementation since the DD-006 cutover (D4 #24;
12
+ // the Go binary ladder that backed rollback retired with #25).
11
13
  export {
12
14
  type EnsureServerOptions,
13
- type ServerDownloadTarget,
14
- downloadServerBinary,
15
- ensureServerBinary,
16
- resolveServerBinary,
15
+ acquireServer,
16
+ ensureServer,
17
+ resolveServerTs,
17
18
  } from "./server.js";
18
19
  export { which } from "./which.js";
@@ -1,4 +1,5 @@
1
- // Resolution of the Node.js runtime used to launch the runner subprocess.
1
+ // Resolution of the Node.js runtime used to launch the runner and TS-server
2
+ // subprocesses.
2
3
  //
3
4
  // We reuse the very Node that is running the CLI (process.execPath). The CLI is
4
5
  // itself an npm package with `engines: node >= 22.13`, so a suitable Node is
@@ -8,16 +9,26 @@
8
9
  // (`spawn(process.execPath, ...)`). An explicit STIGMER_NODE_BIN override is
9
10
  // honored and capability-checked for advanced/multi-runtime setups.
10
11
  //
11
- // The gate is a CAPABILITY probe, not a version check. The runner's durable
12
- // local checkpointer imports Node's built-in `node:sqlite`, available unflagged
13
- // from 22.13 in the 22.x line and only from 23.4 in the 23.x line — a gap
14
- // (23.0–23.3) that a previous version-table gate here missed, letting those
15
- // Nodes through to crash at runner boot with a raw ERR_UNKNOWN_BUILTIN_MODULE.
16
- // Probing "can this binary provide node:sqlite?" directly cannot drift; the
17
- // version floors survive only in the error message, where staleness is
18
- // harmless. The runner performs the same probe on its own boot (see
19
- // backend/services/runner/src/preflight.ts) — this one exists to fail at
20
- // resolve time with CLI-appropriate guidance instead of a subprocess crash.
12
+ // The gates are CAPABILITY probes, not version checks. Two probes, because the
13
+ // two children need different sqlite capabilities:
14
+ //
15
+ // - The RUNNER's durable local checkpointer imports Node's built-in
16
+ // `node:sqlite`, available unflagged from 22.13 in the 22.x line and only
17
+ // from 23.4 in the 23.x line — a gap (23.0–23.3) that a previous
18
+ // version-table gate here missed, letting those Nodes through to crash at
19
+ // runner boot with a raw ERR_UNKNOWN_BUILTIN_MODULE.
20
+ // - The SERVER additionally needs `node:sqlite` compiled WITH FTS5 (its
21
+ // search index; migration v3 creates an fts5 virtual table at boot). Node
22
+ // 23.4 PROVIDES node:sqlite but its sqlite build LACKS FTS5 — found by
23
+ // D4 #14 and the reason the module-presence probe alone is insufficient
24
+ // for the server. Probing "can this binary create an fts5 table?" directly
25
+ // cannot drift; the version floors survive only in the error messages,
26
+ // where staleness is harmless.
27
+ //
28
+ // The runner performs the module-presence probe on its own boot (see
29
+ // backend/services/runner/src/preflight.ts); the server fails at migration
30
+ // time. Both probes here exist to fail at resolve time with CLI-appropriate
31
+ // guidance instead of a subprocess crash.
21
32
 
22
33
  import { execFileSync } from "node:child_process";
23
34
  import { CliExitError } from "../../errors/cli-exit-error.js";
@@ -39,6 +50,16 @@ import { ExitCode } from "../../errors/exit-codes.js";
39
50
  const NODE_SQLITE_PROBE =
40
51
  "process.exit(process.getBuiltinModule?.('node:sqlite') === undefined ? 1 : 0)";
41
52
 
53
+ // Exits 0 when the binary's `node:sqlite` can create an FTS5 virtual table,
54
+ // 1 otherwise. Strictly stronger than NODE_SQLITE_PROBE: it exercises the
55
+ // exact operation the server's migration v3 performs, in memory. The
56
+ // try/catch covers both failure shapes — module absent (TypeError on the
57
+ // undefined module) and FTS5 absent (the exec throws "no such module: fts5").
58
+ const NODE_SQLITE_FTS5_PROBE =
59
+ "try{const{DatabaseSync}=process.getBuiltinModule('node:sqlite');" +
60
+ "new DatabaseSync(':memory:').exec('CREATE VIRTUAL TABLE t USING fts5(x)');" +
61
+ "process.exit(0)}catch{process.exit(1)}";
62
+
42
63
  /**
43
64
  * Resolve the Node binary to launch the runner with. Honors STIGMER_NODE_BIN;
44
65
  * otherwise returns the current runtime. Either way the binary is probed for
@@ -47,12 +68,50 @@ const NODE_SQLITE_PROBE =
47
68
  * runner-launch paths only (ensureRunner), never on ordinary CLI commands.
48
69
  */
49
70
  export function resolveNode(): string {
50
- const override = process.env.STIGMER_NODE_BIN;
51
- const bin = override !== undefined && override !== "" ? override : process.execPath;
52
- assertProvidesNodeSqlite(bin);
71
+ const bin = nodeCandidate();
72
+ assertCapability(bin, NODE_SQLITE_PROBE, {
73
+ subject: "the runner",
74
+ capability: "the built-in node:sqlite module",
75
+ floor: "Node >= 22.13 (>= 23.4 in the 23.x line)",
76
+ detail: [
77
+ "The runner's durable checkpointer requires node:sqlite, which is available",
78
+ "unflagged from Node 22.13 (22.x line) and 23.4 (23.x and later) — note that",
79
+ "23.0-23.3 lack it.",
80
+ ],
81
+ });
82
+ return bin;
83
+ }
84
+
85
+ /**
86
+ * Resolve the Node binary to launch the TS server with. Same
87
+ * STIGMER_NODE_BIN/execPath resolution as {@link resolveNode}, but probes for
88
+ * `node:sqlite` WITH FTS5 — the server's search index needs it and some Node
89
+ * builds (e.g. 23.4) ship node:sqlite without it. Runs on server-launch paths
90
+ * only (ensureServer).
91
+ */
92
+ export function resolveServerNode(): string {
93
+ const bin = nodeCandidate();
94
+ assertCapability(bin, NODE_SQLITE_FTS5_PROBE, {
95
+ subject: "the stigmer server",
96
+ capability: "the built-in node:sqlite module with FTS5",
97
+ floor: "Node 22.13+ in the 22.x line (23.x builds lack FTS5)",
98
+ detail: [
99
+ "The server's search index requires node:sqlite compiled with FTS5.",
100
+ "The 22.x line from 22.13 is known-good; 23.x builds ship node:sqlite",
101
+ "WITHOUT FTS5 and cannot run the server. Newer majors work if their",
102
+ "sqlite build includes FTS5 — this probe is the authority.",
103
+ ],
104
+ });
53
105
  return bin;
54
106
  }
55
107
 
108
+ function nodeCandidate(): string {
109
+ const override = process.env.STIGMER_NODE_BIN;
110
+ return override !== undefined && override !== ""
111
+ ? override
112
+ : process.execPath;
113
+ }
114
+
56
115
  /** Best-effort `--version` for error messages only; null when the spawn fails. */
57
116
  function probeNodeVersion(bin: string): string | null {
58
117
  try {
@@ -66,10 +125,27 @@ function probeNodeVersion(bin: string): string | null {
66
125
  }
67
126
  }
68
127
 
69
- function assertProvidesNodeSqlite(bin: string): void {
128
+ interface CapabilityErrorCopy {
129
+ /** What is being launched, e.g. "the runner". */
130
+ subject: string;
131
+ /** The missing capability, in user terms. */
132
+ capability: string;
133
+ /** The known-good version floor, per probe — the two probes differ (the
134
+ * runner accepts 23.4+; the server does not). Error copy only; the probe
135
+ * is the authority. */
136
+ floor: string;
137
+ /** Guidance lines explaining the requirement. */
138
+ detail: string[];
139
+ }
140
+
141
+ function assertCapability(
142
+ bin: string,
143
+ probe: string,
144
+ copy: CapabilityErrorCopy,
145
+ ): void {
70
146
  let exitStatus: number | null;
71
147
  try {
72
- execFileSync(bin, ["-e", NODE_SQLITE_PROBE], { stdio: "ignore" });
148
+ execFileSync(bin, ["-e", probe], { stdio: "ignore" });
73
149
  return;
74
150
  } catch (err) {
75
151
  // execFileSync reports a nonzero exit via `status`; a spawn failure
@@ -78,19 +154,19 @@ function assertProvidesNodeSqlite(bin: string): void {
78
154
  }
79
155
 
80
156
  if (exitStatus === null) {
81
- throw new CliExitError(`could not run ${bin} to verify the runner's Node requirements`, ExitCode.General, [
82
- `Ensure ${bin} is a working Node >= 22.13 runtime (>= 23.4 in the 23.x line).`,
83
- ]);
157
+ throw new CliExitError(
158
+ `could not run ${bin} to verify ${copy.subject}'s Node requirements`,
159
+ ExitCode.General,
160
+ [`Ensure ${bin} is a working ${copy.floor} runtime.`],
161
+ );
84
162
  }
85
163
 
86
164
  const version = probeNodeVersion(bin) ?? "unknown version";
87
165
  throw new CliExitError(
88
- `Node at ${bin} (${version}) cannot run the runner: it does not provide the built-in node:sqlite module`,
166
+ `Node at ${bin} (${version}) cannot run ${copy.subject}: it does not provide ${copy.capability}`,
89
167
  ExitCode.General,
90
168
  [
91
- "The runner's durable checkpointer requires node:sqlite, which is available",
92
- "unflagged from Node 22.13 (22.x line) and 23.4 (23.x and later) — note that",
93
- "23.0-23.3 lack it.",
169
+ ...copy.detail,
94
170
  "Upgrade Node, or point STIGMER_NODE_BIN at a suitable runtime.",
95
171
  ],
96
172
  );
@@ -17,8 +17,7 @@
17
17
  // launch contract (`node main.js`) is identical to the repo-tree runner, so the
18
18
  // daemon spawns either the same way.
19
19
 
20
- import { execFileSync } from "node:child_process";
21
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
20
+ import { existsSync } from "node:fs";
22
21
  import { homedir } from "node:os";
23
22
  import { dirname, join } from "node:path";
24
23
  import { fileURLToPath } from "node:url";
@@ -28,6 +27,7 @@ import { log } from "../../logger.js";
28
27
  import { VERSION } from "../../version.js";
29
28
  import { runtimesDir } from "../paths.js";
30
29
  import { resolveNode } from "./node.js";
30
+ import { ensureRuntimesRoot, isAcquirableRelease, npmInstallIntoRuntimes, type NpmInstall } from "./runtimes-install.js";
31
31
 
32
32
  const SLIM_PACKAGE = "@stigmer/runner-slim";
33
33
 
@@ -48,7 +48,7 @@ export interface EnsureRunnerOptions {
48
48
  /** Node resolver (injectable for tests). */
49
49
  node?: () => string;
50
50
  /** npm install implementation (injectable for tests). */
51
- install?: (installDir: string, spec: string) => void;
51
+ install?: NpmInstall;
52
52
  }
53
53
 
54
54
  /**
@@ -111,14 +111,8 @@ export function acquireRunner(opts: EnsureRunnerOptions = {}): RunnerResolution
111
111
 
112
112
  if (!existsSync(entryPath)) {
113
113
  log.info(`acquiring ${SLIM_PACKAGE}`, { version, dir: installDir });
114
- mkdirSync(installDir, { recursive: true });
115
- // A stable package.json root makes the install deterministic and records the
116
- // pinned dependency rather than letting npm synthesize an ad-hoc root.
117
- writeFileSync(
118
- join(installDir, "package.json"),
119
- `${JSON.stringify({ name: "stigmer-runtime", private: true, version: "0.0.0" }, null, 2)}\n`,
120
- );
121
- const install = opts.install ?? installRunnerSlim;
114
+ ensureRuntimesRoot(installDir);
115
+ const install = opts.install ?? npmInstallIntoRuntimes;
122
116
  install(installDir, `${SLIM_PACKAGE}@${version}`);
123
117
  }
124
118
 
@@ -132,24 +126,6 @@ export function acquireRunner(opts: EnsureRunnerOptions = {}): RunnerResolution
132
126
  return { nodeBin: node(), entryPath, appDir: dirname(entryPath) };
133
127
  }
134
128
 
135
- // Install the slim package (plus its platform-native optional dependency) into an
136
- // isolated prefix. `--omit=dev` drops devDependencies while keeping the optional
137
- // native package npm selects by os/cpu; output is inherited so the user sees the
138
- // one-time download progress.
139
- function installRunnerSlim(installDir: string, spec: string): void {
140
- try {
141
- execFileSync("npm", ["install", spec, "--prefix", installDir, "--omit=dev", "--no-audit", "--no-fund"], {
142
- stdio: "inherit",
143
- });
144
- } catch (err) {
145
- throw new CliExitError(`failed to install ${spec}`, ExitCode.General, [
146
- `Command: npm install ${spec} --prefix ${installDir}`,
147
- "Ensure npm is on PATH and the network is reachable.",
148
- String(err),
149
- ]);
150
- }
151
- }
152
-
153
129
  function resolveBuiltRunner(appDir: string, node: () => string): RunnerResolution {
154
130
  const entryPath = join(appDir, "dist", "main.js");
155
131
  if (!existsSync(entryPath)) {
@@ -162,13 +138,6 @@ function resolveBuiltRunner(appDir: string, node: () => string): RunnerResolutio
162
138
  return { nodeBin: node(), entryPath, appDir };
163
139
  }
164
140
 
165
- // A source build reports "0.0.0-dev" and the dev npm channel stamps "<v>-dev.<stamp>"
166
- // versions; neither publishes a matching @stigmer/runner-slim, so they are not
167
- // acquirable. Release and rc/next versions are.
168
- function isAcquirableRelease(version: string): boolean {
169
- return !version.includes("-dev");
170
- }
171
-
172
141
  function hasPackageJson(dir: string): boolean {
173
142
  return existsSync(join(dir, "package.json"));
174
143
  }
@@ -3,9 +3,8 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { CliExitError } from "../../errors/cli-exit-error.js";
6
- import { resolveNode } from "./node.js";
6
+ import { resolveNode, resolveServerNode } from "./node.js";
7
7
  import { acquireRunner, resolveRunner } from "./runner.js";
8
- import { resolveServerBinary } from "./server.js";
9
8
  import { which } from "./which.js";
10
9
 
11
10
  function tempDir(prefix: string): string {
@@ -13,7 +12,7 @@ function tempDir(prefix: string): string {
13
12
  }
14
13
 
15
14
  // Snapshot and restore the env vars these resolvers read, so tests don't leak.
16
- const TOUCHED = ["PATH", "STIGMER_SERVER_BIN", "STIGMER_RUNNER_DIR", "STIGMER_NODE_BIN"] as const;
15
+ const TOUCHED = ["PATH", "STIGMER_RUNNER_DIR", "STIGMER_NODE_BIN"] as const;
17
16
  let saved: Record<string, string | undefined>;
18
17
 
19
18
  beforeEach(() => {
@@ -52,12 +51,19 @@ describe("which", () => {
52
51
 
53
52
  // A fake "node" whose --version and capability-probe behavior the test
54
53
  // controls, making these tests deterministic regardless of which Node runs
55
- // the suite itself.
56
- function fakeNodeBinary(opts: { version: string; hasSqlite: boolean }): string {
54
+ // the suite itself. The fake INSPECTS the probe script it receives ("fts5"
55
+ // in the source distinguishes the server's FTS5 probe from the runner's
56
+ // module-presence probe), so a mutation that wires resolveServerNode to the
57
+ // weaker probe — or neuters the FTS5 probe's failure exit — fails here.
58
+ function fakeNodeBinary(opts: { version: string; hasSqlite: boolean; hasFts5?: boolean }): string {
57
59
  const bin = join(tempDir("stigmer-node-"), "node");
60
+ const fts5Exit = (opts.hasFts5 ?? opts.hasSqlite) ? 0 : 1;
58
61
  writeFileSync(
59
62
  bin,
60
- `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "${opts.version}"; exit 0; fi\nexit ${opts.hasSqlite ? 0 : 1}\n`,
63
+ `#!/bin/sh\n` +
64
+ `if [ "$1" = "--version" ]; then echo "${opts.version}"; exit 0; fi\n` +
65
+ `case "$2" in *fts5*) exit ${fts5Exit};; esac\n` +
66
+ `exit ${opts.hasSqlite ? 0 : 1}\n`,
61
67
  );
62
68
  chmodSync(bin, 0o755);
63
69
  return bin;
@@ -97,15 +103,53 @@ describe("resolveNode", () => {
97
103
  });
98
104
  });
99
105
 
100
- describe("resolveServerBinary", () => {
101
- it("honors the STIGMER_SERVER_BIN override", () => {
102
- const dir = tempDir("stigmer-server-");
103
- const bin = join(dir, "stigmer-server");
104
- writeFileSync(bin, "#!/bin/sh\n");
105
- chmodSync(bin, 0o755);
106
- process.env.STIGMER_SERVER_BIN = bin;
107
- expect(resolveServerBinary()).toBe(bin);
106
+ describe("resolveServerNode", () => {
107
+ it("honors an override that passes the FTS5 capability probe", () => {
108
+ const bin = fakeNodeBinary({ version: "v22.13.0", hasSqlite: true });
109
+ process.env.STIGMER_NODE_BIN = bin;
110
+ expect(resolveServerNode()).toBe(bin);
111
+ });
112
+
113
+ it("rejects a Node whose sqlite lacks FTS5, naming the capability (the 23.4 trap)", () => {
114
+ // The shape of a REAL 23.4 binary: node:sqlite present (the module probe
115
+ // would pass), FTS5 absent (the fts5 probe fails) — pinning that the
116
+ // server resolution dispatches the STRONGER probe (D4 #14).
117
+ process.env.STIGMER_NODE_BIN = fakeNodeBinary({ version: "v23.4.0", hasSqlite: true, hasFts5: false });
118
+
119
+ expect(() => resolveServerNode()).toThrow(/FTS5/);
120
+ expect(() => resolveServerNode()).toThrow(/v23\.4\.0/);
121
+ });
122
+
123
+ it("the runner resolution still accepts the 23.4 shape (module present, FTS5 absent)", () => {
124
+ // The two probes deliberately differ: the runner needs only node:sqlite.
125
+ const bin = fakeNodeBinary({ version: "v23.4.0", hasSqlite: true, hasFts5: false });
126
+ process.env.STIGMER_NODE_BIN = bin;
127
+ expect(resolveNode()).toBe(bin);
108
128
  });
129
+
130
+ it("runs the real FTS5 probe against the current runtime", () => {
131
+ // The one place the REAL probe script executes end-to-end (the fake-node
132
+ // tests above only exercise the dispatch and error copy). Conditional on
133
+ // the runtime's own capability — the resolveNode own-runtime pattern —
134
+ // so a no-FTS5 Node (e.g. 23.4) asserts the throw instead of the pass.
135
+ delete process.env.STIGMER_NODE_BIN;
136
+ if (currentRuntimeHasFts5()) {
137
+ expect(resolveServerNode()).toBe(process.execPath);
138
+ } else {
139
+ expect(() => resolveServerNode()).toThrow(/FTS5/);
140
+ }
141
+ });
142
+
143
+ function currentRuntimeHasFts5(): boolean {
144
+ const sqlite = process.getBuiltinModule?.("node:sqlite");
145
+ if (sqlite === undefined) return false;
146
+ try {
147
+ new sqlite.DatabaseSync(":memory:").exec("CREATE VIRTUAL TABLE t USING fts5(x)");
148
+ return true;
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
109
153
  });
110
154
 
111
155
  describe("resolveRunner", () => {
@@ -0,0 +1,56 @@
1
+ // Pins the shared runtimes-install contract both acquirers depend on: the
2
+ // no-clobber root manifest (the runner and the server install into ONE
3
+ // per-version root), the dev-version gate, and the actionable install
4
+ // failure.
5
+
6
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { describe, expect, it } from "vitest";
10
+ import { CliExitError } from "../../errors/cli-exit-error.js";
11
+ import { ensureRuntimesRoot, isAcquirableRelease, npmInstallIntoRuntimes } from "./runtimes-install.js";
12
+
13
+ function tempDir(): string {
14
+ return mkdtempSync(join(tmpdir(), "stigmer-runtimes-"));
15
+ }
16
+
17
+ describe("ensureRuntimesRoot", () => {
18
+ it("creates the root with the stable manifest when absent", () => {
19
+ const dir = join(tempDir(), "0.5.0");
20
+ ensureRuntimesRoot(dir);
21
+ const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
22
+ expect(manifest).toEqual({ name: "stigmer-runtime", private: true, version: "0.0.0" });
23
+ });
24
+
25
+ it("never clobbers an existing root manifest — the shared-root contract", () => {
26
+ const dir = tempDir();
27
+ writeFileSync(join(dir, "package.json"), '{"name":"stigmer-runtime","marker":"first-installer"}\n');
28
+ ensureRuntimesRoot(dir);
29
+ expect(readFileSync(join(dir, "package.json"), "utf8")).toContain("first-installer");
30
+ });
31
+ });
32
+
33
+ describe("isAcquirableRelease", () => {
34
+ it("admits releases and rc/next versions, refuses dev builds", () => {
35
+ expect(isAcquirableRelease("0.5.0")).toBe(true);
36
+ expect(isAcquirableRelease("0.5.0-rc.1")).toBe(true);
37
+ expect(isAcquirableRelease("0.0.0-dev")).toBe(false);
38
+ expect(isAcquirableRelease("0.5.0-dev.20260825")).toBe(false);
39
+ });
40
+ });
41
+
42
+ describe("npmInstallIntoRuntimes", () => {
43
+ it("wraps an install failure in an actionable CliExitError with remediation hints", () => {
44
+ // /dev/null/x is unwritable on every POSIX system, so the real npm spawn
45
+ // fails deterministically without network access.
46
+ const attempt = (): void => npmInstallIntoRuntimes("/dev/null/x", "@stigmer/nonexistent@0.0.0");
47
+ expect(attempt).toThrow(CliExitError);
48
+ try {
49
+ attempt();
50
+ } catch (err) {
51
+ const hints = (err as CliExitError).hints?.join("\n") ?? "";
52
+ expect(hints).toMatch(/npm install/);
53
+ expect(hints).toMatch(/remove/i);
54
+ }
55
+ });
56
+ });