@stigmer/cli 3.12.9 → 3.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commands/up.d.ts.map +1 -1
- package/commands/up.js +22 -6
- package/commands/up.js.map +1 -1
- package/config/resolve.d.ts +5 -2
- package/config/resolve.d.ts.map +1 -1
- package/config/resolve.js +7 -4
- package/config/resolve.js.map +1 -1
- package/local/constants.d.ts +8 -3
- package/local/constants.d.ts.map +1 -1
- package/local/constants.js +8 -3
- package/local/constants.js.map +1 -1
- package/local/daemon/components.d.ts.map +1 -1
- package/local/daemon/components.js +4 -2
- package/local/daemon/components.js.map +1 -1
- package/local/daemon/env.d.ts +16 -3
- package/local/daemon/env.d.ts.map +1 -1
- package/local/daemon/env.js +16 -3
- package/local/daemon/env.js.map +1 -1
- package/local/daemon/launch.d.ts.map +1 -1
- package/local/daemon/launch.js +5 -4
- package/local/daemon/launch.js.map +1 -1
- package/local/daemon/process.d.ts.map +1 -1
- package/local/daemon/process.js +8 -5
- package/local/daemon/process.js.map +1 -1
- package/local/runtime/index.d.ts +2 -2
- package/local/runtime/index.d.ts.map +1 -1
- package/local/runtime/index.js +4 -2
- package/local/runtime/index.js.map +1 -1
- package/local/runtime/node.d.ts +8 -0
- package/local/runtime/node.d.ts.map +1 -1
- package/local/runtime/node.js +74 -23
- package/local/runtime/node.js.map +1 -1
- package/local/runtime/runner.d.ts +2 -1
- package/local/runtime/runner.d.ts.map +1 -1
- package/local/runtime/runner.js +4 -31
- package/local/runtime/runner.js.map +1 -1
- package/local/runtime/runtimes-install.d.ts +13 -0
- package/local/runtime/runtimes-install.d.ts.map +1 -0
- package/local/runtime/runtimes-install.js +60 -0
- package/local/runtime/runtimes-install.js.map +1 -0
- package/local/runtime/server.d.ts +26 -30
- package/local/runtime/server.d.ts.map +1 -1
- package/local/runtime/server.js +106 -108
- package/local/runtime/server.js.map +1 -1
- package/local/status.d.ts.map +1 -1
- package/local/status.js +4 -2
- package/local/status.js.map +1 -1
- package/local/webconsole/index.d.ts +2 -3
- package/local/webconsole/index.d.ts.map +1 -1
- package/local/webconsole/index.js +24 -10
- package/local/webconsole/index.js.map +1 -1
- package/package.json +5 -5
- package/src/commands/up.ts +24 -6
- package/src/config/resolve.test.ts +1 -1
- package/src/config/resolve.ts +7 -4
- package/src/local/constants.ts +8 -4
- package/src/local/daemon/components.test.ts +9 -1
- package/src/local/daemon/components.ts +4 -2
- package/src/local/daemon/daemon.integration.test.ts +1 -1
- package/src/local/daemon/env.test.ts +15 -4
- package/src/local/daemon/env.ts +33 -5
- package/src/local/daemon/launch.ts +5 -4
- package/src/local/daemon/process.ts +7 -4
- package/src/local/runtime/index.ts +6 -5
- package/src/local/runtime/node.ts +99 -23
- package/src/local/runtime/runner.ts +5 -36
- package/src/local/runtime/runtime.test.ts +58 -14
- package/src/local/runtime/runtimes-install.test.ts +56 -0
- package/src/local/runtime/runtimes-install.ts +72 -0
- package/src/local/runtime/server.test.ts +178 -114
- package/src/local/runtime/server.ts +149 -129
- package/src/local/status.ts +4 -2
- package/src/local/webconsole/index.ts +27 -10
- package/src/resources/connect/oauth.test.ts +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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(/
|
|
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
|
});
|
package/src/local/daemon/env.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
112
|
+
state: consoleAvailable ? "running" : "stopped",
|
|
110
113
|
started_at: "",
|
|
111
114
|
restart_count: 0,
|
|
112
115
|
};
|
|
113
|
-
if (config.noWeb) log.info("web console
|
|
114
|
-
else if (!
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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",
|
|
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(
|
|
82
|
-
`
|
|
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
|
|
166
|
+
`Node at ${bin} (${version}) cannot run ${copy.subject}: it does not provide ${copy.capability}`,
|
|
89
167
|
ExitCode.General,
|
|
90
168
|
[
|
|
91
|
-
|
|
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 {
|
|
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?:
|
|
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
|
-
|
|
115
|
-
|
|
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", "
|
|
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
|
-
|
|
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\
|
|
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("
|
|
101
|
-
it("honors the
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
+
});
|