@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
|
@@ -36,14 +36,35 @@ export interface TemporalControl {
|
|
|
36
36
|
|
|
37
37
|
export interface InternalDaemonDeps {
|
|
38
38
|
config?: DaemonConfig;
|
|
39
|
+
/**
|
|
40
|
+
* The environment the children inherit (defaults to this process's). The
|
|
41
|
+
* detached daemon IS the environment the launcher built for it; the
|
|
42
|
+
* foreground launcher passes that same built environment here so the
|
|
43
|
+
* children see exactly what they would have seen detached.
|
|
44
|
+
*/
|
|
45
|
+
env?: NodeJS.ProcessEnv;
|
|
39
46
|
host?: ProcessHost;
|
|
40
47
|
clock?: Clock;
|
|
41
48
|
temporal?: TemporalControl;
|
|
42
49
|
log?: Logger;
|
|
43
50
|
/** Resolves when shutdown is requested (real entry: SIGTERM/SIGINT). */
|
|
44
51
|
waitForShutdown: () => Promise<void>;
|
|
45
|
-
/**
|
|
46
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Invoked once the stack is up and the monitor is running, and awaited
|
|
54
|
+
* before the daemon settles into waiting for shutdown. The foreground
|
|
55
|
+
* launcher does its post-readiness work here (seedpack, the success card);
|
|
56
|
+
* tests use it to observe "started".
|
|
57
|
+
*/
|
|
58
|
+
onStarted?: () => void | Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Resolve on the first SIGTERM or SIGINT — the daemon's shutdown trigger. */
|
|
62
|
+
export function waitForShutdownSignal(): Promise<void> {
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const onSignal = (): void => resolve();
|
|
65
|
+
process.once("SIGTERM", onSignal);
|
|
66
|
+
process.once("SIGINT", onSignal);
|
|
67
|
+
});
|
|
47
68
|
}
|
|
48
69
|
|
|
49
70
|
/** Run the daemon to completion. Returns the process exit code. */
|
|
@@ -88,7 +109,13 @@ export async function runInternalDaemon(deps: InternalDaemonDeps): Promise<numbe
|
|
|
88
109
|
}
|
|
89
110
|
|
|
90
111
|
// --- Start the server (gated) and runner via the supervisor. ---
|
|
91
|
-
const supervisor = new ProcessSupervisor(buildComponents(config
|
|
112
|
+
const supervisor = new ProcessSupervisor(buildComponents(config, deps.env ?? process.env), {
|
|
113
|
+
host,
|
|
114
|
+
clock,
|
|
115
|
+
healthState,
|
|
116
|
+
persist,
|
|
117
|
+
log,
|
|
118
|
+
});
|
|
92
119
|
const result = await supervisor.startAll();
|
|
93
120
|
if (!result.ok) {
|
|
94
121
|
log.error("critical component failed to start", { component: result.failedCritical });
|
|
@@ -127,7 +154,7 @@ export async function runInternalDaemon(deps: InternalDaemonDeps): Promise<numbe
|
|
|
127
154
|
monitor.unref?.();
|
|
128
155
|
|
|
129
156
|
log.info("daemon started", { pid: process.pid });
|
|
130
|
-
deps.onStarted?.();
|
|
157
|
+
await deps.onStarted?.();
|
|
131
158
|
|
|
132
159
|
// --- Wait for shutdown, then tear down in reverse order. ---
|
|
133
160
|
await deps.waitForShutdown();
|
|
@@ -109,7 +109,7 @@ function spec(name: string, opts: { critical?: boolean; marker?: string; gate?:
|
|
|
109
109
|
name,
|
|
110
110
|
pidFile: join(dir, `${name}.pid`),
|
|
111
111
|
critical: opts.critical ?? false,
|
|
112
|
-
resolve: () => ({ command: name, args: [], env: {}, logFile: join(dir, `${name}.log`), readinessMarker: opts.marker }),
|
|
112
|
+
resolve: () => ({ name, command: name, args: [], env: {}, logFile: join(dir, `${name}.log`), readinessMarker: opts.marker }),
|
|
113
113
|
gate: opts.gate,
|
|
114
114
|
};
|
|
115
115
|
}
|
|
@@ -14,6 +14,8 @@ export interface ExitInfo {
|
|
|
14
14
|
|
|
15
15
|
/** A request to launch one child process. */
|
|
16
16
|
export interface SpawnRequest {
|
|
17
|
+
/** The component's name (the ComponentSpec's), used to label mirrored output. */
|
|
18
|
+
name: string;
|
|
17
19
|
command: string;
|
|
18
20
|
args: string[];
|
|
19
21
|
cwd?: string;
|
package/src/local/paths.ts
CHANGED
|
@@ -47,11 +47,24 @@ export function binDir(home: string = homedir()): string {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* Env var that relocates the acquired-runtimes root. The acquirers (server,
|
|
51
|
+
* runner, seedpack) keep their `<root>/<version>/node_modules/@stigmer/<pkg>`
|
|
52
|
+
* layout under it, so a root that already holds the packages is used as-is
|
|
53
|
+
* and nothing is downloaded. The all-in-one image bakes exactly that layout
|
|
54
|
+
* read-only off its data volume — a `stigmer up` whose first-run acquisitions
|
|
55
|
+
* already happened — and points here.
|
|
53
56
|
*/
|
|
54
|
-
export
|
|
57
|
+
export const RUNTIMES_DIR_ENV = "STIGMER_RUNTIMES_DIR";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Root for on-demand-acquired npm runtimes (~/.stigmer/runtimes, or
|
|
61
|
+
* STIGMER_RUNTIMES_DIR). Each release version installs into its own
|
|
62
|
+
* `<version>/` subtree so multiple CLI versions can coexist and a partial
|
|
63
|
+
* install never poisons another version's runner.
|
|
64
|
+
*/
|
|
65
|
+
export function runtimesDir(home: string = homedir(), env: NodeJS.ProcessEnv = process.env): string {
|
|
66
|
+
const override = env[RUNTIMES_DIR_ENV];
|
|
67
|
+
if (override !== undefined && override !== "") return override;
|
|
55
68
|
return join(configDir(home), "runtimes");
|
|
56
69
|
}
|
|
57
70
|
|
|
@@ -94,22 +94,26 @@ export function resolveRunner(node: () => string = resolveNode): RunnerResolutio
|
|
|
94
94
|
* ~/.stigmer/runtimes/<version>/ (idempotent: reuses a prior install) and resolve
|
|
95
95
|
* its `main.js` entry. The version is pinned to the CLI's own version so the
|
|
96
96
|
* runner's protos/SDK stay in lockstep with the control plane.
|
|
97
|
+
*
|
|
98
|
+
* Presence beats acquirability (the server acquirer's rule, stated there): an
|
|
99
|
+
* installed runtime is used whatever its version string; only an install we
|
|
100
|
+
* would have to perform is refused for a non-release build.
|
|
97
101
|
*/
|
|
98
102
|
export function acquireRunner(opts: EnsureRunnerOptions = {}): RunnerResolution {
|
|
99
103
|
const home = opts.home ?? homedir();
|
|
100
104
|
const version = opts.version ?? VERSION;
|
|
101
|
-
if (!isAcquirableRelease(version)) {
|
|
102
|
-
throw new CliExitError(`cannot acquire ${SLIM_PACKAGE} for a non-release build (${version})`, ExitCode.General, [
|
|
103
|
-
"Run from the repo with a built runner, or set STIGMER_RUNNER_DIR.",
|
|
104
|
-
"On-demand acquisition is only available for published releases.",
|
|
105
|
-
]);
|
|
106
|
-
}
|
|
107
105
|
|
|
108
106
|
const node = opts.node ?? resolveNode;
|
|
109
107
|
const installDir = join(runtimesDir(home), version);
|
|
110
108
|
const entryPath = join(installDir, "node_modules", "@stigmer", "runner-slim", "main.js");
|
|
111
109
|
|
|
112
110
|
if (!existsSync(entryPath)) {
|
|
111
|
+
if (!isAcquirableRelease(version)) {
|
|
112
|
+
throw new CliExitError(`cannot acquire ${SLIM_PACKAGE} for a non-release build (${version})`, ExitCode.General, [
|
|
113
|
+
"Run from the repo with a built runner, or set STIGMER_RUNNER_DIR.",
|
|
114
|
+
"On-demand acquisition is only available for published releases.",
|
|
115
|
+
]);
|
|
116
|
+
}
|
|
113
117
|
log.info(`acquiring ${SLIM_PACKAGE}`, { version, dir: installDir });
|
|
114
118
|
ensureRuntimesRoot(installDir);
|
|
115
119
|
const install = opts.install ?? npmInstallIntoRuntimes;
|
|
@@ -222,4 +222,19 @@ describe("acquireRunner", () => {
|
|
|
222
222
|
/non-release build/,
|
|
223
223
|
);
|
|
224
224
|
});
|
|
225
|
+
|
|
226
|
+
// Presence beats acquirability (the server acquirer's rule; the all-in-one
|
|
227
|
+
// image bakes a dev-stamped runtime npm never published).
|
|
228
|
+
it("uses an already-installed runtime even for a non-release (dev) build", () => {
|
|
229
|
+
const home = tempDir("stigmer-home-");
|
|
230
|
+
const pkgDir = join(home, ".stigmer", "runtimes", "0.0.0-dev.abc123", "node_modules", "@stigmer", "runner-slim");
|
|
231
|
+
mkdirSync(pkgDir, { recursive: true });
|
|
232
|
+
writeFileSync(join(pkgDir, "main.js"), "// slim bundle");
|
|
233
|
+
|
|
234
|
+
const install = vi.fn();
|
|
235
|
+
const resolution = acquireRunner({ home, version: "0.0.0-dev.abc123", node: fakeNode, install });
|
|
236
|
+
|
|
237
|
+
expect(resolution.entryPath).toBe(join(pkgDir, "main.js"));
|
|
238
|
+
expect(install).not.toHaveBeenCalled();
|
|
239
|
+
});
|
|
225
240
|
});
|
|
@@ -22,6 +22,7 @@ const fakeNode = (): string => "/usr/bin/node";
|
|
|
22
22
|
const TOUCHED = [
|
|
23
23
|
"STIGMER_SERVER_DIR",
|
|
24
24
|
"STIGMER_NODE_BIN",
|
|
25
|
+
"STIGMER_RUNTIMES_DIR",
|
|
25
26
|
] as const;
|
|
26
27
|
let saved: Record<string, string | undefined>;
|
|
27
28
|
|
|
@@ -190,6 +191,42 @@ describe("acquireServer", () => {
|
|
|
190
191
|
expect(hints).toMatch(/Remove .* and retry/);
|
|
191
192
|
});
|
|
192
193
|
|
|
194
|
+
// Presence beats acquirability: the all-in-one image bakes a dev-stamped
|
|
195
|
+
// runtime npm never published; an installed runtime is usable whatever its
|
|
196
|
+
// version string, and only an install we would have to perform is refused.
|
|
197
|
+
it("uses an already-installed runtime even for a non-release (dev) build", () => {
|
|
198
|
+
const home = tempDir("stigmer-home-");
|
|
199
|
+
const pkgDir = join(home, ".stigmer", "runtimes", "0.0.0-dev.abc123", "node_modules", "@stigmer", "server-slim");
|
|
200
|
+
mkdirSync(pkgDir, { recursive: true });
|
|
201
|
+
writeFileSync(join(pkgDir, "main.js"), "// slim bundle");
|
|
202
|
+
|
|
203
|
+
const install = vi.fn();
|
|
204
|
+
const launch = acquireServer({ home, version: "0.0.0-dev.abc123", node: fakeNode, install });
|
|
205
|
+
|
|
206
|
+
expect(launch.entryPath).toBe(join(pkgDir, "main.js"));
|
|
207
|
+
expect(install).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// STIGMER_RUNTIMES_DIR relocates the whole acquired-runtimes root; the
|
|
211
|
+
// per-version layout under it is unchanged, so a pre-filled root is found
|
|
212
|
+
// exactly where an on-demand install would have put things.
|
|
213
|
+
it("reads the runtimes root from STIGMER_RUNTIMES_DIR, keeping the per-version layout", () => {
|
|
214
|
+
const home = tempDir("stigmer-home-");
|
|
215
|
+
const root = tempDir("stigmer-runtimes-");
|
|
216
|
+
process.env.STIGMER_RUNTIMES_DIR = root;
|
|
217
|
+
const pkgDir = join(root, "0.5.0", "node_modules", "@stigmer", "server-slim");
|
|
218
|
+
mkdirSync(pkgDir, { recursive: true });
|
|
219
|
+
writeFileSync(join(pkgDir, "main.js"), "// slim bundle");
|
|
220
|
+
|
|
221
|
+
const install = vi.fn();
|
|
222
|
+
const launch = acquireServer({ home, version: "0.5.0", node: fakeNode, install });
|
|
223
|
+
|
|
224
|
+
expect(launch.entryPath).toBe(join(pkgDir, "main.js"));
|
|
225
|
+
expect(launch.appDir).toBe(pkgDir);
|
|
226
|
+
expect(install).not.toHaveBeenCalled();
|
|
227
|
+
expect(readFileSync(join(pkgDir, "main.js"), "utf8")).toBe("// slim bundle");
|
|
228
|
+
});
|
|
229
|
+
|
|
193
230
|
it("refuses to acquire for a non-release (dev) build, naming the fallback", () => {
|
|
194
231
|
const home = tempDir("stigmer-home-");
|
|
195
232
|
const attempt = (): unknown =>
|
|
@@ -97,23 +97,17 @@ export function resolveServerTs(
|
|
|
97
97
|
* the install root with the runner's package) and resolve its `main.js`
|
|
98
98
|
* entry. The version is pinned to the CLI's own version so the server's
|
|
99
99
|
* protos and behavior stay in lockstep with the CLI and the runner.
|
|
100
|
+
*
|
|
101
|
+
* Presence beats acquirability: a runtime that is already installed is used
|
|
102
|
+
* whatever its version string looks like; only an install we would have to
|
|
103
|
+
* perform is refused for a non-release build. (The all-in-one image relies on
|
|
104
|
+
* this: it bakes a dev-stamped runtime that npm never published.)
|
|
100
105
|
*/
|
|
101
106
|
export function acquireServer(opts: EnsureServerOptions = {}): ServerLaunch {
|
|
102
107
|
const home = opts.home ?? homedir();
|
|
103
108
|
const version = opts.version ?? VERSION;
|
|
104
|
-
if (!isAcquirableRelease(version)) {
|
|
105
|
-
throw new CliExitError(
|
|
106
|
-
`cannot acquire ${SLIM_PACKAGE} for a non-release build (${version})`,
|
|
107
|
-
ExitCode.General,
|
|
108
|
-
[
|
|
109
|
-
"Run from the repo with a built server (make build-server), or set",
|
|
110
|
-
"STIGMER_SERVER_DIR to a built server package.",
|
|
111
|
-
"On-demand acquisition is only available for published releases.",
|
|
112
|
-
],
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
109
|
|
|
116
|
-
// Probe the Node capability BEFORE
|
|
110
|
+
// Probe the Node capability BEFORE any download: a user on an FTS5-less
|
|
117
111
|
// Node must not fetch the full slim artifact only to be rejected after.
|
|
118
112
|
const node = opts.node ?? resolveServerNode;
|
|
119
113
|
const nodeBin = node();
|
|
@@ -127,6 +121,17 @@ export function acquireServer(opts: EnsureServerOptions = {}): ServerLaunch {
|
|
|
127
121
|
);
|
|
128
122
|
|
|
129
123
|
if (!existsSync(entryPath)) {
|
|
124
|
+
if (!isAcquirableRelease(version)) {
|
|
125
|
+
throw new CliExitError(
|
|
126
|
+
`cannot acquire ${SLIM_PACKAGE} for a non-release build (${version})`,
|
|
127
|
+
ExitCode.General,
|
|
128
|
+
[
|
|
129
|
+
"Run from the repo with a built server (make build-server), or set",
|
|
130
|
+
"STIGMER_SERVER_DIR to a built server package.",
|
|
131
|
+
"On-demand acquisition is only available for published releases.",
|
|
132
|
+
],
|
|
133
|
+
);
|
|
134
|
+
}
|
|
130
135
|
log.info(`acquiring ${SLIM_PACKAGE}`, { version, dir: installDir });
|
|
131
136
|
ensureRuntimesRoot(installDir);
|
|
132
137
|
const install = opts.install ?? npmInstallIntoRuntimes;
|
|
@@ -126,6 +126,27 @@ describe("acquireSeedpack", () => {
|
|
|
126
126
|
expect(classify(err)?.exitCode).toBe(ExitCode.General);
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
// Presence beats acquirability (the server acquirer's rule; the all-in-one
|
|
130
|
+
// image bakes dev-stamped content npm never published).
|
|
131
|
+
it("uses already-installed content even for a non-release (dev) build", () => {
|
|
132
|
+
const home = mkdtempSync(join(tmpdir(), "seedpack-home-"));
|
|
133
|
+
const pkgDir = join(home, ".stigmer", "runtimes", "0.0.0-dev.abc123", "node_modules", "@stigmer", "seedpack");
|
|
134
|
+
mkdirSync(pkgDir, { recursive: true });
|
|
135
|
+
writeFileSync(join(pkgDir, "stigmer.yaml"), "kind: Organization\n");
|
|
136
|
+
|
|
137
|
+
let installs = 0;
|
|
138
|
+
const dir = acquireSeedpack({
|
|
139
|
+
home,
|
|
140
|
+
version: "0.0.0-dev.abc123",
|
|
141
|
+
install: () => {
|
|
142
|
+
installs += 1;
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(dir).toBe(pkgDir);
|
|
147
|
+
expect(installs).toBe(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
129
150
|
it("installs on demand and is idempotent on the second call", () => {
|
|
130
151
|
const home = mkdtempSync(join(tmpdir(), "seedpack-home-"));
|
|
131
152
|
let installs = 0;
|
|
@@ -36,6 +36,7 @@ import { ExitCode } from "../../errors/exit-codes.js";
|
|
|
36
36
|
import { log } from "../../logger.js";
|
|
37
37
|
import { VERSION } from "../../version.js";
|
|
38
38
|
import { runtimesDir } from "../paths.js";
|
|
39
|
+
import { isAcquirableRelease } from "../runtime/runtimes-install.js";
|
|
39
40
|
|
|
40
41
|
const SEEDPACK_PACKAGE = "@stigmer/seedpack";
|
|
41
42
|
|
|
@@ -91,26 +92,30 @@ export function resolveSeedpackContent(
|
|
|
91
92
|
* ~/.stigmer/runtimes/<version>/ (idempotent) and return its package directory.
|
|
92
93
|
* The version is pinned to the CLI's own version so system content stays in
|
|
93
94
|
* lockstep with the control plane it bootstraps.
|
|
95
|
+
*
|
|
96
|
+
* Presence beats acquirability (the server acquirer's rule, stated in
|
|
97
|
+
* runtime/server.ts): installed content is used whatever its version string;
|
|
98
|
+
* only an install we would have to perform is refused for a non-release build.
|
|
94
99
|
*/
|
|
95
100
|
export function acquireSeedpack(opts: ResolveSeedpackOptions = {}): string {
|
|
96
101
|
const home = opts.home ?? homedir();
|
|
97
102
|
const version = opts.version ?? VERSION;
|
|
98
|
-
if (!isAcquirableRelease(version)) {
|
|
99
|
-
throw new CliExitError(
|
|
100
|
-
`cannot acquire ${SEEDPACK_PACKAGE} for a non-release build (${version})`,
|
|
101
|
-
ExitCode.General,
|
|
102
|
-
[
|
|
103
|
-
"Run from the repo (the seedpack/ tree is used directly in dev).",
|
|
104
|
-
"On-demand acquisition is only available for published releases.",
|
|
105
|
-
],
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
103
|
|
|
109
104
|
const installDir = join(runtimesDir(home), version);
|
|
110
105
|
const pkgDir = join(installDir, "node_modules", "@stigmer", "seedpack");
|
|
111
106
|
const marker = join(pkgDir, "stigmer.yaml");
|
|
112
107
|
|
|
113
108
|
if (!existsSync(marker)) {
|
|
109
|
+
if (!isAcquirableRelease(version)) {
|
|
110
|
+
throw new CliExitError(
|
|
111
|
+
`cannot acquire ${SEEDPACK_PACKAGE} for a non-release build (${version})`,
|
|
112
|
+
ExitCode.General,
|
|
113
|
+
[
|
|
114
|
+
"Run from the repo (the seedpack/ tree is used directly in dev).",
|
|
115
|
+
"On-demand acquisition is only available for published releases.",
|
|
116
|
+
],
|
|
117
|
+
);
|
|
118
|
+
}
|
|
114
119
|
log.info(`acquiring ${SEEDPACK_PACKAGE}`, { version, dir: installDir });
|
|
115
120
|
mkdirSync(installDir, { recursive: true });
|
|
116
121
|
// A stable package.json root keeps the install deterministic. npm install is
|
|
@@ -234,12 +239,6 @@ function toPosix(p: string): string {
|
|
|
234
239
|
return sep === posix.sep ? p : p.split(sep).join(posix.sep);
|
|
235
240
|
}
|
|
236
241
|
|
|
237
|
-
// A source build reports "0.0.0-dev" and the dev channel stamps "<v>-dev.<stamp>";
|
|
238
|
-
// neither publishes a matching @stigmer/seedpack, so they are not acquirable.
|
|
239
|
-
function isAcquirableRelease(version: string): boolean {
|
|
240
|
-
return !version.includes("-dev");
|
|
241
|
-
}
|
|
242
|
-
|
|
243
242
|
// Walk up from this module to a repo root containing seedpack/stigmer.yaml.
|
|
244
243
|
function repoSeedpackDir(): string | null {
|
|
245
244
|
let dir = dirname(fileURLToPath(import.meta.url));
|
package/src/local/state/lock.ts
CHANGED
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
// lock and whose contents are the owning PID. Creation uses the "wx" flag
|
|
5
5
|
// (O_CREAT | O_EXCL), which is atomic on POSIX and Windows, so two racing
|
|
6
6
|
// acquirers cannot both win. If the file already exists we read the owner PID:
|
|
7
|
-
// a
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// a live peer means the lock is genuinely held; a dead owner means a stale
|
|
8
|
+
// lock we may reclaim; an owner PID that is OUR OWN is held only if this
|
|
9
|
+
// process is actually holding it (tracked in-process), otherwise it is a
|
|
10
|
+
// leftover from a previous life of the PID (see isOtherLiveProcess) and stale.
|
|
11
|
+
// This guards the Temporal dev server against double-start the way the Go
|
|
12
|
+
// CLI's flock does, without a native dependency.
|
|
10
13
|
|
|
11
14
|
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
15
|
import { dirname } from "node:path";
|
|
13
|
-
import {
|
|
16
|
+
import { isOtherLiveProcess } from "./proc.js";
|
|
14
17
|
|
|
15
18
|
/** A held lock. Call release() exactly once when done. */
|
|
16
19
|
export interface FileLock {
|
|
@@ -18,20 +21,30 @@ export interface FileLock {
|
|
|
18
21
|
release(): void;
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
// Lock paths this process currently holds. A lock file naming OUR pid is
|
|
25
|
+
// genuinely held only if it is in here; otherwise it is a leftover from a
|
|
26
|
+
// previous life of the pid (a restarted container, a rebooted laptop) and is
|
|
27
|
+
// reclaimed like any other stale lock.
|
|
28
|
+
const heldInProcess = new Set<string>();
|
|
29
|
+
|
|
21
30
|
/**
|
|
22
31
|
* Try to acquire the lock at `path`. Returns a FileLock on success, or null if
|
|
23
|
-
* the lock is held by a live process
|
|
24
|
-
*
|
|
32
|
+
* the lock is held by a live process (this one included — a second acquire
|
|
33
|
+
* while we hold it fails). A lock left behind by a dead process, or by a past
|
|
34
|
+
* process that had our pid, is reclaimed automatically.
|
|
25
35
|
*/
|
|
26
36
|
export function acquireLock(path: string): FileLock | null {
|
|
27
37
|
mkdirSync(dirname(path), { recursive: true });
|
|
28
38
|
|
|
39
|
+
if (heldInProcess.has(path)) return null; // held right here, by us
|
|
40
|
+
|
|
29
41
|
if (tryCreate(path)) return makeLock(path);
|
|
30
42
|
|
|
31
|
-
// The lock file exists. Reclaim it only if its owner is gone
|
|
43
|
+
// The lock file exists. Reclaim it only if its owner is gone, or is our own
|
|
44
|
+
// pid from a previous life (we just established we are not holding it).
|
|
32
45
|
const owner = readOwner(path);
|
|
33
|
-
if (owner !== null &&
|
|
34
|
-
return null; // genuinely held by a live
|
|
46
|
+
if (owner !== null && isOtherLiveProcess(owner)) {
|
|
47
|
+
return null; // genuinely held by a live peer
|
|
35
48
|
}
|
|
36
49
|
|
|
37
50
|
// Stale (or unreadable) lock: remove and retry exactly once. A failure on the
|
|
@@ -63,12 +76,14 @@ function readOwner(path: string): number | null {
|
|
|
63
76
|
}
|
|
64
77
|
|
|
65
78
|
function makeLock(path: string): FileLock {
|
|
79
|
+
heldInProcess.add(path);
|
|
66
80
|
let released = false;
|
|
67
81
|
return {
|
|
68
82
|
path,
|
|
69
83
|
release(): void {
|
|
70
84
|
if (released) return;
|
|
71
85
|
released = true;
|
|
86
|
+
heldInProcess.delete(path);
|
|
72
87
|
// Only remove the file if we still own it, so we never delete a lock a
|
|
73
88
|
// different process acquired after ours was (somehow) gone.
|
|
74
89
|
if (readOwner(path) === process.pid) rmSync(path, { force: true });
|
package/src/local/state/proc.ts
CHANGED
|
@@ -19,6 +19,22 @@ export function isProcessAlive(pid: number): boolean {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Report whether `pid` names a live process OTHER than this one — the question
|
|
24
|
+
* every stale-state reader (a PID file, a lock file's owner) actually asks.
|
|
25
|
+
*
|
|
26
|
+
* A recorded PID equal to our own can never be a live peer: we did not write it
|
|
27
|
+
* in this life, so it is a leftover from a previous process that happened to
|
|
28
|
+
* get the same number. That happens deterministically in a container (every
|
|
29
|
+
* start is a fresh PID namespace, so the daemon lands on the same low PID it
|
|
30
|
+
* had before the unclean stop) and occasionally on a laptop after a reboot.
|
|
31
|
+
* Treating such a PID as "alive" makes a launcher SIGTERM itself or refuse a
|
|
32
|
+
* lock it could never have been holding.
|
|
33
|
+
*/
|
|
34
|
+
export function isOtherLiveProcess(pid: number): boolean {
|
|
35
|
+
return pid !== process.pid && isProcessAlive(pid);
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
/** Send a signal to a single process. Returns false if the process is gone. */
|
|
23
39
|
export function killProcess(pid: number, signal: NodeJS.Signals = "SIGTERM"): boolean {
|
|
24
40
|
try {
|
|
@@ -6,7 +6,7 @@ import { type HealthState, loadHealthState, writeHealthState } from "./health-st
|
|
|
6
6
|
import { acquireLock } from "./lock.js";
|
|
7
7
|
import { cleanupOldLogs, rotateLogs } from "./log-rotation.js";
|
|
8
8
|
import { readPidFile, removePidFile, writePidFile } from "./pidfile.js";
|
|
9
|
-
import { isProcessAlive } from "./proc.js";
|
|
9
|
+
import { isOtherLiveProcess, isProcessAlive } from "./proc.js";
|
|
10
10
|
import { loadStartupConfig, removeStartupConfig, saveStartupConfig, type StartupConfig } from "./startup-config.js";
|
|
11
11
|
|
|
12
12
|
function tempDir(prefix: string): string {
|
|
@@ -46,6 +46,17 @@ describe("isProcessAlive", () => {
|
|
|
46
46
|
it("is true for the current process and false for a dead PID", () => {
|
|
47
47
|
expect(isProcessAlive(process.pid)).toBe(true);
|
|
48
48
|
expect(isProcessAlive(DEAD_PID)).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("isOtherLiveProcess", () => {
|
|
53
|
+
// A recorded PID equal to our own is a leftover from a previous life of this
|
|
54
|
+
// PID (a restarted container's fresh PID namespace, a laptop after reboot),
|
|
55
|
+
// never a live peer — the distinction every stale-state reader needs.
|
|
56
|
+
it("is false for the current process, true for its parent, false for a dead PID", () => {
|
|
57
|
+
expect(isOtherLiveProcess(process.pid)).toBe(false);
|
|
58
|
+
expect(isOtherLiveProcess(process.ppid)).toBe(true);
|
|
59
|
+
expect(isOtherLiveProcess(DEAD_PID)).toBe(false);
|
|
49
60
|
expect(isProcessAlive(0)).toBe(false);
|
|
50
61
|
expect(isProcessAlive(-1)).toBe(false);
|
|
51
62
|
});
|
|
@@ -176,6 +187,19 @@ describe("lock", () => {
|
|
|
176
187
|
expect(readFileSync(path, "utf8").trim()).toBe(String(process.pid));
|
|
177
188
|
});
|
|
178
189
|
|
|
190
|
+
// The container case: after an unclean stop the lock file on the volume names
|
|
191
|
+
// the PID the daemon had last time — and a fresh PID namespace hands the
|
|
192
|
+
// restarted daemon that same PID. We are not holding a lock we are trying to
|
|
193
|
+
// acquire, so an owner equal to ourselves is stale by construction.
|
|
194
|
+
it("reclaims a lock whose recorded owner is this very process (a previous life of the PID)", () => {
|
|
195
|
+
const path = join(tempDir("stigmer-lock-"), "temporal.lock");
|
|
196
|
+
writeFileSync(path, `${process.pid}\n`);
|
|
197
|
+
const lock = acquireLock(path);
|
|
198
|
+
expect(lock).not.toBeNull();
|
|
199
|
+
lock?.release();
|
|
200
|
+
expect(acquireLock(path)).not.toBeNull();
|
|
201
|
+
});
|
|
202
|
+
|
|
179
203
|
it("release is idempotent and only removes our own lock", () => {
|
|
180
204
|
const path = join(tempDir("stigmer-lock-"), "temporal.lock");
|
|
181
205
|
const lock = acquireLock(path);
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { gzipSync } from "fflate";
|
|
5
6
|
import { describe, expect, it } from "vitest";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
TEMPORAL_CHECKSUMS_FILE,
|
|
9
|
+
downloadTemporalCli,
|
|
10
|
+
extractTarEntry,
|
|
11
|
+
temporalArchiveName,
|
|
12
|
+
temporalReleaseAssetUrl,
|
|
13
|
+
} from "./download.js";
|
|
7
14
|
|
|
8
15
|
// Build a minimal ustar archive containing a single regular-file entry. Only
|
|
9
16
|
// the fields the reader uses (name, octal size, type flag) are populated.
|
|
@@ -40,24 +47,85 @@ describe("extractTarEntry", () => {
|
|
|
40
47
|
});
|
|
41
48
|
});
|
|
42
49
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
// A release as GitHub serves it: the archive for the requested platform plus a
|
|
51
|
+
// release-wide checksums.txt listing every asset. `fetchImpl` routes by URL so
|
|
52
|
+
// the checksum arm is exercised against a realistic multi-line file.
|
|
53
|
+
function release(version: string, archives: Record<string, Buffer>, checksums: string): typeof fetch {
|
|
54
|
+
return (async (input: string | URL | Request) => {
|
|
55
|
+
const url = String(input);
|
|
56
|
+
const name = url.slice(url.lastIndexOf("/") + 1);
|
|
57
|
+
if (name === TEMPORAL_CHECKSUMS_FILE) {
|
|
58
|
+
return { ok: true, status: 200, text: async () => checksums } as unknown as Response;
|
|
59
|
+
}
|
|
60
|
+
const gz = archives[name];
|
|
61
|
+
if (gz === undefined) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) } as unknown as Response;
|
|
62
|
+
return {
|
|
48
63
|
ok: true,
|
|
49
64
|
status: 200,
|
|
50
65
|
arrayBuffer: async () => gz.buffer.slice(gz.byteOffset, gz.byteOffset + gz.byteLength),
|
|
51
|
-
}
|
|
66
|
+
} as unknown as Response;
|
|
67
|
+
}) as unknown as typeof fetch;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function archiveFor(payload: Buffer): Buffer {
|
|
71
|
+
return Buffer.from(gzipSync(new Uint8Array(makeTar("temporal", payload))));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sha256(bytes: Buffer): string {
|
|
75
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("downloadTemporalCli", () => {
|
|
79
|
+
const linuxArm = temporalArchiveName("1.5.1", "linux", "arm64");
|
|
80
|
+
const linuxAmd = temporalArchiveName("1.5.1", "linux", "x64");
|
|
81
|
+
|
|
82
|
+
it("names the release assets the way Temporal publishes them", () => {
|
|
83
|
+
expect(linuxArm).toBe("temporal_cli_1.5.1_linux_arm64.tar.gz");
|
|
84
|
+
expect(linuxAmd).toBe("temporal_cli_1.5.1_linux_amd64.tar.gz");
|
|
85
|
+
expect(temporalArchiveName("1.5.1", "darwin", "arm64")).toBe("temporal_cli_1.5.1_darwin_arm64.tar.gz");
|
|
86
|
+
expect(temporalReleaseAssetUrl("1.5.1", TEMPORAL_CHECKSUMS_FILE)).toBe(
|
|
87
|
+
"https://github.com/temporalio/cli/releases/download/v1.5.1/checksums.txt",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("downloads, verifies against the matching checksums.txt line, gunzips, untars, and writes the binary", async () => {
|
|
92
|
+
const arm = archiveFor(Buffer.from("the-arm64-binary"));
|
|
93
|
+
const amd = archiveFor(Buffer.from("the-amd64-binary"));
|
|
94
|
+
// amd64 listed FIRST: a first-token parser would verify arm64 against it.
|
|
95
|
+
const checksums = `${sha256(amd)} ${linuxAmd}\n${sha256(arm)} ${linuxArm}\n`;
|
|
96
|
+
const fetchImpl = release("1.5.1", { [linuxArm]: arm, [linuxAmd]: amd }, checksums);
|
|
52
97
|
|
|
53
98
|
const binPath = join(mkdtempSync(join(tmpdir(), "stigmer-temporal-")), "bin", "temporal");
|
|
54
|
-
await downloadTemporalCli({ version: "1.5.1", binPath, fetchImpl });
|
|
99
|
+
await downloadTemporalCli({ version: "1.5.1", binPath, platform: "linux", arch: "arm64", fetchImpl });
|
|
100
|
+
|
|
101
|
+
expect(readFileSync(binPath, "utf8")).toBe("the-arm64-binary");
|
|
102
|
+
});
|
|
55
103
|
|
|
56
|
-
|
|
104
|
+
it("refuses an archive whose digest does not match its checksums.txt line", async () => {
|
|
105
|
+
const arm = archiveFor(Buffer.from("tampered"));
|
|
106
|
+
const checksums = `${"0".repeat(64)} ${linuxArm}\n`;
|
|
107
|
+
const fetchImpl = release("1.5.1", { [linuxArm]: arm }, checksums);
|
|
108
|
+
|
|
109
|
+
const binPath = join(mkdtempSync(join(tmpdir(), "stigmer-temporal-")), "temporal");
|
|
110
|
+
await expect(
|
|
111
|
+
downloadTemporalCli({ version: "1.5.1", binPath, platform: "linux", arch: "arm64", fetchImpl }),
|
|
112
|
+
).rejects.toThrow(/checksum mismatch/);
|
|
113
|
+
expect(existsSync(binPath)).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("refuses an archive the checksums.txt has no entry for", async () => {
|
|
117
|
+
const arm = archiveFor(Buffer.from("unlisted"));
|
|
118
|
+
const checksums = `${sha256(arm)} some_other_asset.tar.gz\n`;
|
|
119
|
+
const fetchImpl = release("1.5.1", { [linuxArm]: arm }, checksums);
|
|
120
|
+
|
|
121
|
+
const binPath = join(mkdtempSync(join(tmpdir(), "stigmer-temporal-")), "temporal");
|
|
122
|
+
await expect(
|
|
123
|
+
downloadTemporalCli({ version: "1.5.1", binPath, platform: "linux", arch: "arm64", fetchImpl }),
|
|
124
|
+
).rejects.toThrow(/checksum mismatch/);
|
|
57
125
|
});
|
|
58
126
|
|
|
59
127
|
it("fails clearly on a non-OK response", async () => {
|
|
60
|
-
const fetchImpl = (
|
|
128
|
+
const fetchImpl = release("9.9.9", {}, "");
|
|
61
129
|
const binPath = join(mkdtempSync(join(tmpdir(), "stigmer-temporal-")), "temporal");
|
|
62
130
|
await expect(downloadTemporalCli({ version: "9.9.9", binPath, fetchImpl })).rejects.toThrow(/HTTP 404/);
|
|
63
131
|
});
|