@stigmer/cli 3.15.0 → 3.15.2-dev.20260913223433

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 (102) hide show
  1. package/commands/internal-daemon.d.ts.map +1 -1
  2. package/commands/internal-daemon.js +2 -9
  3. package/commands/internal-daemon.js.map +1 -1
  4. package/commands/up.d.ts.map +1 -1
  5. package/commands/up.js +37 -8
  6. package/commands/up.js.map +1 -1
  7. package/local/artifact.d.ts +13 -3
  8. package/local/artifact.d.ts.map +1 -1
  9. package/local/artifact.js +31 -10
  10. package/local/artifact.js.map +1 -1
  11. package/local/daemon/components.d.ts.map +1 -1
  12. package/local/daemon/components.js +2 -0
  13. package/local/daemon/components.js.map +1 -1
  14. package/local/daemon/host.d.ts +8 -0
  15. package/local/daemon/host.d.ts.map +1 -1
  16. package/local/daemon/host.js +42 -1
  17. package/local/daemon/host.js.map +1 -1
  18. package/local/daemon/index.d.ts +3 -3
  19. package/local/daemon/index.d.ts.map +1 -1
  20. package/local/daemon/index.js +2 -2
  21. package/local/daemon/index.js.map +1 -1
  22. package/local/daemon/launch.d.ts +29 -1
  23. package/local/daemon/launch.d.ts.map +1 -1
  24. package/local/daemon/launch.js +73 -23
  25. package/local/daemon/launch.js.map +1 -1
  26. package/local/daemon/process.d.ts +16 -2
  27. package/local/daemon/process.d.ts.map +1 -1
  28. package/local/daemon/process.js +16 -2
  29. package/local/daemon/process.js.map +1 -1
  30. package/local/daemon/types.d.ts +2 -0
  31. package/local/daemon/types.d.ts.map +1 -1
  32. package/local/paths.d.ts +14 -4
  33. package/local/paths.d.ts.map +1 -1
  34. package/local/paths.js +17 -4
  35. package/local/paths.js.map +1 -1
  36. package/local/runtime/runner.d.ts +4 -0
  37. package/local/runtime/runner.d.ts.map +1 -1
  38. package/local/runtime/runner.js +10 -6
  39. package/local/runtime/runner.js.map +1 -1
  40. package/local/runtime/server.d.ts +5 -0
  41. package/local/runtime/server.d.ts.map +1 -1
  42. package/local/runtime/server.js +13 -8
  43. package/local/runtime/server.js.map +1 -1
  44. package/local/seedpack/content.d.ts +4 -0
  45. package/local/seedpack/content.d.ts.map +1 -1
  46. package/local/seedpack/content.js +11 -11
  47. package/local/seedpack/content.js.map +1 -1
  48. package/local/state/lock.d.ts +3 -2
  49. package/local/state/lock.d.ts.map +1 -1
  50. package/local/state/lock.js +23 -9
  51. package/local/state/lock.js.map +1 -1
  52. package/local/state/proc.d.ts +13 -0
  53. package/local/state/proc.d.ts.map +1 -1
  54. package/local/state/proc.js +15 -0
  55. package/local/state/proc.js.map +1 -1
  56. package/local/temporal/download.d.ts +7 -1
  57. package/local/temporal/download.d.ts.map +1 -1
  58. package/local/temporal/download.js +21 -11
  59. package/local/temporal/download.js.map +1 -1
  60. package/local/temporal/index.d.ts +3 -3
  61. package/local/temporal/index.d.ts.map +1 -1
  62. package/local/temporal/index.js +2 -2
  63. package/local/temporal/index.js.map +1 -1
  64. package/local/temporal/inspect.d.ts +10 -3
  65. package/local/temporal/inspect.d.ts.map +1 -1
  66. package/local/temporal/inspect.js +52 -13
  67. package/local/temporal/inspect.js.map +1 -1
  68. package/local/temporal/manager.d.ts +16 -4
  69. package/local/temporal/manager.d.ts.map +1 -1
  70. package/local/temporal/manager.js +19 -6
  71. package/local/temporal/manager.js.map +1 -1
  72. package/package.json +5 -5
  73. package/src/commands/internal-daemon.ts +2 -10
  74. package/src/commands/up.ts +52 -8
  75. package/src/local/artifact.test.ts +27 -1
  76. package/src/local/artifact.ts +35 -13
  77. package/src/local/daemon/components.ts +2 -0
  78. package/src/local/daemon/daemon.integration.test.ts +33 -0
  79. package/src/local/daemon/host.ts +50 -1
  80. package/src/local/daemon/index.ts +3 -3
  81. package/src/local/daemon/launch.test.ts +136 -0
  82. package/src/local/daemon/launch.ts +108 -27
  83. package/src/local/daemon/process.ts +31 -4
  84. package/src/local/daemon/supervisor.test.ts +1 -1
  85. package/src/local/daemon/types.ts +2 -0
  86. package/src/local/paths.ts +17 -4
  87. package/src/local/runtime/runner.ts +10 -6
  88. package/src/local/runtime/runtime.test.ts +15 -0
  89. package/src/local/runtime/server.test.ts +37 -0
  90. package/src/local/runtime/server.ts +17 -12
  91. package/src/local/seedpack/content.test.ts +21 -0
  92. package/src/local/seedpack/content.ts +15 -16
  93. package/src/local/state/lock.ts +24 -9
  94. package/src/local/state/proc.ts +16 -0
  95. package/src/local/state/state.test.ts +25 -1
  96. package/src/local/temporal/download.test.ts +79 -11
  97. package/src/local/temporal/download.ts +24 -11
  98. package/src/local/temporal/index.ts +11 -3
  99. package/src/local/temporal/inspect.test.ts +83 -0
  100. package/src/local/temporal/inspect.ts +60 -12
  101. package/src/local/temporal/manager.test.ts +34 -1
  102. package/src/local/temporal/manager.ts +22 -8
