@vellumai/cli 0.8.12 → 0.9.0-dev.202606162156.4bad3e5

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 (52) hide show
  1. package/README.md +1 -1
  2. package/bun.lock +49 -56
  3. package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +224 -0
  4. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
  5. package/node_modules/@vellumai/local-mode/src/index.ts +8 -1
  6. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +0 -15
  7. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +8 -4
  8. package/node_modules/@vellumai/local-mode/src/sleep.ts +80 -0
  9. package/node_modules/@vellumai/local-mode/src/status.ts +342 -0
  10. package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
  11. package/package.json +3 -3
  12. package/src/__tests__/assistant-config.test.ts +1 -2
  13. package/src/__tests__/device-id.test.ts +6 -14
  14. package/src/__tests__/helpers/os-mock.ts +27 -0
  15. package/src/__tests__/login-loopback.test.ts +71 -0
  16. package/src/__tests__/multi-local.test.ts +2 -10
  17. package/src/__tests__/nginx-ingress-command.test.ts +69 -0
  18. package/src/__tests__/nginx-ingress.test.ts +403 -0
  19. package/src/__tests__/sleep.test.ts +4 -0
  20. package/src/__tests__/teleport.test.ts +6 -9
  21. package/src/__tests__/tunnel.test.ts +164 -0
  22. package/src/__tests__/wake.test.ts +15 -4
  23. package/src/__tests__/workos-pkce.test.ts +314 -0
  24. package/src/commands/flags.ts +1 -22
  25. package/src/commands/hatch.ts +90 -9
  26. package/src/commands/login.ts +123 -59
  27. package/src/commands/nginx-ingress.ts +291 -0
  28. package/src/commands/rollback.ts +0 -6
  29. package/src/commands/sleep.ts +17 -0
  30. package/src/commands/teleport.ts +23 -36
  31. package/src/commands/tunnel.ts +69 -11
  32. package/src/commands/upgrade.ts +0 -2
  33. package/src/commands/wake.ts +7 -5
  34. package/src/commands/workflows.ts +301 -0
  35. package/src/index.ts +8 -0
  36. package/src/lib/arg-utils.ts +48 -0
  37. package/src/lib/assistant-client.ts +2 -0
  38. package/src/lib/assistant-config.ts +0 -7
  39. package/src/lib/cloudflare-tunnel.ts +15 -2
  40. package/src/lib/docker.ts +103 -49
  41. package/src/lib/feature-flags.test.ts +157 -0
  42. package/src/lib/feature-flags.ts +38 -0
  43. package/src/lib/hatch-local.ts +0 -1
  44. package/src/lib/local.ts +5 -0
  45. package/src/lib/nginx-ingress.ts +576 -0
  46. package/src/lib/ngrok.ts +26 -4
  47. package/src/lib/platform-client.ts +0 -1
  48. package/src/lib/retire-local.ts +5 -0
  49. package/src/lib/statefulset.ts +73 -21
  50. package/src/lib/sync-cloud-assistants.ts +4 -17
  51. package/src/lib/upgrade-lifecycle.ts +1 -2
  52. package/src/lib/workos-pkce.ts +160 -0
@@ -14,7 +14,6 @@ describe("parseLockfile", () => {
14
14
  runtimeUrl: "http://127.0.0.1:7777",
15
15
  species: "vellum",
16
16
  hatchedAt: "2026-01-01T00:00:00.000Z",
17
- version: "0.7.0",
18
17
  organizationId: "org_1",
19
18
  resources: { gatewayPort: 7777, daemonPort: 7778 },
20
19
  },
@@ -170,20 +169,6 @@ describe("parseLockfile", () => {
170
169
  ]);
171
170
  });
172
171
 
