@stigmer/cli 3.14.1 → 3.15.1
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/client/client.d.ts +20 -1
- package/client/client.d.ts.map +1 -1
- package/client/client.js +32 -5
- package/client/client.js.map +1 -1
- package/commands/auth/index.d.ts.map +1 -1
- package/commands/auth/index.js +7 -36
- package/commands/auth/index.js.map +1 -1
- package/commands/auth/login.d.ts +13 -0
- package/commands/auth/login.d.ts.map +1 -0
- package/commands/auth/login.js +58 -0
- package/commands/auth/login.js.map +1 -0
- package/commands/auth/whoami.d.ts +18 -0
- package/commands/auth/whoami.d.ts.map +1 -0
- package/commands/auth/whoami.js +58 -0
- package/commands/auth/whoami.js.map +1 -0
- package/commands/draft.d.ts.map +1 -1
- package/commands/draft.js +24 -8
- package/commands/draft.js.map +1 -1
- package/commands/internal-daemon.d.ts.map +1 -1
- package/commands/internal-daemon.js +2 -9
- package/commands/internal-daemon.js.map +1 -1
- package/commands/run.d.ts.map +1 -1
- package/commands/run.js +15 -6
- package/commands/run.js.map +1 -1
- package/commands/up.d.ts.map +1 -1
- package/commands/up.js +37 -8
- package/commands/up.js.map +1 -1
- package/local/artifact.d.ts +13 -3
- package/local/artifact.d.ts.map +1 -1
- package/local/artifact.js +31 -10
- package/local/artifact.js.map +1 -1
- package/local/daemon/components.d.ts.map +1 -1
- package/local/daemon/components.js +2 -0
- package/local/daemon/components.js.map +1 -1
- package/local/daemon/host.d.ts +8 -0
- package/local/daemon/host.d.ts.map +1 -1
- package/local/daemon/host.js +42 -1
- package/local/daemon/host.js.map +1 -1
- package/local/daemon/index.d.ts +3 -3
- package/local/daemon/index.d.ts.map +1 -1
- package/local/daemon/index.js +2 -2
- package/local/daemon/index.js.map +1 -1
- package/local/daemon/launch.d.ts +29 -1
- package/local/daemon/launch.d.ts.map +1 -1
- package/local/daemon/launch.js +73 -23
- package/local/daemon/launch.js.map +1 -1
- package/local/daemon/process.d.ts +16 -2
- package/local/daemon/process.d.ts.map +1 -1
- package/local/daemon/process.js +16 -2
- package/local/daemon/process.js.map +1 -1
- package/local/daemon/types.d.ts +2 -0
- package/local/daemon/types.d.ts.map +1 -1
- package/local/paths.d.ts +14 -4
- package/local/paths.d.ts.map +1 -1
- package/local/paths.js +17 -4
- package/local/paths.js.map +1 -1
- package/local/runtime/runner.d.ts +4 -0
- package/local/runtime/runner.d.ts.map +1 -1
- package/local/runtime/runner.js +10 -6
- package/local/runtime/runner.js.map +1 -1
- package/local/runtime/server.d.ts +5 -0
- package/local/runtime/server.d.ts.map +1 -1
- package/local/runtime/server.js +13 -8
- package/local/runtime/server.js.map +1 -1
- package/local/seedpack/content.d.ts +4 -0
- package/local/seedpack/content.d.ts.map +1 -1
- package/local/seedpack/content.js +11 -11
- package/local/seedpack/content.js.map +1 -1
- package/local/state/lock.d.ts +3 -2
- package/local/state/lock.d.ts.map +1 -1
- package/local/state/lock.js +23 -9
- package/local/state/lock.js.map +1 -1
- package/local/state/proc.d.ts +13 -0
- package/local/state/proc.d.ts.map +1 -1
- package/local/state/proc.js +15 -0
- package/local/state/proc.js.map +1 -1
- package/local/temporal/download.d.ts +7 -1
- package/local/temporal/download.d.ts.map +1 -1
- package/local/temporal/download.js +21 -11
- package/local/temporal/download.js.map +1 -1
- package/local/temporal/index.d.ts +3 -3
- package/local/temporal/index.d.ts.map +1 -1
- package/local/temporal/index.js +2 -2
- package/local/temporal/index.js.map +1 -1
- package/local/temporal/inspect.d.ts +10 -3
- package/local/temporal/inspect.d.ts.map +1 -1
- package/local/temporal/inspect.js +52 -13
- package/local/temporal/inspect.js.map +1 -1
- package/local/temporal/manager.d.ts +16 -4
- package/local/temporal/manager.d.ts.map +1 -1
- package/local/temporal/manager.js +19 -6
- package/local/temporal/manager.js.map +1 -1
- package/package.json +5 -5
- package/resources/run/prepare.d.ts +10 -8
- package/resources/run/prepare.d.ts.map +1 -1
- package/resources/run/prepare.js +18 -4
- package/resources/run/prepare.js.map +1 -1
- package/src/client/client.test.ts +89 -1
- package/src/client/client.ts +82 -9
- package/src/commands/auth/index.ts +7 -56
- package/src/commands/auth/login.test.ts +52 -0
- package/src/commands/auth/login.ts +70 -0
- package/src/commands/auth/whoami.test.ts +96 -0
- package/src/commands/auth/whoami.ts +87 -0
- package/src/commands/draft.ts +85 -26
- package/src/commands/internal-daemon.ts +2 -10
- package/src/commands/run.ts +77 -35
- package/src/commands/up.ts +52 -8
- package/src/local/artifact.test.ts +27 -1
- package/src/local/artifact.ts +35 -13
- package/src/local/daemon/components.ts +2 -0
- package/src/local/daemon/daemon.integration.test.ts +33 -0
- package/src/local/daemon/host.ts +50 -1
- package/src/local/daemon/index.ts +3 -3
- package/src/local/daemon/launch.test.ts +136 -0
- package/src/local/daemon/launch.ts +108 -27
- package/src/local/daemon/process.ts +31 -4
- package/src/local/daemon/supervisor.test.ts +1 -1
- package/src/local/daemon/types.ts +2 -0
- package/src/local/paths.ts +17 -4
- package/src/local/runtime/runner.ts +10 -6
- package/src/local/runtime/runtime.test.ts +15 -0
- package/src/local/runtime/server.test.ts +37 -0
- package/src/local/runtime/server.ts +17 -12
- package/src/local/seedpack/content.test.ts +21 -0
- package/src/local/seedpack/content.ts +15 -16
- package/src/local/state/lock.ts +24 -9
- package/src/local/state/proc.ts +16 -0
- package/src/local/state/state.test.ts +25 -1
- package/src/local/temporal/download.test.ts +79 -11
- package/src/local/temporal/download.ts +24 -11
- package/src/local/temporal/index.ts +11 -3
- package/src/local/temporal/inspect.test.ts +83 -0
- package/src/local/temporal/inspect.ts +60 -12
- package/src/local/temporal/manager.test.ts +34 -1
- package/src/local/temporal/manager.ts +22 -8
- package/src/resources/run/prepare.test.ts +28 -26
- package/src/resources/run/prepare.ts +48 -18
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
|
-
import { mapReleaseArch, mapReleaseOs, sha256Hex } from "./artifact.js";
|
|
3
|
+
import { mapReleaseArch, mapReleaseOs, parseShasum, sha256Hex } from "./artifact.js";
|
|
4
4
|
|
|
5
5
|
describe("mapReleaseOs", () => {
|
|
6
6
|
it("maps Node platforms to release OS tokens", () => {
|
|
@@ -26,3 +26,29 @@ describe("sha256Hex", () => {
|
|
|
26
26
|
expect(sha256Hex(bytes)).toBe(expected);
|
|
27
27
|
});
|
|
28
28
|
});
|
|
29
|
+
|
|
30
|
+
// A release-wide `sha256sum` file is matched by filename, never by position,
|
|
31
|
+
// and the binary-mode "*" prefix is not part of a name.
|
|
32
|
+
describe("parseShasum", () => {
|
|
33
|
+
const A = "a".repeat(64);
|
|
34
|
+
const B = "b".repeat(64);
|
|
35
|
+
|
|
36
|
+
it("returns the digest on the line naming the file, wherever the line is", () => {
|
|
37
|
+
const file = `${A} first.tar.gz\n${B} second.tar.gz\n`;
|
|
38
|
+
expect(parseShasum(file, "second.tar.gz")).toBe(B);
|
|
39
|
+
expect(parseShasum(file, "first.tar.gz")).toBe(A);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns an empty string when no line names the file", () => {
|
|
43
|
+
expect(parseShasum(`${A} first.tar.gz\n`, "missing.tar.gz")).toBe("");
|
|
44
|
+
expect(parseShasum("", "missing.tar.gz")).toBe("");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("accepts sha256sum's binary-mode marker and normalises the hex to lowercase", () => {
|
|
48
|
+
expect(parseShasum(`${A.toUpperCase()} *first.tar.gz\n`, "first.tar.gz")).toBe(A);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("ignores blank and malformed lines", () => {
|
|
52
|
+
expect(parseShasum(`\n\nnot-a-digest\n${B} x.tar.gz\n`, "x.tar.gz")).toBe(B);
|
|
53
|
+
});
|
|
54
|
+
});
|
package/src/local/artifact.ts
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
// `.tar.gz`: release platform/arch mapping, a fetch + gunzip + minimal tar
|
|
3
3
|
// reader, optional sha256 verification, and an executable write.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
5
|
+
// The Temporal CLI downloader (`temporal/download.ts`) builds on this, and any
|
|
6
|
+
// future release-binary downloader should too, so the fetch/verify/extract
|
|
7
|
+
// contract lives in exactly one place. (The `stigmer-server` Go binary was its
|
|
8
|
+
// second consumer until the server became an npm artifact.) The tar reader is a
|
|
9
|
+
// tiny POSIX/ustar implementation: no native `tar` dependency, which keeps the
|
|
10
|
+
// base install lean (DD-002).
|
|
10
11
|
|
|
11
12
|
import { createHash } from "node:crypto";
|
|
12
13
|
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
@@ -43,9 +44,11 @@ export interface TarballBinarySource {
|
|
|
43
44
|
/** URL of the `.tar.gz` archive. */
|
|
44
45
|
url: string;
|
|
45
46
|
/**
|
|
46
|
-
* URL of a `shasum -a 256`-format checksum file
|
|
47
|
-
*
|
|
48
|
-
*
|
|
47
|
+
* URL of a `shasum -a 256`-format checksum file covering {@link url}. The
|
|
48
|
+
* file may list many assets (a release's `checksums.txt`); the line whose
|
|
49
|
+
* filename is the archive's basename is the one that counts. When set, the
|
|
50
|
+
* downloaded archive's sha256 is verified against it before extraction and
|
|
51
|
+
* any mismatch — or a file with no entry for the archive — aborts the install.
|
|
49
52
|
*/
|
|
50
53
|
checksumUrl?: string;
|
|
51
54
|
/** Basename of the entry to extract from the archive. */
|
|
@@ -69,11 +72,12 @@ export async function fetchTarballBinary(src: TarballBinarySource): Promise<void
|
|
|
69
72
|
const archive = await fetchBytes(doFetch, src.url, src.label);
|
|
70
73
|
|
|
71
74
|
if (src.checksumUrl !== undefined) {
|
|
72
|
-
const
|
|
75
|
+
const archiveName = archiveBasename(src.url);
|
|
76
|
+
const expected = parseShasum(await fetchText(doFetch, src.checksumUrl, `${src.label} checksum`), archiveName);
|
|
73
77
|
const actual = sha256Hex(archive);
|
|
74
78
|
if (expected === "" || expected !== actual) {
|
|
75
79
|
throw new CliExitError(`${src.label} checksum mismatch — refusing to use the download`, ExitCode.General, [
|
|
76
|
-
`expected: ${expected ||
|
|
80
|
+
`expected: ${expected || `(no entry for ${archiveName} in ${src.checksumUrl})`}`,
|
|
77
81
|
`actual: ${actual}`,
|
|
78
82
|
`archive: ${src.url}`,
|
|
79
83
|
]);
|
|
@@ -121,9 +125,27 @@ async function doFetchOrThrow(doFetch: typeof fetch, url: string, label: string)
|
|
|
121
125
|
return res;
|
|
122
126
|
}
|
|
123
127
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
128
|
+
/**
|
|
129
|
+
* The sha256 a `shasum`/`sha256sum`-format file records for `filename`, lowercase
|
|
130
|
+
* hex, or "" when the file has no entry for it. Each line is "<hex> <filename>";
|
|
131
|
+
* `sha256sum -b` writes the filename with a leading "*" (binary mode), which is
|
|
132
|
+
* not part of the name. A release-wide file lists every asset, so the match is
|
|
133
|
+
* by filename, never by position.
|
|
134
|
+
*/
|
|
135
|
+
export function parseShasum(content: string, filename: string): string {
|
|
136
|
+
for (const line of content.split("\n")) {
|
|
137
|
+
const [hex, name] = line.trim().split(/\s+/, 2);
|
|
138
|
+
if (hex === undefined || name === undefined) continue;
|
|
139
|
+
if (name.replace(/^\*/, "") === filename) return hex.toLowerCase();
|
|
140
|
+
}
|
|
141
|
+
return "";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The archive's basename as the checksum file names it: the last path segment
|
|
145
|
+
// of the URL, with any query string already excluded by URL parsing.
|
|
146
|
+
function archiveBasename(url: string): string {
|
|
147
|
+
const segments = new URL(url).pathname.split("/");
|
|
148
|
+
return segments[segments.length - 1] ?? "";
|
|
127
149
|
}
|
|
128
150
|
|
|
129
151
|
/**
|
|
@@ -94,6 +94,7 @@ export function buildComponents(config: DaemonConfig, base: NodeJS.ProcessEnv =
|
|
|
94
94
|
// The server is `node <entry>` — the runner's launch shape, one child
|
|
95
95
|
// contract for both components.
|
|
96
96
|
resolve: () => ({
|
|
97
|
+
name: "stigmer-server",
|
|
97
98
|
command: config.server.nodeBin,
|
|
98
99
|
args: [config.server.entryPath],
|
|
99
100
|
env: buildServerEnv(config, base),
|
|
@@ -113,6 +114,7 @@ export function buildComponents(config: DaemonConfig, base: NodeJS.ProcessEnv =
|
|
|
113
114
|
pidFile: join(config.dataDir, RUNNER_PID_FILE),
|
|
114
115
|
critical: false,
|
|
115
116
|
resolve: () => ({
|
|
117
|
+
name: "runner",
|
|
116
118
|
command: runner.nodeBin,
|
|
117
119
|
args: [runner.entryPath],
|
|
118
120
|
cwd: join(config.dataDir, "workspace"),
|
|
@@ -35,6 +35,7 @@ describe("NodeProcessHost (real spawn)", () => {
|
|
|
35
35
|
|
|
36
36
|
const host = new NodeProcessHost();
|
|
37
37
|
const request: SpawnRequest = {
|
|
38
|
+
name: "runner",
|
|
38
39
|
command: process.execPath,
|
|
39
40
|
args: [script],
|
|
40
41
|
env: process.env,
|
|
@@ -57,6 +58,38 @@ describe("NodeProcessHost (real spawn)", () => {
|
|
|
57
58
|
await exited;
|
|
58
59
|
expect(handle.hasExited()).toBe(true);
|
|
59
60
|
});
|
|
61
|
+
|
|
62
|
+
// The foreground launcher's window into the children: every complete line,
|
|
63
|
+
// tagged with the component, in order within a stream; a partial trailing
|
|
64
|
+
// line is delivered at exit; the log file still receives the same bytes.
|
|
65
|
+
it("mirrors complete lines tagged with the component, flushing the tail at exit", async () => {
|
|
66
|
+
const dir = tempDir();
|
|
67
|
+
const script = join(dir, "chatty-child.cjs");
|
|
68
|
+
writeFileSync(
|
|
69
|
+
script,
|
|
70
|
+
// Two writes that split a line across chunks, one stderr line, and a
|
|
71
|
+
// final line with no newline.
|
|
72
|
+
`process.stdout.write("first li");\n` +
|
|
73
|
+
`process.stdout.write("ne\\nsecond line\\n");\n` +
|
|
74
|
+
`process.stderr.write("a warning\\n");\n` +
|
|
75
|
+
`process.stdout.write("tail without newline");\n` +
|
|
76
|
+
`setTimeout(() => process.exit(0), 50);\n`,
|
|
77
|
+
);
|
|
78
|
+
const logFile = join(dir, "child.log");
|
|
79
|
+
const mirrored: string[] = [];
|
|
80
|
+
const host = new NodeProcessHost({ mirror: (component, line) => mirrored.push(`${component}|${line}`) });
|
|
81
|
+
|
|
82
|
+
const handle = host.spawn({ name: "stigmer-server", command: process.execPath, args: [script], env: process.env, logFile });
|
|
83
|
+
await new Promise<void>((resolve) => handle.onExit(() => resolve()));
|
|
84
|
+
|
|
85
|
+
expect(mirrored.filter((l) => l !== "stigmer-server|a warning")).toEqual([
|
|
86
|
+
"stigmer-server|first line",
|
|
87
|
+
"stigmer-server|second line",
|
|
88
|
+
"stigmer-server|tail without newline",
|
|
89
|
+
]);
|
|
90
|
+
expect(mirrored).toContain("stigmer-server|a warning");
|
|
91
|
+
await expect.poll(() => readFileSync(logFile, "utf8")).toContain("first line\nsecond line\n");
|
|
92
|
+
});
|
|
60
93
|
});
|
|
61
94
|
|
|
62
95
|
// ---- Integration 2: the full daemon body, with a fake process host + fake
|
package/src/local/daemon/host.ts
CHANGED
|
@@ -7,13 +7,32 @@
|
|
|
7
7
|
// which is what lets `status` report "runner actually polling" instead of mere
|
|
8
8
|
// liveness. Logs survive normally because the daemon outlives its children; an
|
|
9
9
|
// abnormal daemon death is handled by orphan cleanup on the next `up`.
|
|
10
|
+
//
|
|
11
|
+
// An optional mirror receives every complete line a child writes, tagged with
|
|
12
|
+
// the component's name. The foreground launcher (`stigmer up --foreground`)
|
|
13
|
+
// uses it to show the stack's output in the terminal — or in `docker logs` —
|
|
14
|
+
// while the log files keep receiving the same bytes. The detached daemon
|
|
15
|
+
// installs no mirror: its own stdout is already redirected to daemon.log.
|
|
10
16
|
|
|
11
17
|
import { spawn } from "node:child_process";
|
|
12
18
|
import { createWriteStream } from "node:fs";
|
|
13
19
|
import { killProcessGroup } from "../state/proc.js";
|
|
14
20
|
import type { ChildHandle, Clock, ExitInfo, ProcessHost, SpawnRequest } from "./types.js";
|
|
15
21
|
|
|
22
|
+
/** Receives one complete output line from a child, with the component that wrote it. */
|
|
23
|
+
export type OutputMirror = (component: string, line: string) => void;
|
|
24
|
+
|
|
25
|
+
export interface NodeProcessHostOptions {
|
|
26
|
+
mirror?: OutputMirror;
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
export class NodeProcessHost implements ProcessHost {
|
|
30
|
+
private readonly mirror: OutputMirror | undefined;
|
|
31
|
+
|
|
32
|
+
constructor(options: NodeProcessHostOptions = {}) {
|
|
33
|
+
this.mirror = options.mirror;
|
|
34
|
+
}
|
|
35
|
+
|
|
17
36
|
spawn(request: SpawnRequest): ChildHandle {
|
|
18
37
|
const out = createWriteStream(request.logFile, { flags: "a" });
|
|
19
38
|
const child = spawn(request.command, request.args, {
|
|
@@ -31,8 +50,14 @@ export class NodeProcessHost implements ProcessHost {
|
|
|
31
50
|
// Keep a small rolling tail so a marker split across chunks is still found.
|
|
32
51
|
let tail = "";
|
|
33
52
|
const marker = request.readinessMarker;
|
|
53
|
+
// One line assembler per stream: a chunk can end mid-line, and the two
|
|
54
|
+
// streams interleave, so each keeps its own partial line.
|
|
55
|
+
const mirrorStdout = this.lineMirror(request.name);
|
|
56
|
+
const mirrorStderr = this.lineMirror(request.name);
|
|
57
|
+
|
|
34
58
|
child.stdout?.on("data", (chunk: Buffer) => {
|
|
35
59
|
out.write(chunk);
|
|
60
|
+
mirrorStdout(chunk);
|
|
36
61
|
if (marker !== undefined && !ready) {
|
|
37
62
|
tail = (tail + chunk.toString("utf8")).slice(-Math.max(marker.length * 2, 256));
|
|
38
63
|
if (tail.includes(marker)) {
|
|
@@ -41,10 +66,15 @@ export class NodeProcessHost implements ProcessHost {
|
|
|
41
66
|
}
|
|
42
67
|
}
|
|
43
68
|
});
|
|
44
|
-
child.stderr?.on("data", (chunk: Buffer) =>
|
|
69
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
70
|
+
out.write(chunk);
|
|
71
|
+
mirrorStderr(chunk);
|
|
72
|
+
});
|
|
45
73
|
|
|
46
74
|
child.on("exit", (code, signal) => {
|
|
47
75
|
exited = true;
|
|
76
|
+
mirrorStdout(null);
|
|
77
|
+
mirrorStderr(null);
|
|
48
78
|
out.end();
|
|
49
79
|
exitCb?.({ code, signal });
|
|
50
80
|
});
|
|
@@ -70,6 +100,25 @@ export class NodeProcessHost implements ProcessHost {
|
|
|
70
100
|
},
|
|
71
101
|
};
|
|
72
102
|
}
|
|
103
|
+
|
|
104
|
+
// Assemble complete lines from a byte stream and hand each to the mirror. A
|
|
105
|
+
// `null` chunk flushes the trailing partial line at exit. Without a mirror
|
|
106
|
+
// this is a no-op closure, so the hot path pays nothing in the detached daemon.
|
|
107
|
+
private lineMirror(component: string): (chunk: Buffer | null) => void {
|
|
108
|
+
const mirror = this.mirror;
|
|
109
|
+
if (mirror === undefined) return () => {};
|
|
110
|
+
let partial = "";
|
|
111
|
+
return (chunk) => {
|
|
112
|
+
if (chunk === null) {
|
|
113
|
+
if (partial !== "") mirror(component, partial);
|
|
114
|
+
partial = "";
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const lines = (partial + chunk.toString("utf8")).split("\n");
|
|
118
|
+
partial = lines.pop() ?? "";
|
|
119
|
+
for (const line of lines) mirror(component, line);
|
|
120
|
+
};
|
|
121
|
+
}
|
|
73
122
|
}
|
|
74
123
|
|
|
75
124
|
/** Real wall-clock implementation of Clock. */
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
export { buildComponents, buildRunnerEnv, buildServerEnv } from "./components.js";
|
|
4
4
|
export { type DaemonConfig, type DaemonEnvInputs, DaemonEnvVar, buildDaemonEnv, readDaemonConfig } from "./env.js";
|
|
5
|
-
export { NodeProcessHost, systemClock } from "./host.js";
|
|
6
|
-
export { down, isRunning, type UpOptions, up } from "./launch.js";
|
|
7
|
-
export { type InternalDaemonDeps, type TemporalControl, runInternalDaemon } from "./process.js";
|
|
5
|
+
export { NodeProcessHost, type NodeProcessHostOptions, type OutputMirror, systemClock } from "./host.js";
|
|
6
|
+
export { down, isRunning, type UpForegroundDeps, type UpOptions, up, upForeground } from "./launch.js";
|
|
7
|
+
export { type InternalDaemonDeps, type TemporalControl, runInternalDaemon, waitForShutdownSignal } from "./process.js";
|
|
8
8
|
export {
|
|
9
9
|
HEALTH_CHECK_INTERVAL_MS,
|
|
10
10
|
MAX_RESTARTS,
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Pins the foreground launcher's contract with the daemon body: the config it
|
|
2
|
+
// hands over is the detached shape's, encoded through the same env contract;
|
|
3
|
+
// the children inherit that built environment; the post-readiness work (the
|
|
4
|
+
// bootstrap, the startup record, the caller's onReady) runs from onStarted;
|
|
5
|
+
// the daemon's exit code is the launcher's. Resolution is driven through the
|
|
6
|
+
// same overrides a container sets (STIGMER_SERVER_DIR, STIGMER_RUNNER_DIR,
|
|
7
|
+
// STIGMER_TEMPORAL_BIN), so nothing is downloaded or installed.
|
|
8
|
+
|
|
9
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
13
|
+
import { SERVER_PORT } from "../constants.js";
|
|
14
|
+
import { tcpConnects } from "../net/tcp.js";
|
|
15
|
+
import { loadStartupConfig } from "../state/startup-config.js";
|
|
16
|
+
import { DaemonEnvVar } from "./env.js";
|
|
17
|
+
import { upForeground } from "./launch.js";
|
|
18
|
+
import type { InternalDaemonDeps } from "./process.js";
|
|
19
|
+
|
|
20
|
+
// The launcher refuses to start beside a stack already serving on the server
|
|
21
|
+
// port — including a developer's own `stigmer up`. Report SKIPPED rather than
|
|
22
|
+
// a false failure in that case.
|
|
23
|
+
const serverPortBusy = await tcpConnects(SERVER_PORT, "127.0.0.1", 300);
|
|
24
|
+
|
|
25
|
+
function tempDir(prefix: string): string {
|
|
26
|
+
return mkdtempSync(join(tmpdir(), prefix));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A built package in the shape the resolvers accept: package.json + dist/main.js.
|
|
30
|
+
function builtPackage(prefix: string): string {
|
|
31
|
+
const dir = tempDir(prefix);
|
|
32
|
+
writeFileSync(join(dir, "package.json"), "{}");
|
|
33
|
+
mkdirSync(join(dir, "dist"));
|
|
34
|
+
writeFileSync(join(dir, "dist", "main.js"), "//");
|
|
35
|
+
return dir;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const TOUCHED = ["STIGMER_SERVER_DIR", "STIGMER_RUNNER_DIR", "STIGMER_TEMPORAL_BIN", "STIGMER_NODE_BIN", "ANTHROPIC_API_KEY"] as const;
|
|
39
|
+
let saved: Record<string, string | undefined>;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
saved = {};
|
|
43
|
+
for (const key of TOUCHED) {
|
|
44
|
+
saved[key] = process.env[key];
|
|
45
|
+
delete process.env[key];
|
|
46
|
+
}
|
|
47
|
+
process.env.STIGMER_SERVER_DIR = builtPackage("stigmer-fg-server-");
|
|
48
|
+
process.env.STIGMER_RUNNER_DIR = builtPackage("stigmer-fg-runner-");
|
|
49
|
+
const temporalBin = join(tempDir("stigmer-fg-temporal-"), "temporal");
|
|
50
|
+
writeFileSync(temporalBin, "#!/bin/sh\n");
|
|
51
|
+
process.env.STIGMER_TEMPORAL_BIN = temporalBin;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
for (const key of TOUCHED) {
|
|
56
|
+
if (saved[key] === undefined) delete process.env[key];
|
|
57
|
+
else process.env[key] = saved[key];
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe.skipIf(serverPortBusy)("upForeground", () => {
|
|
62
|
+
it("runs the daemon body in-process with the detached shape's config and env, bootstrapping from onStarted", async () => {
|
|
63
|
+
const home = tempDir("stigmer-fg-home-");
|
|
64
|
+
process.env.ANTHROPIC_API_KEY = "sk-test";
|
|
65
|
+
const order: string[] = [];
|
|
66
|
+
let captured: InternalDaemonDeps | null = null;
|
|
67
|
+
|
|
68
|
+
const code = await upForeground({ noWeb: true }, home, {
|
|
69
|
+
runDaemon: async (deps) => {
|
|
70
|
+
captured = deps;
|
|
71
|
+
order.push("daemon:started");
|
|
72
|
+
await deps.onStarted?.();
|
|
73
|
+
order.push("daemon:waiting");
|
|
74
|
+
return 0;
|
|
75
|
+
},
|
|
76
|
+
bootstrap: async () => {
|
|
77
|
+
order.push("bootstrap");
|
|
78
|
+
},
|
|
79
|
+
onReady: () => {
|
|
80
|
+
order.push("onReady");
|
|
81
|
+
},
|
|
82
|
+
waitForShutdown: () => Promise.resolve(),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(code).toBe(0);
|
|
86
|
+
expect(order).toEqual(["daemon:started", "bootstrap", "onReady", "daemon:waiting"]);
|
|
87
|
+
|
|
88
|
+
const deps = captured as unknown as InternalDaemonDeps;
|
|
89
|
+
// The config is the env contract read back — the detached daemon's view.
|
|
90
|
+
expect(deps.config?.dataDir).toBe(join(home, ".stigmer", "data"));
|
|
91
|
+
expect(deps.config?.temporalManaged).toBe(true);
|
|
92
|
+
expect(deps.config?.noWeb).toBe(true);
|
|
93
|
+
expect(deps.config?.server.entryPath).toBe(join(process.env.STIGMER_SERVER_DIR!, "dist", "main.js"));
|
|
94
|
+
expect(deps.config?.runner?.entryPath).toBe(join(process.env.STIGMER_RUNNER_DIR!, "dist", "main.js"));
|
|
95
|
+
expect(deps.config?.anthropicApiKey).toBe("sk-test");
|
|
96
|
+
// The children inherit the built environment, contract variables included.
|
|
97
|
+
expect(deps.env?.[DaemonEnvVar.DataDir]).toBe(join(home, ".stigmer", "data"));
|
|
98
|
+
expect(deps.env?.[DaemonEnvVar.ServerEntry]).toBe(deps.config?.server.entryPath);
|
|
99
|
+
// The startup record names this process as the daemon.
|
|
100
|
+
expect(loadStartupConfig(join(home, ".stigmer", "data"))?.stigmer_server_pid).toBe(process.pid);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("propagates the daemon's failure exit code without bootstrapping", async () => {
|
|
104
|
+
const home = tempDir("stigmer-fg-home-");
|
|
105
|
+
const bootstrap = vi.fn(async () => {});
|
|
106
|
+
|
|
107
|
+
const code = await upForeground({}, home, {
|
|
108
|
+
// A critical component failed to start: the body returns 1 before onStarted.
|
|
109
|
+
runDaemon: async () => 1,
|
|
110
|
+
bootstrap,
|
|
111
|
+
waitForShutdown: () => Promise.resolve(),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(code).toBe(1);
|
|
115
|
+
expect(bootstrap).not.toHaveBeenCalled();
|
|
116
|
+
expect(loadStartupConfig(join(home, ".stigmer", "data"))).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("omits the runner from the contract in server-only mode", async () => {
|
|
120
|
+
const home = tempDir("stigmer-fg-home-");
|
|
121
|
+
let captured: InternalDaemonDeps | null = null;
|
|
122
|
+
|
|
123
|
+
await upForeground({ serverOnly: true }, home, {
|
|
124
|
+
runDaemon: async (deps) => {
|
|
125
|
+
captured = deps;
|
|
126
|
+
return 0;
|
|
127
|
+
},
|
|
128
|
+
bootstrap: async () => {},
|
|
129
|
+
waitForShutdown: () => Promise.resolve(),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const deps = captured as unknown as InternalDaemonDeps;
|
|
133
|
+
expect(deps.config?.serverOnly).toBe(true);
|
|
134
|
+
expect(deps.config?.runner).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
// Foreground control of the daemon: `up` (spawn + verify + wait-ready),
|
|
2
|
-
// `
|
|
2
|
+
// `upForeground` (run the daemon body right here until a signal), `down`
|
|
3
|
+
// (signal + wait + safety-net cleanup), and a liveness check.
|
|
3
4
|
//
|
|
4
|
-
// `up`
|
|
5
|
+
// Both `up` shapes resolve every heavy dependency first (Temporal binary,
|
|
5
6
|
// server launch, runner entry) so failures surface with a clear message before
|
|
6
|
-
//
|
|
7
|
-
// `internal-daemon` and waits until the server's gRPC port
|
|
7
|
+
// anything long-lived starts. The detached shape then re-execs this same CLI
|
|
8
|
+
// as the hidden `internal-daemon` and waits until the server's gRPC port
|
|
9
|
+
// answers; the foreground shape calls the same daemon body in-process, which
|
|
10
|
+
// is what a container entrypoint, a systemd unit or a tmux pane wants: one
|
|
11
|
+
// process to watch, signals delivered to it, the stack's output on its stdio.
|
|
8
12
|
|
|
9
13
|
import { spawn } from "node:child_process";
|
|
10
14
|
import { mkdirSync, openSync } from "node:fs";
|
|
@@ -18,7 +22,7 @@ import { DAEMON_PID_FILE, SERVER_PORT } from "../constants.js";
|
|
|
18
22
|
import { tcpConnects, waitForTcp } from "../net/tcp.js";
|
|
19
23
|
import { dataDir, logDir } from "../paths.js";
|
|
20
24
|
import { readPidFile, removePidFile } from "../state/pidfile.js";
|
|
21
|
-
import { findProcessByPort, isProcessAlive, killProcess } from "../state/proc.js";
|
|
25
|
+
import { findProcessByPort, isOtherLiveProcess, isProcessAlive, killProcess } from "../state/proc.js";
|
|
22
26
|
import { type StartupConfig, removeStartupConfig, saveStartupConfig } from "../state/startup-config.js";
|
|
23
27
|
import { rotateLogs } from "../state/log-rotation.js";
|
|
24
28
|
import { resolveApiKey, resolveProvider } from "../llm-config.js";
|
|
@@ -26,7 +30,9 @@ import { resolveOperatorIdentity } from "../operator-config.js";
|
|
|
26
30
|
import { ensureRunner } from "../runtime/runner.js";
|
|
27
31
|
import { ensureServer } from "../runtime/server.js";
|
|
28
32
|
import { TemporalManager } from "../temporal/manager.js";
|
|
29
|
-
import { buildDaemonEnv, type DaemonEnvInputs } from "./env.js";
|
|
33
|
+
import { buildDaemonEnv, type DaemonEnvInputs, readDaemonConfig } from "./env.js";
|
|
34
|
+
import { NodeProcessHost, type OutputMirror } from "./host.js";
|
|
35
|
+
import { type InternalDaemonDeps, runInternalDaemon, waitForShutdownSignal } from "./process.js";
|
|
30
36
|
|
|
31
37
|
/** How long `up` waits for the server's gRPC port after spawning the daemon. */
|
|
32
38
|
const READY_TIMEOUT_MS = 60_000;
|
|
@@ -41,8 +47,94 @@ export interface UpOptions {
|
|
|
41
47
|
noWeb?: boolean;
|
|
42
48
|
}
|
|
43
49
|
|
|
50
|
+
/** Everything both `up` shapes need once the heavy resolution is done. */
|
|
51
|
+
interface PreparedLaunch {
|
|
52
|
+
data: string;
|
|
53
|
+
logs: string;
|
|
54
|
+
temporalAddress: string;
|
|
55
|
+
env: NodeJS.ProcessEnv;
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
/** Start the local stack in the background. Throws on any startup failure. */
|
|
45
59
|
export async function up(options: UpOptions = {}, home: string = homedir()): Promise<void> {
|
|
60
|
+
const { data, logs, temporalAddress, env } = await prepareLaunch(options, home);
|
|
61
|
+
|
|
62
|
+
const daemonPid = spawnDaemon(env, join(logs, "daemon.log"));
|
|
63
|
+
log.info("daemon process started", { pid: daemonPid });
|
|
64
|
+
|
|
65
|
+
await sleep(DAEMON_SETTLE_MS);
|
|
66
|
+
if (!isProcessAlive(daemonPid)) {
|
|
67
|
+
throw new CliExitError("daemon process crashed during startup", ExitCode.General, [
|
|
68
|
+
`Check ${join(logs, "daemon.log")} for details.`,
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await waitForTcp({ port: SERVER_PORT, timeoutMs: READY_TIMEOUT_MS, label: "stigmer-server" });
|
|
73
|
+
|
|
74
|
+
await applySeedpackBestEffort(home);
|
|
75
|
+
|
|
76
|
+
saveStartupConfig(data, buildStartupConfig(data, logs, temporalAddress, daemonPid, options));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Seams of the foreground launcher, injectable for tests. */
|
|
80
|
+
export interface UpForegroundDeps {
|
|
81
|
+
/** The daemon body (default: the real one). */
|
|
82
|
+
runDaemon?: (deps: InternalDaemonDeps) => Promise<number>;
|
|
83
|
+
/** Resolves when the stack should shut down (default: the first SIGTERM/SIGINT). */
|
|
84
|
+
waitForShutdown?: () => Promise<void>;
|
|
85
|
+
/** Post-readiness bootstrap (default: the best-effort seedpack apply). */
|
|
86
|
+
bootstrap?: (home: string) => Promise<void>;
|
|
87
|
+
/** Receives each line the server and runner write (default: a `[component]`-prefixed stdout mirror). */
|
|
88
|
+
mirror?: OutputMirror;
|
|
89
|
+
/** Invoked once the stack is serving and seeded — the moment a detached `up` would have returned. */
|
|
90
|
+
onReady?: () => void | Promise<void>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Start the local stack in THIS process and run it until shutdown is requested.
|
|
95
|
+
* Resolves to the daemon's exit code: 0 after a clean shutdown, 1 when a
|
|
96
|
+
* critical component failed to start (the daemon has already logged why).
|
|
97
|
+
*
|
|
98
|
+
* The same resolution, the same daemon body and the same on-disk state as
|
|
99
|
+
* `up`, so `stigmer status` and `stigmer down` from another shell see an
|
|
100
|
+
* ordinary daemon whose PID happens to be ours. The children receive the very
|
|
101
|
+
* environment a detached daemon would have inherited, and what a detached `up`
|
|
102
|
+
* does once the server answers — the seedpack, the startup record — happens in
|
|
103
|
+
* `onStarted`, at the same point in the stack's life.
|
|
104
|
+
*/
|
|
105
|
+
export async function upForeground(
|
|
106
|
+
options: UpOptions = {},
|
|
107
|
+
home: string = homedir(),
|
|
108
|
+
deps: UpForegroundDeps = {},
|
|
109
|
+
): Promise<number> {
|
|
110
|
+
const { data, logs, temporalAddress, env } = await prepareLaunch(options, home);
|
|
111
|
+
const runDaemon = deps.runDaemon ?? runInternalDaemon;
|
|
112
|
+
const bootstrap = deps.bootstrap ?? applySeedpackBestEffort;
|
|
113
|
+
|
|
114
|
+
return runDaemon({
|
|
115
|
+
config: readDaemonConfig(env),
|
|
116
|
+
env,
|
|
117
|
+
host: new NodeProcessHost({ mirror: deps.mirror ?? mirrorToStdout }),
|
|
118
|
+
waitForShutdown: deps.waitForShutdown ?? waitForShutdownSignal,
|
|
119
|
+
onStarted: async () => {
|
|
120
|
+
await bootstrap(home);
|
|
121
|
+
saveStartupConfig(data, buildStartupConfig(data, logs, temporalAddress, process.pid, options));
|
|
122
|
+
await deps.onReady?.();
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The default foreground mirror: one line per child line, tagged with the
|
|
128
|
+
// component, on stdout — a terminal in a tmux pane, `docker logs` in a
|
|
129
|
+
// container. The log files keep receiving the same bytes untagged.
|
|
130
|
+
const mirrorToStdout: OutputMirror = (component, line) => {
|
|
131
|
+
process.stdout.write(`[${component}] ${line}\n`);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// The part of `up` that is the same whether the daemon then runs detached or
|
|
135
|
+
// right here: refuse a second stack, prepare the state dirs, resolve every
|
|
136
|
+
// heavy dependency, and encode the launcher→daemon contract.
|
|
137
|
+
async function prepareLaunch(options: UpOptions, home: string): Promise<PreparedLaunch> {
|
|
46
138
|
const data = dataDir(home);
|
|
47
139
|
const logs = logDir(home);
|
|
48
140
|
|
|
@@ -84,21 +176,7 @@ export async function up(options: UpOptions = {}, home: string = homedir()): Pro
|
|
|
84
176
|
process.env,
|
|
85
177
|
);
|
|
86
178
|
|
|
87
|
-
|
|
88
|
-
log.info("daemon process started", { pid: daemonPid });
|
|
89
|
-
|
|
90
|
-
await sleep(DAEMON_SETTLE_MS);
|
|
91
|
-
if (!isProcessAlive(daemonPid)) {
|
|
92
|
-
throw new CliExitError("daemon process crashed during startup", ExitCode.General, [
|
|
93
|
-
`Check ${join(logs, "daemon.log")} for details.`,
|
|
94
|
-
]);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
await waitForTcp({ port: SERVER_PORT, timeoutMs: READY_TIMEOUT_MS, label: "stigmer-server" });
|
|
98
|
-
|
|
99
|
-
await applySeedpackBestEffort(home);
|
|
100
|
-
|
|
101
|
-
saveStartupConfig(data, buildStartupConfig(data, logs, temporal.address, daemonPid, options));
|
|
179
|
+
return { data, logs, temporalAddress: temporal.address, env };
|
|
102
180
|
}
|
|
103
181
|
|
|
104
182
|
/**
|
|
@@ -143,7 +221,7 @@ export async function down(home: string = homedir()): Promise<boolean> {
|
|
|
143
221
|
let pid = readPidFile(join(data, DAEMON_PID_FILE));
|
|
144
222
|
if (pid === null) pid = findProcessByPort(SERVER_PORT);
|
|
145
223
|
|
|
146
|
-
if (pid === null || !
|
|
224
|
+
if (pid === null || !isOtherLiveProcess(pid)) {
|
|
147
225
|
removePidFile(join(data, DAEMON_PID_FILE));
|
|
148
226
|
await stopManagedTemporal(home);
|
|
149
227
|
cleanupOrphans(data);
|
|
@@ -173,13 +251,13 @@ export async function down(home: string = homedir()): Promise<boolean> {
|
|
|
173
251
|
return true;
|
|
174
252
|
}
|
|
175
253
|
|
|
176
|
-
/** Whether the daemon is running: live PID, else a server-port fallback. */
|
|
254
|
+
/** Whether the daemon is running: a live peer's PID, else a server-port fallback. */
|
|
177
255
|
export async function isRunning(home: string = homedir()): Promise<boolean> {
|
|
178
256
|
const data = dataDir(home);
|
|
179
257
|
const pid = readPidFile(join(data, DAEMON_PID_FILE));
|
|
180
258
|
if (pid !== null) {
|
|
181
|
-
if (
|
|
182
|
-
removePidFile(join(data, DAEMON_PID_FILE)); // stale
|
|
259
|
+
if (isOtherLiveProcess(pid)) return true;
|
|
260
|
+
removePidFile(join(data, DAEMON_PID_FILE)); // stale (dead, or a previous life of our own PID)
|
|
183
261
|
}
|
|
184
262
|
return tcpConnects(SERVER_PORT, "127.0.0.1", 1000);
|
|
185
263
|
}
|
|
@@ -204,13 +282,16 @@ function spawnDaemon(env: NodeJS.ProcessEnv, daemonLog: string): number {
|
|
|
204
282
|
return child.pid;
|
|
205
283
|
}
|
|
206
284
|
|
|
207
|
-
// Kill any daemon/server/runner left alive by a previous, improperly-stopped
|
|
285
|
+
// Kill any daemon/server/runner left alive by a previous, improperly-stopped
|
|
286
|
+
// run. A recorded PID that is our own is a leftover, never a peer (the fresh
|
|
287
|
+
// PID namespace of a restarted container hands the launcher the PID its last
|
|
288
|
+
// life had) — signalling it would be signalling ourselves.
|
|
208
289
|
function cleanupOrphans(data: string): void {
|
|
209
290
|
for (const name of [DAEMON_PID_FILE, "stigmer-server.pid", "runner.pid"]) {
|
|
210
291
|
const pidPath = join(data, name);
|
|
211
292
|
const pid = readPidFile(pidPath);
|
|
212
293
|
if (pid === null) continue;
|
|
213
|
-
if (
|
|
294
|
+
if (isOtherLiveProcess(pid)) {
|
|
214
295
|
log.warn("killing orphaned process from a previous run", { file: name, pid });
|
|
215
296
|
killProcess(pid, "SIGTERM");
|
|
216
297
|
}
|