@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.
Files changed (138) hide show
  1. package/client/client.d.ts +20 -1
  2. package/client/client.d.ts.map +1 -1
  3. package/client/client.js +32 -5
  4. package/client/client.js.map +1 -1
  5. package/commands/auth/index.d.ts.map +1 -1
  6. package/commands/auth/index.js +7 -36
  7. package/commands/auth/index.js.map +1 -1
  8. package/commands/auth/login.d.ts +13 -0
  9. package/commands/auth/login.d.ts.map +1 -0
  10. package/commands/auth/login.js +58 -0
  11. package/commands/auth/login.js.map +1 -0
  12. package/commands/auth/whoami.d.ts +18 -0
  13. package/commands/auth/whoami.d.ts.map +1 -0
  14. package/commands/auth/whoami.js +58 -0
  15. package/commands/auth/whoami.js.map +1 -0
  16. package/commands/draft.d.ts.map +1 -1
  17. package/commands/draft.js +24 -8
  18. package/commands/draft.js.map +1 -1
  19. package/commands/internal-daemon.d.ts.map +1 -1
  20. package/commands/internal-daemon.js +2 -9
  21. package/commands/internal-daemon.js.map +1 -1
  22. package/commands/run.d.ts.map +1 -1
  23. package/commands/run.js +15 -6
  24. package/commands/run.js.map +1 -1
  25. package/commands/up.d.ts.map +1 -1
  26. package/commands/up.js +37 -8
  27. package/commands/up.js.map +1 -1
  28. package/local/artifact.d.ts +13 -3
  29. package/local/artifact.d.ts.map +1 -1
  30. package/local/artifact.js +31 -10
  31. package/local/artifact.js.map +1 -1
  32. package/local/daemon/components.d.ts.map +1 -1
  33. package/local/daemon/components.js +2 -0
  34. package/local/daemon/components.js.map +1 -1
  35. package/local/daemon/host.d.ts +8 -0
  36. package/local/daemon/host.d.ts.map +1 -1
  37. package/local/daemon/host.js +42 -1
  38. package/local/daemon/host.js.map +1 -1
  39. package/local/daemon/index.d.ts +3 -3
  40. package/local/daemon/index.d.ts.map +1 -1
  41. package/local/daemon/index.js +2 -2
  42. package/local/daemon/index.js.map +1 -1
  43. package/local/daemon/launch.d.ts +29 -1
  44. package/local/daemon/launch.d.ts.map +1 -1
  45. package/local/daemon/launch.js +73 -23
  46. package/local/daemon/launch.js.map +1 -1
  47. package/local/daemon/process.d.ts +16 -2
  48. package/local/daemon/process.d.ts.map +1 -1
  49. package/local/daemon/process.js +16 -2
  50. package/local/daemon/process.js.map +1 -1
  51. package/local/daemon/types.d.ts +2 -0
  52. package/local/daemon/types.d.ts.map +1 -1
  53. package/local/paths.d.ts +14 -4
  54. package/local/paths.d.ts.map +1 -1
  55. package/local/paths.js +17 -4
  56. package/local/paths.js.map +1 -1
  57. package/local/runtime/runner.d.ts +4 -0
  58. package/local/runtime/runner.d.ts.map +1 -1
  59. package/local/runtime/runner.js +10 -6
  60. package/local/runtime/runner.js.map +1 -1
  61. package/local/runtime/server.d.ts +5 -0
  62. package/local/runtime/server.d.ts.map +1 -1
  63. package/local/runtime/server.js +13 -8
  64. package/local/runtime/server.js.map +1 -1
  65. package/local/seedpack/content.d.ts +4 -0
  66. package/local/seedpack/content.d.ts.map +1 -1
  67. package/local/seedpack/content.js +11 -11
  68. package/local/seedpack/content.js.map +1 -1
  69. package/local/state/lock.d.ts +3 -2
  70. package/local/state/lock.d.ts.map +1 -1
  71. package/local/state/lock.js +23 -9
  72. package/local/state/lock.js.map +1 -1
  73. package/local/state/proc.d.ts +13 -0
  74. package/local/state/proc.d.ts.map +1 -1
  75. package/local/state/proc.js +15 -0
  76. package/local/state/proc.js.map +1 -1
  77. package/local/temporal/download.d.ts +7 -1
  78. package/local/temporal/download.d.ts.map +1 -1
  79. package/local/temporal/download.js +21 -11
  80. package/local/temporal/download.js.map +1 -1
  81. package/local/temporal/index.d.ts +3 -3
  82. package/local/temporal/index.d.ts.map +1 -1
  83. package/local/temporal/index.js +2 -2
  84. package/local/temporal/index.js.map +1 -1
  85. package/local/temporal/inspect.d.ts +10 -3
  86. package/local/temporal/inspect.d.ts.map +1 -1
  87. package/local/temporal/inspect.js +52 -13
  88. package/local/temporal/inspect.js.map +1 -1
  89. package/local/temporal/manager.d.ts +16 -4
  90. package/local/temporal/manager.d.ts.map +1 -1
  91. package/local/temporal/manager.js +19 -6
  92. package/local/temporal/manager.js.map +1 -1
  93. package/package.json +5 -5
  94. package/resources/run/prepare.d.ts +10 -8
  95. package/resources/run/prepare.d.ts.map +1 -1
  96. package/resources/run/prepare.js +18 -4
  97. package/resources/run/prepare.js.map +1 -1
  98. package/src/client/client.test.ts +89 -1
  99. package/src/client/client.ts +82 -9
  100. package/src/commands/auth/index.ts +7 -56
  101. package/src/commands/auth/login.test.ts +52 -0
  102. package/src/commands/auth/login.ts +70 -0
  103. package/src/commands/auth/whoami.test.ts +96 -0
  104. package/src/commands/auth/whoami.ts +87 -0
  105. package/src/commands/draft.ts +85 -26
  106. package/src/commands/internal-daemon.ts +2 -10
  107. package/src/commands/run.ts +77 -35
  108. package/src/commands/up.ts +52 -8
  109. package/src/local/artifact.test.ts +27 -1
  110. package/src/local/artifact.ts +35 -13
  111. package/src/local/daemon/components.ts +2 -0
  112. package/src/local/daemon/daemon.integration.test.ts +33 -0
  113. package/src/local/daemon/host.ts +50 -1
  114. package/src/local/daemon/index.ts +3 -3
  115. package/src/local/daemon/launch.test.ts +136 -0
  116. package/src/local/daemon/launch.ts +108 -27
  117. package/src/local/daemon/process.ts +31 -4
  118. package/src/local/daemon/supervisor.test.ts +1 -1
  119. package/src/local/daemon/types.ts +2 -0
  120. package/src/local/paths.ts +17 -4
  121. package/src/local/runtime/runner.ts +10 -6
  122. package/src/local/runtime/runtime.test.ts +15 -0
  123. package/src/local/runtime/server.test.ts +37 -0
  124. package/src/local/runtime/server.ts +17 -12
  125. package/src/local/seedpack/content.test.ts +21 -0
  126. package/src/local/seedpack/content.ts +15 -16
  127. package/src/local/state/lock.ts +24 -9
  128. package/src/local/state/proc.ts +16 -0
  129. package/src/local/state/state.test.ts +25 -1
  130. package/src/local/temporal/download.test.ts +79 -11
  131. package/src/local/temporal/download.ts +24 -11
  132. package/src/local/temporal/index.ts +11 -3
  133. package/src/local/temporal/inspect.test.ts +83 -0
  134. package/src/local/temporal/inspect.ts +60 -12
  135. package/src/local/temporal/manager.test.ts +34 -1
  136. package/src/local/temporal/manager.ts +22 -8
  137. package/src/resources/run/prepare.test.ts +28 -26
  138. 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