@@ -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
  });
@@ -2,12 +2,14 @@
2
2
  //
3
3
  // `up` needs a `temporal` binary and the upstream release is a small, public,
4
4
  // stable artifact. The fetch/gunzip/untar/write mechanics are shared with the
5
- // stigmer-server downloader in `../artifact.ts`; this module only owns Temporal's
6
- // asset-naming and the default version.
5
+ // other release downloaders in `../artifact.ts`; this module only owns
6
+ // Temporal's asset-naming and the default version.
7
7
  //
8
- // Parity note: like the Go CLI, this does not verify a checksum (the stigmer
9
- // -server downloader does, per DD-003). A checksum-hardening follow-up is filed;
10
- // until then the integrity guarantee is HTTPS + GitHub's release immutability.
8
+ // Every download is verified against the release's `checksums.txt` (the
9
+ // `sha256sum` file Temporal publishes beside its archives) before the binary is
10
+ // written: the integrity guarantee is the published digest, not merely HTTPS
11
+ // plus GitHub's release immutability. The same code path stages the binary the
12
+ // all-in-one image bakes, so one pin and one verification serve every channel.
11
13
 
12
14
  import { existsSync } from "node:fs";
13
15
  import { fetchTarballBinary, mapReleaseArch, mapReleaseOs } from "../artifact.js";
@@ -19,6 +21,9 @@ export { extractTarEntry } from "../artifact.js";
19
21
  /** Default Temporal CLI version (matches the Go CLI's `DefaultTemporalVersion`). */
20
22
  export const DEFAULT_TEMPORAL_VERSION = "1.5.1";
21
23
 
