@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
@@ -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;
@@ -149,8 +149,8 @@ function countingClient(preferences: StubPreferences): { client: Stigmer; calls:
149
149
  return { client, calls: () => calls };
150
150
  }
151
151
 
152
- /** prepareAgentExec options as `run` passes them: cloud + harness opt-in. */
153
- const CLOUD_RUN_OPTIONS = { cloudBackend: true, applyAccountHarnessDefault: true } as const;
152
+ /** prepareAgentExec options as `run` passes them: the kind is served here + harness opt-in. */
153
+ const SERVED_RUN_OPTIONS = { accountPreferencesAvailable: true, applyAccountHarnessDefault: true } as const;
154
154
 
155
155
  describe("prepareAgentExec env injection", () => {
156
156
  it("injects STIGMER_ORG_ID when absent", async () => {
@@ -193,7 +193,7 @@ describe("prepareAgentExec account-preference model fill (oss#293 Phase 1.5)", (
193
193
  clientWithPreference("claude-sonnet-4.6"),
194
194
  "acme",
195
195
  undefined,
196
- { cloudBackend: true },
196
+ { accountPreferencesAvailable: true },
197
197
  );
198
198
  expect(prepared.model).toBe("claude-sonnet-4.6");
199
199
  });
@@ -204,20 +204,22 @@ describe("prepareAgentExec account-preference model fill (oss#293 Phase 1.5)", (
204
204
  clientWithPreference("claude-sonnet-4.6"),
205
205
  "acme",
206
206
  undefined,
207
- { cloudBackend: true },
207
+ { accountPreferencesAvailable: true },
208
208
  );
209
209
  expect(prepared.model).toBe("gpt-5.3");
210
210
  });
211
211
 
212
- it("never consults identity on a local backend", async () => {
213
- // The failing stub doubles as a call detector: local mode must not even
214
- // attempt whoAmI, so a rejecting client cannot affect the result.
212
+ it("never consults identity where the kind is not served", async () => {
213
+ // The failing stub doubles as a call detector: a backend that does not
214
+ // serve identity accounts must not even attempt whoAmI, so a rejecting
215
+ // client cannot affect the result. (20260911.11 A3: the gate is the
216
+ // kind's tier, not the backend type — client/edition.ts resourceServedOn.)
215
217
  const prepared = await prepareAgentExec(
216
218
  BASE_FLAGS,
217
219
  clientWithFailingWhoAmI(),
218
220
  "acme",
219
221
  undefined,
220
- { cloudBackend: false },
222
+ { accountPreferencesAvailable: false },
221
223
  );
222
224
  expect(prepared.model).toBe("");
223
225
  });
@@ -228,7 +230,7 @@ describe("prepareAgentExec account-preference model fill (oss#293 Phase 1.5)", (
228
230
  clientWithFailingWhoAmI(),
229
231
  "acme",
230
232
  undefined,
231
- { cloudBackend: true },
233
+ { accountPreferencesAvailable: true },
232
234
  );
233
235
  expect(prepared.model).toBe("");
234
236
  });
@@ -239,7 +241,7 @@ describe("prepareAgentExec account-preference model fill (oss#293 Phase 1.5)", (
239
241
  clientWithPreference(""),
240
242
  "acme",
241
243
  undefined,
242
- { cloudBackend: true },
244
+ { accountPreferencesAvailable: true },
243
245
  );
244
246
  expect(prepared.model).toBe("");
245
247
  });
@@ -252,7 +254,7 @@ describe("prepareAgentExec harness resolution (oss#293)", () => {
252
254
  clientWithPreferences({ defaultHarness: "cursor" }),
253
255
  "acme",
254
256
  undefined,
255
- CLOUD_RUN_OPTIONS,
257
+ SERVED_RUN_OPTIONS,
256
258
  );
257
259
  expect(prepared.harness).toBe("cursor");
258
260
  });
@@ -265,7 +267,7 @@ describe("prepareAgentExec harness resolution (oss#293)", () => {
265
267
  clientWithPreferences({ defaultHarness: "cursor" }),
266
268
  "acme",
267
269
  undefined,
268
- CLOUD_RUN_OPTIONS,
270
+ SERVED_RUN_OPTIONS,
269
271
  );
270
272
  expect(prepared.harness).toBe("native");
271
273
  });
@@ -279,7 +281,7 @@ describe("prepareAgentExec harness resolution (oss#293)", () => {
279
281
  clientWithPreferences({ defaultHarness: "cursor" }),
280
282
  "acme",
281
283
  undefined,
282
- { cloudBackend: true },
284
+ { accountPreferencesAvailable: true },
283
285
  );
284
286
  expect(prepared.harness).toBe("");
285
287
  });
@@ -290,7 +292,7 @@ describe("prepareAgentExec harness resolution (oss#293)", () => {
290
292
  STUB_CLIENT,
291
293
  "acme",
292
294
  undefined,
293
- { cloudBackend: false },
295
+ { accountPreferencesAvailable: false },
294
296
  );
295
297
  expect(prepared.harness).toBe("cursor");
296
298
  });
@@ -303,7 +305,7 @@ describe("prepareAgentExec harness resolution (oss#293)", () => {
303
305
  clientWithPreferences({ defaultHarness: "devin" }),
304
306
  "acme",
305
307
  undefined,
306
- CLOUD_RUN_OPTIONS,
308
+ SERVED_RUN_OPTIONS,
307
309
  );
308
310
  expect(prepared.harness).toBe("");
309
311
  });