- /** Invoked once the stack is up and the monitor is running (tests). */
46
- onStarted?: () => void;
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), { host, clock, healthState, persist, log });
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;
@@ -47,11 +47,24 @@ export function binDir(home: string = homedir()): string {
47
47
  }
48
48
 
49
49
  /**
50
- * Root for on-demand-acquired npm runtimes (~/.stigmer/runtimes). Each release
51
- * version installs into its own `<version>/` subtree so multiple CLI versions can
52
- * coexist and a partial install never poisons another version's runner.
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 function runtimesDir(home: string = homedir()): string {
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 the download: a user on an FTS5-less
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));
@@ -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 dead owner means a stale lock we may reclaim; a live owner means the lock
8
- // is genuinely held. This guards the Temporal dev server against double-start
9
- // the way the Go CLI's flock does, without a native dependency.
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 { isProcessAlive } from "./proc.js";
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. A lock left behind by a dead process is
24
- * reclaimed automatically.
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 && isProcessAlive(owner)) {
34
- return null; // genuinely held by a live process
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 });
@@ -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 { mkdtempSync, readFileSync } from "node:fs";
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 { downloadTemporalCli, extractTarEntry } from "./download.js";
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
- describe("downloadTemporalCli", () => {
44
- it("downloads, gunzips, untars, and writes an executable binary", async () => {
45
- const payload = Buffer.from("the-temporal-binary");
46
- const gz = gzipSync(new Uint8Array(makeTar("temporal", payload)));
47
- const fetchImpl = (async () => ({
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
- })) as unknown as typeof fetch;
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
- expect(readFileSync(binPath, "utf8")).toBe("the-temporal-binary");
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 = (async () => ({ ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) })) as unknown as typeof fetch;
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
  });