24
+ /** The release's `sha256sum`-format digest file, one line per published asset. */
25
+ export const TEMPORAL_CHECKSUMS_FILE = "checksums.txt";
26
+
22
27
  export interface TemporalDownloadTarget {
23
28
  /** Temporal CLI version, e.g. "1.5.1". */
24
29
  version: string;
@@ -32,15 +37,23 @@ export interface TemporalDownloadTarget {
32
37
  fetchImpl?: typeof fetch;
33
38
  }
34
39
 
35
- /** Download and install the Temporal CLI binary to `binPath`. */
40
+ /** The release asset name for a version on a platform, as Temporal publishes it. */
41
+ export function temporalArchiveName(version: string, platform: NodeJS.Platform, arch: string): string {
42
+ return `temporal_cli_${version}_${mapReleaseOs(platform)}_${mapReleaseArch(arch)}.tar.gz`;
43
+ }
44
+
45
+ /** The GitHub release download URL for one asset of a version. */
46
+ export function temporalReleaseAssetUrl(version: string, asset: string): string {
47
+ return `https://github.com/temporalio/cli/releases/download/v${version}/${asset}`;
48
+ }
49
+
50
+ /** Download, verify and install the Temporal CLI binary to `binPath`. */
36
51
  export async function downloadTemporalCli(target: TemporalDownloadTarget): Promise<void> {
37
- const goos = mapReleaseOs(target.platform ?? process.platform);
38
- const goarch = mapReleaseArch(target.arch ?? process.arch);
39
- const archive = `temporal_cli_${target.version}_${goos}_${goarch}.tar.gz`;
40
- const url = `https://github.com/temporalio/cli/releases/download/v${target.version}/${archive}`;
52
+ const archive = temporalArchiveName(target.version, target.platform ?? process.platform, target.arch ?? process.arch);
41
53
 
42
54
  await fetchTarballBinary({
43
- url,
55
+ url: temporalReleaseAssetUrl(target.version, archive),
56
+ checksumUrl: temporalReleaseAssetUrl(target.version, TEMPORAL_CHECKSUMS_FILE),
44
57
  entryName: "temporal",
45
58
  binPath: target.binPath,
46
59
  label: "Temporal CLI",
@@ -1,6 +1,14 @@
1
1
  // Public surface of the Temporal subsystem.
2
2
 
3
- export { DEFAULT_TEMPORAL_VERSION, downloadTemporalCli, extractTarEntry, isTemporalInstalled } from "./download.js";
4
- export { isLikelyTemporal, processCommandLine } from "./inspect.js";
5
- export { TemporalManager, type TemporalManagerOptions } from "./manager.js";
3
+ export {
4
+ DEFAULT_TEMPORAL_VERSION,
5
+ TEMPORAL_CHECKSUMS_FILE,
6
+ downloadTemporalCli,
7
+ extractTarEntry,
8
+ isTemporalInstalled,
9
+ temporalArchiveName,
10
+ temporalReleaseAssetUrl,
11
+ } from "./download.js";
12
+ export { isLikelyTemporal, processCommandLine, type ProcessInspectOptions } from "./inspect.js";
13
+ export { TEMPORAL_BIN_ENV, TemporalManager, type TemporalManagerOptions } from "./manager.js";
6
14
  export { type SupervisedTarget, type SupervisorOptions, TemporalSupervisor } from "./supervisor.js";
@@ -0,0 +1,83 @@
1
+ // Pins the process-identity reader: the kernel's /proc answer comes first, `ps`
2
+ // is the fallback, and neither's absence is mistaken for "not Temporal" when the
3
+ // other can answer. The /proc arm is what keeps a minimal container (no `ps`)
4
+ // from restarting a healthy Temporal every supervisor tick.
5
+
6
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
10
+ import { isLikelyTemporal, processCommandLine } from "./inspect.js";
11
+
12
+ function procFixture(entries: Record<number, string[] | "">): string {
13
+ const root = mkdtempSync(join(tmpdir(), "stigmer-proc-"));
14
+ for (const [pid, argv] of Object.entries(entries)) {
15
+ mkdirSync(join(root, pid));
16
+ // /proc/<pid>/cmdline: argv joined by NUL, NUL-terminated; empty for a zombie.
17
+ writeFileSync(join(root, pid, "cmdline"), argv === "" ? "" : `${argv.join("\0")}\0`);
18
+ }
19
+ return root;
20
+ }
21
+
22
+ // Hide `ps` so the fallback cannot answer; tests that need it restore PATH.
23
+ let savedPath: string | undefined;
24
+ beforeEach(() => {
25
+ savedPath = process.env.PATH;
26
+ });
27
+ afterEach(() => {
28
+ if (savedPath === undefined) delete process.env.PATH;
29
+ else process.env.PATH = savedPath;
30
+ });
31
+
32
+ describe("processCommandLine", () => {
33
+ it("reads argv from <procRoot>/<pid>/cmdline, joining the NUL-separated arguments", () => {
34
+ const root = procFixture({ 42: ["/opt/stigmer/bin/temporal", "server", "start-dev", "--port", "7233"] });
35
+ process.env.PATH = "";
36
+ expect(processCommandLine(42, { procRoot: root })).toBe("/opt/stigmer/bin/temporal server start-dev --port 7233");
37
+ });
38
+
39
+ it("falls back to ps when the pid has no cmdline entry", () => {
40
+ const root = procFixture({});
41
+ // Our own process is the one PID whose command line `ps` can always report.
42
+ const line = processCommandLine(process.pid, { procRoot: root });
43
+ expect(line).not.toBeNull();
44
+ expect(line).toMatch(/node|vitest/);
45
+ });
46
+
47
+ it("treats an empty cmdline (a zombie) as unreadable and falls back to ps", () => {
48
+ const root = procFixture({ [process.pid]: "" });
49
+ expect(processCommandLine(process.pid, { procRoot: root })).toMatch(/node|vitest/);
50
+ });
51
+
52
+ it("returns null when neither /proc nor ps can answer", () => {
53
+ process.env.PATH = "";
54
+ expect(processCommandLine(process.pid, { procRoot: null })).toBeNull();
55
+ });
56
+ });
57
+
58
+ describe("isLikelyTemporal", () => {
59
+ it("recognises the managed dev server by its binary path", () => {
60
+ const root = procFixture({ 7: ["/home/u/.stigmer/bin/temporal", "server", "start-dev"] });
61
+ process.env.PATH = "";
62
+ expect(isLikelyTemporal(7, "/home/u/.stigmer/bin/temporal", { procRoot: root })).toBe(true);
63
+ });
64
+
65
+ it("recognises a temporal server started from another path by its subcommand", () => {
66
+ const root = procFixture({ 8: ["/usr/local/bin/temporal", "server", "start-dev"] });
67
+ process.env.PATH = "";
68
+ expect(isLikelyTemporal(8, "/elsewhere/temporal", { procRoot: root })).toBe(true);
69
+ });
70
+
71
+ it("rejects an unrelated process that reused the pid", () => {
72
+ const root = procFixture({ 9: ["/usr/bin/node", "/app/main.js"] });
73
+ process.env.PATH = "";
74
+ expect(isLikelyTemporal(9, "/home/u/.stigmer/bin/temporal", { procRoot: root })).toBe(false);
75
+ });
76
+
77
+ // The defect the /proc arm fixes: with `ps` gone and no /proc reader, a live
78
+ // Temporal read as "not Temporal" and the supervisor restarted it forever.
79
+ it("is false, not an exception, when no reader can answer", () => {
80
+ process.env.PATH = "";
81
+ expect(isLikelyTemporal(process.pid, "/x/temporal", { procRoot: null })).toBe(false);
82
+ });
83
+ });
@@ -3,30 +3,78 @@
3
3
  // A PID file can outlive the process it named; the OS may then assign that PID
4
4
  // to something unrelated. Before trusting a PID as "our Temporal", we confirm
5
5
  // the live process's command line actually looks like the Temporal dev server.
6
- // POSIX-only (uses `ps`); returns false on any uncertainty.
6
+ //
7
+ // Two readers, in order. On Linux the kernel publishes every process's command
8
+ // line at /proc/<pid>/cmdline (NUL-separated argv), which needs no external
9
+ // program — the reader that works inside a minimal container. Everywhere else
10
+ // (and on a Linux where /proc is unreadable) we shell out to `ps`, the
11
+ // POSIX-portable answer. Returns null on any uncertainty; callers treat null
12
+ // as "not Temporal", which is the safe direction for a PID-reuse guard.
13
+ //
14
+ // Why the /proc reader exists: `node:22-slim` and its kin ship no `ps`. With
15
+ // `ps` as the only reader, every liveness probe answered "not Temporal", the
16
+ // supervisor restarted a healthy dev server every tick, and each restart spawned
17
+ // a second process that failed to bind while the first kept the port. A
18
+ // dependency on a userland tool is the wrong shape for a liveness check that
19
+ // the kernel can answer directly.
7
20
 
8
21
  import { execFileSync } from "node:child_process";
22
+ import { readFileSync } from "node:fs";
23
+ import { join } from "node:path";
9
24
 
10
- /** Full command line of a process via `ps`, or null if it cannot be read. */
11
- export function processCommandLine(pid: number): string | null {
12
- try {
13
- return execFileSync("ps", ["-p", String(pid), "-o", "command="], {
14
- encoding: "utf8",
15
- stdio: ["ignore", "pipe", "ignore"],
16
- }).trim();
17
- } catch {
18
- return null;
25
+ /** Where the kernel publishes process metadata on Linux; null where it does not. */
26
+ const DEFAULT_PROC_ROOT: string | null = process.platform === "linux" ? "/proc" : null;
27
+
28
+ export interface ProcessInspectOptions {
29
+ /**
30
+ * Root of a procfs tree to read `<pid>/cmdline` from. Defaults to `/proc` on
31
+ * Linux and to none elsewhere; tests point it at a fixture directory.
32
+ */
33
+ procRoot?: string | null;
34
+ }
35
+
36
+ /** Full command line of a process, or null if it cannot be read. */
37
+ export function processCommandLine(pid: number, options: ProcessInspectOptions = {}): string | null {
38
+ const procRoot = options.procRoot === undefined ? DEFAULT_PROC_ROOT : options.procRoot;
39
+ if (procRoot !== null) {
40
+ const fromProc = readProcCmdline(procRoot, pid);
41
+ if (fromProc !== null) return fromProc;
19
42
  }
43
+ return psCommandLine(pid);
20
44
  }
21
45
 
22
46
  /**
23
47
  * Heuristically confirm that `pid` is the Temporal server we manage: its command
24
48
  * line mentions "temporal" and either our binary path or a "server" subcommand.
25
49
  */
26
- export function isLikelyTemporal(pid: number, binPath: string): boolean {
27
- const cmd = processCommandLine(pid);
50
+ export function isLikelyTemporal(pid: number, binPath: string, options: ProcessInspectOptions = {}): boolean {
51
+ const cmd = processCommandLine(pid, options);
28
52
  if (cmd === null) return false;
29
53
  const lower = cmd.toLowerCase();
30
54
  if (!lower.includes("temporal")) return false;
31
55
  return cmd.includes(binPath) || lower.includes("server");
32
56
  }
57
+
58
+ // /proc/<pid>/cmdline is argv joined by NUL bytes (with a trailing NUL). An
59
+ // empty file means a zombie or a kernel thread — nothing we can name, so null
60
+ // lets the caller fall through to `ps`.
61
+ function readProcCmdline(procRoot: string, pid: number): string | null {
62
+ try {
63
+ const raw = readFileSync(join(procRoot, String(pid), "cmdline"), "utf8");
64
+ const argv = raw.split("\0").filter((arg) => arg !== "");
65
+ return argv.length === 0 ? null : argv.join(" ");
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ function psCommandLine(pid: number): string | null {
72
+ try {
73
+ return execFileSync("ps", ["-p", String(pid), "-o", "command="], {
74
+ encoding: "utf8",
75
+ stdio: ["ignore", "pipe", "ignore"],
76
+ }).trim();
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
@@ -1,4 +1,4 @@
1
- import { mkdtempSync } from "node:fs";
1
+ import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { describe, expect, it } from "vitest";
@@ -28,4 +28,37 @@ describe("TemporalManager", () => {
28
28
  const manager = TemporalManager.forHome(tempHome());
29
29
  expect(await manager.isRunning()).toBe(false);
30
30
  });
31
+
32
+ // A stale PID file naming THIS process (the restarted container's fresh PID
33
+ // namespace reused it) can never be a running Temporal.
34
+ it("reports not-running when the PID file names the current process", async () => {
35
+ const home = tempHome();
36
+ writePidFile(temporalPidFile(home), process.pid);
37
+ expect(await TemporalManager.forHome(home).isRunning()).toBe(false);
38
+ });
39
+
40
+ describe("STIGMER_TEMPORAL_BIN", () => {
41
+ it("relocates only the binary; data, log, PID and lock stay under the home", async () => {
42
+ const home = tempHome();
43
+ const binPath = join(tempHome(), "opt", "temporal");
44
+ mkdirSync(join(binPath, ".."), { recursive: true });
45
+ writeFileSync(binPath, "#!/bin/sh\n");
46
+
47
+ const manager = TemporalManager.forHome(home, { STIGMER_TEMPORAL_BIN: binPath });
48
+ expect(manager.binPath).toBe(binPath);
49
+
50
+ // Nothing to download: the pre-installed binary is what ensureInstalled checks for.
51
+ await manager.ensureInstalled();
52
+ expect(existsSync(join(home, ".stigmer", "bin", "temporal"))).toBe(false);
53
+ // State still lives in the home layout.
54
+ writePidFile(temporalPidFile(home), 5151);
55
+ expect(manager.getPid()).toBe(5151);
56
+ });
57
+
58
+ it("is ignored when empty, keeping the ~/.stigmer/bin default", () => {
59
+ const home = tempHome();
60
+ const manager = TemporalManager.forHome(home, { STIGMER_TEMPORAL_BIN: "" });
61
+ expect(manager.binPath).toBe(join(home, ".stigmer", "bin", "temporal"));
62
+ });
63
+ });
31
64
  });
@@ -19,7 +19,7 @@ import { binDir, logDir, temporalDataDir, temporalLockFile, temporalPidFile } fr
19
19
  import { TEMPORAL_PORT, TEMPORAL_UI_PORT } from "../constants.js";
20
20
  import { type FileLock, acquireLock } from "../state/lock.js";
21
21
  import { readPidFile, removePidFile, writePidFile } from "../state/pidfile.js";
22
- import { findProcessByPort, isProcessAlive, killProcessGroup } from "../state/proc.js";
22
+ import { findProcessByPort, isOtherLiveProcess, killProcessGroup } from "../state/proc.js";
23
23
  import { DEFAULT_TEMPORAL_VERSION, downloadTemporalCli, isTemporalInstalled } from "./download.js";
24
24
  import { isLikelyTemporal } from "./inspect.js";
25
25
 
@@ -27,8 +27,16 @@ const READY_TIMEOUT_MS = 10_000;
27
27
  const STOP_POLL_MS = 500;
28
28
  const STOP_TIMEOUT_MS = 10_000;
29
29
 
30
+ /**
31
+ * Env var naming a pre-installed `temporal` binary to use instead of the one
32
+ * the CLI downloads into ~/.stigmer/bin — the STIGMER_NODE_BIN precedent. The
33
+ * all-in-one image bakes the binary read-only off its data volume and points
34
+ * here; `ensureInstalled` then has nothing to download.
35
+ */
36
+ export const TEMPORAL_BIN_ENV = "STIGMER_TEMPORAL_BIN";
37
+
30
38
  export interface TemporalManagerOptions {
31
- /** Path to the temporal binary (~/.stigmer/bin/temporal). */
39
+ /** Path to the temporal binary (~/.stigmer/bin/temporal, or STIGMER_TEMPORAL_BIN). */
32
40
  binPath: string;
33
41
  /** Temporal dev-server data directory (~/.stigmer/temporal-data). */
34
42
  dataDir: string;
@@ -44,7 +52,8 @@ export interface TemporalManagerOptions {
44
52
  }
45
53
 
46
54
  export class TemporalManager {
47
- private readonly binPath: string;
55
+ /** The `temporal` binary this manager runs (downloaded, or pre-installed via STIGMER_TEMPORAL_BIN). */
56
+ readonly binPath: string;
48
57
  private readonly dataDir: string;
49
58
  private readonly logFile: string;
50
59
  private readonly pidFile: string;
@@ -65,10 +74,15 @@ export class TemporalManager {
65
74
  this.uiPort = options.uiPort ?? TEMPORAL_UI_PORT;
66
75
  }
67
76
 
68
- /** Build a manager from the standard ~/.stigmer layout. */
69
- static forHome(home: string = homedir()): TemporalManager {
77
+ /**
78
+ * Build a manager from the standard ~/.stigmer layout. The binary alone may
79
+ * be relocated through STIGMER_TEMPORAL_BIN; the dev server's data, log,
80
+ * PID and lock stay under the home, because they are state, not software.
81
+ */
82
+ static forHome(home: string = homedir(), env: NodeJS.ProcessEnv = process.env): TemporalManager {
83
+ const binOverride = env[TEMPORAL_BIN_ENV];
70
84
  return new TemporalManager({
71
- binPath: join(binDir(home), "temporal"),
85
+ binPath: binOverride !== undefined && binOverride !== "" ? binOverride : join(binDir(home), "temporal"),
72
86
  dataDir: temporalDataDir(home),
73
87
  logFile: join(logDir(home), "temporal.log"),
74
88
  pidFile: temporalPidFile(home),
@@ -183,7 +197,7 @@ export class TemporalManager {
183
197
  async isRunning(): Promise<boolean> {
184
198
  const pid = this.getPid();
185
199
  if (pid === null) return false;
186
- if (!isProcessAlive(pid)) return false;
200
+ if (!isOtherLiveProcess(pid)) return false;
187
201
  if (!isLikelyTemporal(pid, this.binPath)) return false;
188
202
  return this.isPortInUse();
189
203
  }
@@ -229,7 +243,7 @@ export class TemporalManager {
229
243
  private cleanupStaleProcesses(): void {
230
244
  const pid = this.getPid();
231
245
  if (pid !== null) {
232
- if (!isProcessAlive(pid) || !isLikelyTemporal(pid, this.binPath)) {
246
+ if (!isOtherLiveProcess(pid) || !isLikelyTemporal(pid, this.binPath)) {
233
247
  removePidFile(this.pidFile);
234
248
  }
235
249
  return;