173
- test("keeps version when a string and drops it when mistyped", () => {
174
- const raw = {
175
- assistants: [
176
- { assistantId: "asst_1", cloud: "docker", version: "0.7.0" },
177
- { assistantId: "asst_2", cloud: "docker", version: 7 }, // mistyped
178
- ],
179
- activeAssistant: null,
180
- };
181
- expect(parseLockfile(raw).assistants).toEqual([
182
- { assistantId: "asst_1", cloud: "docker", version: "0.7.0" },
183
- { assistantId: "asst_2", cloud: "docker" },
184
- ]);
185
- });
186
-
187
172
  test("drops a resources object missing its numeric ports", () => {
188
173
  const raw = {
189
174
  assistants: [
@@ -31,6 +31,7 @@
31
31
  */
32
32
 
33
33
  export interface LocalAssistantResources {
34
+ instanceDir?: string;
34
35
  gatewayPort: number;
35
36
  daemonPort: number;
36
37
  }
@@ -42,8 +43,6 @@ export interface LockfileAssistant {
42
43
  runtimeUrl?: string;
43
44
  species?: string;
44
45
  hatchedAt?: string;
45
- /** Installed release version (no `v` prefix), written by the CLI at hatch/upgrade. */
46
- version?: string;
47
46
  /** Owning org for platform assistants; absent for local ones. */
48
47
  organizationId?: string;
49
48
  resources?: LocalAssistantResources;
@@ -71,7 +70,13 @@ function parseResources(value: unknown): LocalAssistantResources | undefined {
71
70
  if (!isRecord(value)) return undefined;
72
71
  if (typeof value.gatewayPort !== "number") return undefined;
73
72
  if (typeof value.daemonPort !== "number") return undefined;
74
- return { gatewayPort: value.gatewayPort, daemonPort: value.daemonPort };
73
+ return {
74
+ ...(typeof value.instanceDir === "string"
75
+ ? { instanceDir: value.instanceDir }
76
+ : {}),
77
+ gatewayPort: value.gatewayPort,
78
+ daemonPort: value.daemonPort,
79
+ };
75
80
  }
76
81
 
77
82
  /**
@@ -91,7 +96,6 @@ function parseAssistant(value: unknown): LockfileAssistant | null {
91
96
  if (typeof value.runtimeUrl === "string") assistant.runtimeUrl = value.runtimeUrl;
92
97
  if (typeof value.species === "string") assistant.species = value.species;
93
98
  if (typeof value.hatchedAt === "string") assistant.hatchedAt = value.hatchedAt;
94
- if (typeof value.version === "string") assistant.version = value.version;
95
99
  if (typeof value.organizationId === "string") assistant.organizationId = value.organizationId;
96
100
  const resources = parseResources(value.resources);
97
101
  if (resources) assistant.resources = resources;
@@ -0,0 +1,80 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ import type { CliInvocation } from "./util";
4
+
5
+ // The CLI's `sleep` command uses a 120s SIGKILL ceiling for the assistant
6
+ // daemon (WAL checkpoint can be slow on large databases) plus 7s for the
7
+ // gateway drain window. The wrapper timeout must sit above that total so a
8
+ // slow-but-succeeding sleep isn't killed and misreported as a timeout.
9
+ const SLEEP_TIMEOUT_MS = 150_000;
10
+
11
+ export type SleepResult =
12
+ | { ok: true }
13
+ | { ok: false; status: number; error: string };
14
+
15
+ /**
16
+ * Stop a local assistant's daemon and gateway via the CLI's `sleep --force`.
17
+ *
18
+ * Uses `--force` to bypass the active-call-lease guard — the restart flow
19
+ * immediately follows with a `wake`, so the brief interruption is expected
20
+ * and user-confirmed at the UI level.
21
+ *
22
+ * Mirrors {@link runRetire}'s never-reject contract so each host wires
23
+ * transport once and surfaces a structured failure rather than a thrown error.
24
+ */
25
+ export function runSleep(
26
+ invocation: CliInvocation,
27
+ assistantId: string,
28
+ ): Promise<SleepResult> {
29
+ return new Promise((resolve) => {
30
+ const child = spawn(
31
+ invocation.command,
32
+ [...invocation.baseArgs, "sleep", assistantId, "--force"],
33
+ { stdio: ["ignore", "pipe", "pipe"] },
34
+ );
35
+
36
+ let stdout = "";
37
+ let stderr = "";
38
+ let done = false;
39
+
40
+ const finish = (result: SleepResult) => {
41
+ if (done) return;
42
+ done = true;
43
+ clearTimeout(timeout);
44
+ resolve(result);
45
+ };
46
+
47
+ const timeout = setTimeout(() => {
48
+ child.kill("SIGTERM");
49
+ finish({
50
+ ok: false,
51
+ status: 500,
52
+ error: `Sleep timed out after ${SLEEP_TIMEOUT_MS / 1000} seconds`,
53
+ });
54
+ }, SLEEP_TIMEOUT_MS);
55
+
56
+ child.stdout.on("data", (data: Buffer) => {
57
+ stdout += data.toString();
58
+ });
59
+
60
+ child.stderr.on("data", (data: Buffer) => {
61
+ stderr += data.toString();
62
+ });
63
+
64
+ child.on("close", (code) => {
65
+ if (code === 0) {
66
+ finish({ ok: true });
67
+ } else {
68
+ finish({ ok: false, status: 500, error: stderr || stdout });
69
+ }
70
+ });
71
+
72
+ child.on("error", (err) => {
73
+ finish({
74
+ ok: false,
75
+ status: 500,
76
+ error: `Failed to spawn CLI: ${err.message}`,
77
+ });
78
+ });
79
+ });
80
+ }
@@ -0,0 +1,342 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import os from "node:os";
3
+ import http from "node:http";
4
+ import path from "node:path";
5
+
6
+ import { SEEDS } from "@vellumai/environments";
7
+
8
+ import type {
9
+ LockfileAssistant,
10
+ } from "./lockfile-contract";
11
+ import { getLockfileData } from "./lockfile";
12
+
13
+ const HEALTH_TIMEOUT_MS = 1_500;
14
+ const STARTING_GRACE_MS = 60_000;
15
+ const PRODUCTION_ENVIRONMENT_NAME = "production";
16
+ const DEFAULT_PORTS = {
17
+ daemon: 7821,
18
+ gateway: 7830,
19
+ };
20
+
21
+ export type LocalAssistantRuntimeState =
22
+ | "healthy"
23
+ | "sleeping"
24
+ | "starting"
25
+ | "crashed"
26
+ | "unknown";
27
+
28
+ export type LocalAssistantStatusResult =
29
+ | {
30
+ ok: true;
31
+ state: LocalAssistantRuntimeState;
32
+ detail?: string;
33
+ pid?: number;
34
+ }
35
+ | { ok: false; status: number; error: string };
36
+
37
+ type PidState =
38
+ | { state: "missing" }
39
+ | { state: "starting"; updatedAtMs: number }
40
+ | { state: "alive"; pid: number; updatedAtMs: number }
41
+ | { state: "dead"; pid: number; updatedAtMs: number }
42
+ | { state: "invalid"; value: string; updatedAtMs: number };
43
+
44
+ interface StatusResources {
45
+ instanceDir: string;
46
+ gatewayPort: number;
47
+ daemonPort: number;
48
+ }
49
+
50
+ function getDaemonPidPath(instanceDir: string): string {
51
+ return path.join(instanceDir, ".vellum", "workspace", "vellum.pid");
52
+ }
53
+
54
+ function getGatewayPidPath(instanceDir: string): string {
55
+ return path.join(instanceDir, ".vellum", "gateway.pid");
56
+ }
57
+
58
+ function readPidState(pidFile: string): PidState {
59
+ if (!existsSync(pidFile)) return { state: "missing" };
60
+
61
+ const updatedAtMs = statSync(pidFile).mtimeMs;
62
+ const value = readFileSync(pidFile, "utf-8").trim();
63
+ if (!value) return { state: "missing" };
64
+ if (value === "starting") return { state: "starting", updatedAtMs };
65
+
66
+ const pid = Number(value);
67
+ if (!Number.isInteger(pid) || pid <= 0) {
68
+ return { state: "invalid", value, updatedAtMs };
69
+ }
70
+
71
+ try {
72
+ process.kill(pid, 0);
73
+ return { state: "alive", pid, updatedAtMs };
74
+ } catch {
75
+ return { state: "dead", pid, updatedAtMs };
76
+ }
77
+ }
78
+
79
+ function isFreshPidState(
80
+ pidState: PidState,
81
+ observedAtMs: number,
82
+ ): boolean {
83
+ return (
84
+ "updatedAtMs" in pidState &&
85
+ observedAtMs - pidState.updatedAtMs <= STARTING_GRACE_MS
86
+ );
87
+ }
88
+
89
+ function httpHealthCheck(port: number): Promise<boolean> {
90
+ return new Promise((resolve) => {
91
+ const req = http.get(
92
+ {
93
+ hostname: "127.0.0.1",
94
+ port,
95
+ path: "/healthz",
96
+ timeout: HEALTH_TIMEOUT_MS,
97
+ },
98
+ (res) => {
99
+ const chunks: Buffer[] = [];
100
+ res.on("data", (chunk: Buffer) => chunks.push(chunk));
101
+ res.on("end", () => {
102
+ if (res.statusCode !== 200) {
103
+ resolve(false);
104
+ return;
105
+ }
106
+
107
+ try {
108
+ const body = JSON.parse(Buffer.concat(chunks).toString()) as {
109
+ status?: string;
110
+ };
111
+ resolve(
112
+ body.status === undefined ||
113
+ body.status === "healthy" ||
114
+ body.status === "ok",
115
+ );
116
+ } catch {
117
+ resolve(true);
118
+ }
119
+ });
120
+ },
121
+ );
122
+
123
+ req.on("timeout", () => {
124
+ req.destroy();
125
+ resolve(false);
126
+ });
127
+ req.on("error", () => resolve(false));
128
+ });
129
+ }
130
+
131
+ function localOnlyEntry(
132
+ entry: LockfileAssistant | undefined,
133
+ ): LockfileAssistant | null {
134
+ if (!entry || (entry.cloud != null && entry.cloud !== "local")) return null;
135
+ return entry;
136
+ }
137
+
138
+ function isRecord(value: unknown): value is Record<string, unknown> {
139
+ return typeof value === "object" && value !== null && !Array.isArray(value);
140
+ }
141
+
142
+ function parsePortFromUrl(url: unknown): number | undefined {
143
+ if (typeof url !== "string") return undefined;
144
+ try {
145
+ const parsed = new URL(url);
146
+ const port = Number(parsed.port);
147
+ return Number.isInteger(port) && port > 0 ? port : undefined;
148
+ } catch {
149
+ return undefined;
150
+ }
151
+ }
152
+
153
+ function defaultPorts(env: Record<string, string | undefined>): {
154
+ daemon: number;
155
+ gateway: number;
156
+ } {
157
+ const envName = env.VELLUM_ENVIRONMENT?.trim() || PRODUCTION_ENVIRONMENT_NAME;
158
+ const seed = SEEDS[envName] ?? SEEDS[PRODUCTION_ENVIRONMENT_NAME];
159
+ return {
160
+ daemon: seed?.portsOverride?.daemon ?? DEFAULT_PORTS.daemon,
161
+ gateway: seed?.portsOverride?.gateway ?? DEFAULT_PORTS.gateway,
162
+ };
163
+ }
164
+
165
+ function defaultInstanceDir(
166
+ env: Record<string, string | undefined>,
167
+ assistantId: string,
168
+ ): string {
169
+ const envName = env.VELLUM_ENVIRONMENT?.trim() || PRODUCTION_ENVIRONMENT_NAME;
170
+ const xdgDataHome =
171
+ env.XDG_DATA_HOME?.trim() || path.join(os.homedir(), ".local", "share");
172
+ const dataRoot =
173
+ envName === PRODUCTION_ENVIRONMENT_NAME ? "vellum" : `vellum-${envName}`;
174
+ return path.join(xdgDataHome, dataRoot, "assistants", assistantId);
175
+ }
176
+
177
+ function firstString(...values: unknown[]): string | undefined {
178
+ for (const value of values) {
179
+ if (typeof value === "string" && value.length > 0) return value;
180
+ }
181
+ return undefined;
182
+ }
183
+
184
+ function firstNumber(...values: unknown[]): number | undefined {
185
+ for (const value of values) {
186
+ if (typeof value === "number" && Number.isFinite(value)) return value;
187
+ }
188
+ return undefined;
189
+ }
190
+
191
+ function findRawAssistant(
192
+ lockfilePaths: string[],
193
+ assistantId: string,
194
+ ): Record<string, unknown> | null {
195
+ for (const candidate of lockfilePaths) {
196
+ let data: unknown;
197
+ try {
198
+ data = JSON.parse(readFileSync(candidate, "utf-8"));
199
+ } catch {
200
+ continue;
201
+ }
202
+ if (!isRecord(data) || !Array.isArray(data.assistants)) return null;
203
+ const entry = data.assistants.find(
204
+ (assistant) =>
205
+ isRecord(assistant) && assistant.assistantId === assistantId,
206
+ );
207
+ return isRecord(entry) ? entry : null;
208
+ }
209
+ return null;
210
+ }
211
+
212
+ function resolveStatusResources(
213
+ entry: LockfileAssistant,
214
+ rawEntry: Record<string, unknown> | null,
215
+ env: Record<string, string | undefined>,
216
+ ): StatusResources {
217
+ const rawResources = isRecord(rawEntry?.resources)
218
+ ? rawEntry.resources
219
+ : undefined;
220
+ const ports = defaultPorts(env);
221
+ const instanceDir =
222
+ firstString(
223
+ entry.resources?.instanceDir,
224
+ rawResources?.instanceDir,
225
+ rawEntry?.baseDataDir,
226
+ ) ?? defaultInstanceDir(env, entry.assistantId);
227
+ return {
228
+ instanceDir,
229
+ daemonPort:
230
+ firstNumber(entry.resources?.daemonPort, rawResources?.daemonPort) ??
231
+ ports.daemon,
232
+ gatewayPort:
233
+ firstNumber(entry.resources?.gatewayPort, rawResources?.gatewayPort) ??
234
+ parsePortFromUrl(rawEntry?.localUrl) ??
235
+ parsePortFromUrl(rawEntry?.runtimeUrl ?? entry.runtimeUrl) ??
236
+ ports.gateway,
237
+ };
238
+ }
239
+
240
+ async function runtimeStatusForEntry(
241
+ entry: LockfileAssistant,
242
+ rawEntry: Record<string, unknown> | null,
243
+ env: Record<string, string | undefined>,
244
+ ): Promise<LocalAssistantStatusResult> {
245
+ const resources = resolveStatusResources(entry, rawEntry, env);
246
+ const observedAtMs = Date.now();
247
+
248
+ const assistantPid = readPidState(getDaemonPidPath(resources.instanceDir));
249
+ if (assistantPid.state === "missing") {
250
+ return { ok: true, state: "sleeping" };
251
+ }
252
+ if (assistantPid.state === "starting") {
253
+ return { ok: true, state: "starting" };
254
+ }
255
+ if (assistantPid.state === "dead") {
256
+ return { ok: true, state: "sleeping", pid: assistantPid.pid };
257
+ }
258
+ if (assistantPid.state === "invalid") {
259
+ return {
260
+ ok: true,
261
+ state: "crashed",
262
+ detail: "assistant PID file is invalid",
263
+ };
264
+ }
265
+
266
+ const assistantHealthy = await httpHealthCheck(resources.daemonPort);
267
+ if (!assistantHealthy) {
268
+ if (isFreshPidState(assistantPid, observedAtMs)) {
269
+ return { ok: true, state: "starting", pid: assistantPid.pid };
270
+ }
271
+ return {
272
+ ok: true,
273
+ state: "crashed",
274
+ pid: assistantPid.pid,
275
+ detail: "assistant process is not responding",
276
+ };
277
+ }
278
+
279
+ const gatewayPid = readPidState(getGatewayPidPath(resources.instanceDir));
280
+ if (gatewayPid.state === "starting") {
281
+ return { ok: true, state: "starting", pid: assistantPid.pid };
282
+ }
283
+ if (gatewayPid.state !== "alive") {
284
+ if (
285
+ isFreshPidState(assistantPid, observedAtMs) ||
286
+ isFreshPidState(gatewayPid, observedAtMs)
287
+ ) {
288
+ return { ok: true, state: "starting", pid: assistantPid.pid };
289
+ }
290
+ return {
291
+ ok: true,
292
+ state: "crashed",
293
+ pid: assistantPid.pid,
294
+ detail: "gateway process is not running",
295
+ };
296
+ }
297
+
298
+ const gatewayHealthy = await httpHealthCheck(resources.gatewayPort);
299
+ if (!gatewayHealthy) {
300
+ if (isFreshPidState(gatewayPid, observedAtMs)) {
301
+ return { ok: true, state: "starting", pid: gatewayPid.pid };
302
+ }
303
+ return {
304
+ ok: true,
305
+ state: "crashed",
306
+ pid: gatewayPid.pid,
307
+ detail: "gateway process is not responding",
308
+ };
309
+ }
310
+
311
+ return { ok: true, state: "healthy", pid: assistantPid.pid };
312
+ }
313
+
314
+ export async function getLocalAssistantStatus(
315
+ lockfilePaths: string[],
316
+ assistantId: string,
317
+ env: Record<string, string | undefined> = process.env,
318
+ ): Promise<LocalAssistantStatusResult> {
319
+ const result = getLockfileData(lockfilePaths);
320
+ if (!result.ok) {
321
+ return {
322
+ ok: false,
323
+ status: result.status,
324
+ error: result.error ?? "Failed to read lockfile",
325
+ };
326
+ }
327
+
328
+ const entry = localOnlyEntry(
329
+ result.data.assistants.find(
330
+ (assistant) => assistant.assistantId === assistantId,
331
+ ),
332
+ );
333
+ if (!entry) {
334
+ return { ok: false, status: 404, error: "Local assistant not found" };
335
+ }
336
+
337
+ return runtimeStatusForEntry(
338
+ entry,
339
+ findRawAssistant(lockfilePaths, assistantId),
340
+ env,
341
+ );
342
+ }
@@ -14,6 +14,11 @@ export type WakeResult =
14
14
  | { ok: true }
15
15
  | { ok: false; status: number; error: string };
16
16
 
17
+ export interface WakeOptions {
18
+ /** Pass --repair-guardian to re-provision a missing/expired guardian token. Revokes the assistant's other device-bound tokens, so callers must gate this behind explicit user confirmation. */
19
+ repairGuardian?: boolean;
20
+ }
21
+
17
22
  /**
18
23
  * Start (or restart) a local assistant's daemon and gateway via the CLI's
19
24
  * `wake`, which also re-seeds the guardian token from a sibling environment.
@@ -27,11 +32,17 @@ export type WakeResult =
27
32
  export function runWake(
28
33
  invocation: CliInvocation,
29
34
  assistantId: string,
35
+ options?: WakeOptions,
30
36
  ): Promise<WakeResult> {
31
37
  return new Promise((resolve) => {
32
38
  const child = spawn(
33
39
  invocation.command,
34
- [...invocation.baseArgs, "wake", assistantId],
40
+ [
41
+ ...invocation.baseArgs,
42
+ "wake",
43
+ assistantId,
44
+ ...(options?.repairGuardian ? ["--repair-guardian"] : []),
45
+ ],
35
46
  { stdio: ["ignore", "pipe", "pipe"] },
36
47
  );
37
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.8.12",
3
+ "version": "0.9.0-dev.202606162156.4bad3e5",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -23,6 +23,7 @@
23
23
  "lint": "eslint",
24
24
  "lint:unused": "knip --include files,dependencies,unlisted",
25
25
  "prepack": "node ../scripts/prepack-bundled-deps.mjs",
26
+ "compile:check": "bun build --compile --external react-devtools-core src/index.ts --outfile /dev/null",
26
27
  "test": "bun test",
27
28
  "typecheck": "bunx tsc --noEmit"
28
29
  },
@@ -34,8 +35,7 @@
34
35
  "chalk": "5.6.2",
35
36
  "ink": "6.8.0",
36
37
  "nanoid": "5.1.7",
37
- "react": "19.2.4",
38
- "react-devtools-core": "6.1.5"
38
+ "react": "19.2.4"
39
39
  },
40
40
  "bundledDependencies": [
41
41
  "@vellumai/environments",
@@ -75,12 +75,11 @@ describe("assistant-config", () => {
75
75
  });
76
76
 
77
77
  test("saveAssistantEntry and loadAllAssistants round-trip", () => {
78
- const entry = makeEntry("test-1", undefined, { version: "0.7.0" });
78
+ const entry = makeEntry("test-1");
79
79
  saveAssistantEntry(entry);
80
80
  const all = loadAllAssistants();
81
81
  expect(all).toHaveLength(1);
82
82
  expect(all[0].assistantId).toBe("test-1");
83
- expect(all[0].version).toBe("0.7.0");
84
83
  });
85
84
 
86
85
  test("findAssistantByName returns matching entry", () => {
@@ -1,4 +1,4 @@
1
- import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import {
3
3
  existsSync,
4
4
  mkdirSync,
@@ -11,22 +11,17 @@ import {
11
11
  import { tmpdir } from "os";
12
12
  import { join } from "path";
13
13
 
14
- // Bun's os.homedir() ignores runtime HOME changes, so mock it (same pattern
15
- // as multi-local.test.ts) to keep production-path tests off the real ~/.vellum.
14
+ // Bun's os.homedir() ignores runtime HOME changes, so mock it (via the shared
15
+ // helper) to keep production-path tests off the real ~/.vellum.
16
16
  let fakeHome: string | undefined;
17
- const realOs = await import("node:os");
18
- const osMock = () => ({
19
- ...realOs,
20
- homedir: () => fakeHome ?? realOs.homedir(),
21
- });
22
- mock.module("node:os", osMock);
23
- mock.module("os", osMock);
17
+ await mockOsHomedir((realHomedir) => () => fakeHome ?? realHomedir());
24
18
 
25
19
  import {
26
20
  getOrCreateHostDeviceId,
27
21
  resetHostDeviceIdCache,
28
22
  } from "../lib/device-id.js";
29
23
  import { snapshotEnv } from "./helpers/env.js";
24
+ import { mockOsHomedir } from "./helpers/os-mock.js";
30
25
 
31
26
  const UUID_RE =
32
27
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
@@ -154,10 +149,7 @@ describe("getOrCreateHostDeviceId (production)", () => {
154
149
 
155
150
  test("reuses an existing ~/.vellum/device.json", () => {
156
151
  mkdirSync(join(tempHome, ".vellum"), { recursive: true });
157
- writeFileSync(
158
- deviceFile,
159
- JSON.stringify({ deviceId: "shared-prod-id" }),
160
- );
152
+ writeFileSync(deviceFile, JSON.stringify({ deviceId: "shared-prod-id" }));
161
153
 
162
154
  expect(getOrCreateHostDeviceId()).toBe("shared-prod-id");
163
155
  expect(readFileSync(deviceFile, "utf-8")).toBe(
@@ -0,0 +1,27 @@
1
+ import { mock } from "bun:test";
2
+
3
+ /**
4
+ * Mock `os.homedir()` (both the `"os"` and `"node:os"` specifiers) while
5
+ * keeping every other os export intact.
6
+ *
7
+ * The factory passed to `mock.module()` must only close over plain snapshots
8
+ * captured before the mock is installed. When another test file has already
9
+ * loaded "os", `mock.module()` patches the live namespace in place — a
10
+ * factory that spreads or calls back through that namespace resolves to the
11
+ * mock itself and recurses forever, a synchronous spin that froze the whole
12
+ * suite (and CI) at whichever file loaded next. This helper owns that
13
+ * invariant so test files don't have to.
14
+ *
15
+ * @param makeHomedir Receives the real (pre-mock) `homedir` and returns the
16
+ * replacement implementation.
17
+ */
18
+ export async function mockOsHomedir(
19
+ makeHomedir: (realHomedir: () => string) => () => string,
20
+ ): Promise<void> {
21
+ const realOs = await import("node:os");
22
+ const realOsSnapshot = { ...realOs };
23
+ const homedir = makeHomedir(realOs.homedir);
24
+ const factory = () => ({ ...realOsSnapshot, homedir });
25
+ mock.module("node:os", factory);
26
+ mock.module("os", factory);
27
+ }