@@ -314,7 +316,7 @@ describe("prepareAgentExec harness resolution (oss#293)", () => {
314
316
  clientWithFailingWhoAmI(),
315
317
  "acme",
316
318
  undefined,
317
- CLOUD_RUN_OPTIONS,
319
+ SERVED_RUN_OPTIONS,
318
320
  );
319
321
  expect(prepared.harness).toBe("");
320
322
  expect(prepared.model).toBe("");
@@ -327,7 +329,7 @@ describe("prepareAgentExec harness resolution (oss#293)", () => {
327
329
  clientWithFailingWhoAmI(),
328
330
  "acme",
329
331
  undefined,
330
- { cloudBackend: false, applyAccountHarnessDefault: true },
332
+ { accountPreferencesAvailable: false, applyAccountHarnessDefault: true },
331
333
  );
332
334
  expect(prepared.harness).toBe("");
333
335
  });
@@ -338,7 +340,7 @@ describe("prepareAgentExec harness resolution (oss#293)", () => {
338
340
  clientWithPreferences({}),
339
341
  "acme",
340
342
  undefined,
341
- CLOUD_RUN_OPTIONS,
343
+ SERVED_RUN_OPTIONS,
342
344
  );
343
345
  expect(prepared.harness).toBe("");
344
346
  });
@@ -356,7 +358,7 @@ describe("prepareAgentExec harness-aware model fill (oss#293)", () => {
356
358
  clientWithPreferences(BOTH_MODELS),
357
359
  "acme",
358
360
  undefined,
359
- CLOUD_RUN_OPTIONS,
361
+ SERVED_RUN_OPTIONS,
360
362
  );
361
363
  expect(prepared.model).toBe("composer-2.5");
362
364
  });
@@ -367,7 +369,7 @@ describe("prepareAgentExec harness-aware model fill (oss#293)", () => {
367
369
  clientWithPreferences({ ...BOTH_MODELS, defaultHarness: "cursor" }),
368
370
  "acme",
369
371
  undefined,
370
- CLOUD_RUN_OPTIONS,
372
+ SERVED_RUN_OPTIONS,
371
373
  );
372
374
  expect(prepared.harness).toBe("cursor");
373
375
  expect(prepared.model).toBe("composer-2.5");
@@ -379,7 +381,7 @@ describe("prepareAgentExec harness-aware model fill (oss#293)", () => {
379
381
  clientWithPreferences(BOTH_MODELS),
380
382
  "acme",
381
383
  undefined,
382
- CLOUD_RUN_OPTIONS,
384
+ SERVED_RUN_OPTIONS,
383
385
  );
384
386
  expect(explicit.model).toBe("claude-sonnet-4.6");
385
387
 
@@ -388,7 +390,7 @@ describe("prepareAgentExec harness-aware model fill (oss#293)", () => {
388
390
  clientWithPreferences(BOTH_MODELS),
389
391
  "acme",
390
392
  undefined,
391
- CLOUD_RUN_OPTIONS,
393
+ SERVED_RUN_OPTIONS,
392
394
  );
393
395
  expect(unset.model).toBe("claude-sonnet-4.6");
394
396
  });
@@ -399,7 +401,7 @@ describe("prepareAgentExec harness-aware model fill (oss#293)", () => {
399
401
  clientWithPreferences(BOTH_MODELS),
400
402
  "acme",
401
403
  undefined,
402
- CLOUD_RUN_OPTIONS,
404
+ SERVED_RUN_OPTIONS,
403
405
  );
404
406
  expect(prepared.model).toBe("gpt-5.3");
405
407
  });
@@ -412,7 +414,7 @@ describe("prepareAgentExec harness-aware model fill (oss#293)", () => {
412
414
  clientWithPreferences(BOTH_MODELS),
413
415
  "acme",
414
416
  undefined,
415
- { cloudBackend: true },
417
+ { accountPreferencesAvailable: true },
416
418
  );
417
419
  expect(prepared.harness).toBe("cursor");
418
420
  expect(prepared.model).toBe("composer-2.5");
@@ -420,7 +422,7 @@ describe("prepareAgentExec harness-aware model fill (oss#293)", () => {
420
422
 
421
423
  it("serves both fills from one whoAmI round trip, and none when both flags are explicit", async () => {
422
424
  const single = countingClient({ ...BOTH_MODELS, defaultHarness: "cursor" });
423
- const filled = await prepareAgentExec(BASE_FLAGS, single.client, "acme", undefined, CLOUD_RUN_OPTIONS);
425
+ const filled = await prepareAgentExec(BASE_FLAGS, single.client, "acme", undefined, SERVED_RUN_OPTIONS);
424
426
  expect(filled.harness).toBe("cursor");
425
427
  expect(filled.model).toBe("composer-2.5");
426
428
  expect(single.calls(), "harness + model fills share one whoAmI").toBe(1);
@@ -431,7 +433,7 @@ describe("prepareAgentExec harness-aware model fill (oss#293)", () => {
431
433
  skipped.client,
432
434
  "acme",
433
435
  undefined,
434
- CLOUD_RUN_OPTIONS,
436
+ SERVED_RUN_OPTIONS,
435
437
  );
436
438
  expect(explicit.harness).toBe("native");
437
439
  expect(explicit.model).toBe("gpt-5.3");