@vellumai/cli 0.6.3 → 0.6.5

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 (56) hide show
  1. package/AGENTS.md +12 -2
  2. package/README.md +3 -3
  3. package/bun.lock +17 -17
  4. package/bunfig.toml +6 -0
  5. package/package.json +18 -18
  6. package/src/__tests__/assistant-config.test.ts +124 -0
  7. package/src/__tests__/env-drift.test.ts +87 -0
  8. package/src/__tests__/guardian-token.test.ts +225 -0
  9. package/src/__tests__/llm-provider-env-var-parity.test.ts +64 -0
  10. package/src/__tests__/multi-local.test.ts +90 -13
  11. package/src/__tests__/orphan-detection.test.ts +214 -0
  12. package/src/__tests__/platform-client.test.ts +204 -0
  13. package/src/__tests__/preload.ts +27 -0
  14. package/src/__tests__/ssh-user-guard.test.ts +28 -0
  15. package/src/__tests__/teleport.test.ts +1073 -56
  16. package/src/commands/backup.ts +8 -0
  17. package/src/commands/exec.ts +186 -0
  18. package/src/commands/hatch.ts +1 -1
  19. package/src/commands/login.ts +209 -9
  20. package/src/commands/logs.ts +652 -0
  21. package/src/commands/pair.ts +9 -1
  22. package/src/commands/ps.ts +37 -7
  23. package/src/commands/recover.ts +8 -4
  24. package/src/commands/restore.ts +8 -0
  25. package/src/commands/retire.ts +16 -9
  26. package/src/commands/rollback.ts +32 -33
  27. package/src/commands/ssh.ts +7 -0
  28. package/src/commands/teleport.ts +253 -1
  29. package/src/commands/upgrade.ts +43 -52
  30. package/src/commands/wake.ts +25 -10
  31. package/src/components/DefaultMainScreen.tsx +7 -1
  32. package/src/index.ts +6 -0
  33. package/src/lib/__tests__/docker.test.ts +168 -0
  34. package/src/lib/assistant-config.ts +82 -108
  35. package/src/lib/aws.ts +12 -1
  36. package/src/lib/config-utils.ts +4 -4
  37. package/src/lib/constants.ts +0 -10
  38. package/src/lib/docker.ts +158 -8
  39. package/src/lib/environments/__tests__/paths.test.ts +228 -0
  40. package/src/lib/environments/__tests__/resolve.test.ts +226 -0
  41. package/src/lib/environments/__tests__/seeds.test.ts +72 -0
  42. package/src/lib/environments/paths.ts +109 -0
  43. package/src/lib/environments/resolve.ts +96 -0
  44. package/src/lib/environments/seeds.ts +74 -0
  45. package/src/lib/environments/types.ts +60 -0
  46. package/src/lib/exec-apple-container.ts +122 -0
  47. package/src/lib/gcp.ts +12 -1
  48. package/src/lib/guardian-token.ts +71 -10
  49. package/src/lib/hatch-local.ts +44 -23
  50. package/src/lib/local.ts +47 -5
  51. package/src/lib/orphan-detection.ts +28 -12
  52. package/src/lib/platform-client.ts +354 -24
  53. package/src/lib/retire-apple-container.ts +102 -0
  54. package/src/lib/ssh-apple-container.ts +166 -0
  55. package/src/lib/upgrade-lifecycle.ts +101 -28
  56. package/src/shared/provider-env-vars.ts +30 -6
@@ -94,22 +94,99 @@ describe("multi-local", () => {
94
94
  });
95
95
 
96
96
  describe("allocateLocalResources() produces non-conflicting ports", () => {
97
- test("first instance gets home directory and default ports", async () => {
98
- // GIVEN no local assistants exist in the lockfile
99
-
100
- // WHEN we allocate resources for the first instance
101
- const res = await allocateLocalResources("instance-a");
97
+ test("first instance (prod) gets XDG multi-instance dir with default ports", async () => {
98
+ // GIVEN XDG_DATA_HOME points at a scratch directory and no local
99
+ // assistants exist in the lockfile
100
+ const prevXdg = process.env.XDG_DATA_HOME;
101
+ const xdgDataHome = mkdtempSync(join(tmpdir(), "cli-multi-xdg-data-"));
102
+ process.env.XDG_DATA_HOME = xdgDataHome;
103
+ try {
104
+ // WHEN we allocate resources for the first instance
105
+ const res = await allocateLocalResources("instance-a");
106
+
107
+ // THEN it lands under the XDG multi-instance dir (no "first = home"
108
+ // special case anymore)
109
+ expect(res.instanceDir).toBe(
110
+ join(xdgDataHome, "vellum", "assistants", "instance-a"),
111
+ );
112
+
113
+ // AND it gets the default ports since no other instances exist
114
+ expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
115
+ expect(res.gatewayPort).toBe(DEFAULT_GATEWAY_PORT);
116
+ expect(res.qdrantPort).toBe(DEFAULT_QDRANT_PORT);
117
+
118
+ // AND the PID file is under the instance's .vellum/
119
+ expect(res.pidFile).toBe(
120
+ join(res.instanceDir, ".vellum", "vellum.pid"),
121
+ );
122
+ } finally {
123
+ if (prevXdg !== undefined) {
124
+ process.env.XDG_DATA_HOME = prevXdg;
125
+ } else {
126
+ delete process.env.XDG_DATA_HOME;
127
+ }
128
+ rmSync(xdgDataHome, { recursive: true, force: true });
129
+ }
130
+ });
102
131
 
103
- // THEN it gets the home directory as its instance root
104
- expect(res.instanceDir).toBe(testDir);
132
+ test("first instance (dev) uses env-scoped multi-instance dir", async () => {
133
+ // GIVEN VELLUM_ENVIRONMENT=dev and XDG_DATA_HOME set to scratch
134
+ const prevEnv = process.env.VELLUM_ENVIRONMENT;
135
+ const prevXdg = process.env.XDG_DATA_HOME;
136
+ const xdgDataHome = mkdtempSync(join(tmpdir(), "cli-multi-xdg-dev-"));
137
+ process.env.VELLUM_ENVIRONMENT = "dev";
138
+ process.env.XDG_DATA_HOME = xdgDataHome;
139
+ try {
140
+ // WHEN we allocate resources for the first instance
141
+ const res = await allocateLocalResources("instance-a");
105
142
 
106
- // AND it gets the default ports since no other instances exist
107
- expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
108
- expect(res.gatewayPort).toBe(DEFAULT_GATEWAY_PORT);
109
- expect(res.qdrantPort).toBe(DEFAULT_QDRANT_PORT);
143
+ // THEN it lands under the env-scoped multi-instance dir
144
+ expect(res.instanceDir).toBe(
145
+ join(xdgDataHome, "vellum-dev", "assistants", "instance-a"),
146
+ );
147
+ } finally {
148
+ if (prevEnv !== undefined) {
149
+ process.env.VELLUM_ENVIRONMENT = prevEnv;
150
+ } else {
151
+ delete process.env.VELLUM_ENVIRONMENT;
152
+ }
153
+ if (prevXdg !== undefined) {
154
+ process.env.XDG_DATA_HOME = prevXdg;
155
+ } else {
156
+ delete process.env.XDG_DATA_HOME;
157
+ }
158
+ rmSync(xdgDataHome, { recursive: true, force: true });
159
+ }
160
+ });
110
161
 
111
- // AND the PID file is under ~/.vellum/
112
- expect(res.pidFile).toBe(join(testDir, ".vellum", "vellum.pid"));
162
+ test("allocation picks env-specific port bases for non-prod envs", async () => {
163
+ // Each non-prod env sits in its own 1000-port window (see
164
+ // environments/seeds.ts). Hatching under VELLUM_ENVIRONMENT=dev should
165
+ // produce ports in the dev block (18000+), not the production defaults.
166
+ const prevEnv = process.env.VELLUM_ENVIRONMENT;
167
+ const prevXdg = process.env.XDG_DATA_HOME;
168
+ const xdgDataHome = mkdtempSync(join(tmpdir(), "cli-multi-xdg-ports-"));
169
+ process.env.VELLUM_ENVIRONMENT = "dev";
170
+ process.env.XDG_DATA_HOME = xdgDataHome;
171
+ try {
172
+ const res = await allocateLocalResources("dev-a");
173
+ expect(res.daemonPort).toBe(18000);
174
+ expect(res.gatewayPort).toBe(18100);
175
+ expect(res.qdrantPort).toBe(18200);
176
+ expect(res.cesPort).toBe(18300);
177
+ } finally {
178
+ if (prevEnv !== undefined) {
179
+ process.env.VELLUM_ENVIRONMENT = prevEnv;
180
+ } else {
181
+ delete process.env.VELLUM_ENVIRONMENT;
182
+ }
183
+ if (prevXdg !== undefined) {
184
+ process.env.XDG_DATA_HOME = prevXdg;
185
+ } else {
186
+ delete process.env.XDG_DATA_HOME;
187
+ }
188
+ rmSync(xdgDataHome, { recursive: true, force: true });
189
+ }
113
190
  });
114
191
 
115
192
  test("second instance gets distinct ports and dir when first instance is saved", async () => {
@@ -0,0 +1,214 @@
1
+ import { describe, test, expect, beforeEach, afterAll, mock } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // Redirect lockfile reads/writes to a scratch directory so the test never
7
+ // touches the real `~/.vellum.lock.json`.
8
+ const testDir = mkdtempSync(join(tmpdir(), "cli-orphan-detection-test-"));
9
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
10
+
11
+ // Mock homedir() so `join(homedir(), ".vellum")` inside orphan-detection
12
+ // resolves under the scratch directory — the legacy fallback scan is part
13
+ // of the behavior under test and we need to keep it off the real filesystem.
14
+ const realOs = await import("node:os");
15
+ const fakeHome = mkdtempSync(join(tmpdir(), "cli-orphan-detection-home-"));
16
+ mock.module("node:os", () => ({
17
+ ...realOs,
18
+ homedir: () => fakeHome,
19
+ }));
20
+ mock.module("os", () => ({
21
+ ...realOs,
22
+ homedir: () => fakeHome,
23
+ }));
24
+
25
+ // Stub execOutput so the process-table scan never shells out to `ps`.
26
+ // The PID-file scan is the surface we want to exercise here.
27
+ mock.module("../lib/step-runner", () => ({
28
+ execOutput: () => Promise.resolve(""),
29
+ exec: () => Promise.resolve(undefined),
30
+ }));
31
+
32
+ // Every detected PID claims to be a live process so the scan surfaces it.
33
+ const originalKill = process.kill;
34
+ process.kill = ((pid: number, signal?: string | number) => {
35
+ if (signal === 0) return true;
36
+ return originalKill.call(process, pid, signal);
37
+ }) as typeof process.kill;
38
+
39
+ import { detectOrphanedProcesses } from "../lib/orphan-detection.js";
40
+ import {
41
+ saveAssistantEntry,
42
+ type AssistantEntry,
43
+ type LocalInstanceResources,
44
+ } from "../lib/assistant-config.js";
45
+ import {
46
+ DEFAULT_CES_PORT,
47
+ DEFAULT_DAEMON_PORT,
48
+ DEFAULT_GATEWAY_PORT,
49
+ DEFAULT_QDRANT_PORT,
50
+ } from "../lib/constants.js";
51
+
52
+ afterAll(() => {
53
+ process.kill = originalKill;
54
+ rmSync(testDir, { recursive: true, force: true });
55
+ rmSync(fakeHome, { recursive: true, force: true });
56
+ delete process.env.VELLUM_LOCKFILE_DIR;
57
+ });
58
+
59
+ function resetLockfile(): void {
60
+ for (const name of [".vellum.lock.json", ".vellum.lockfile.json"]) {
61
+ try {
62
+ rmSync(join(testDir, name));
63
+ } catch {
64
+ // file may not exist
65
+ }
66
+ }
67
+ }
68
+
69
+ function resetFakeHome(): void {
70
+ rmSync(fakeHome, { recursive: true, force: true });
71
+ mkdirSync(fakeHome, { recursive: true });
72
+ }
73
+
74
+ function makeResources(
75
+ instanceDir: string,
76
+ ports: Partial<LocalInstanceResources> = {},
77
+ ): LocalInstanceResources {
78
+ return {
79
+ instanceDir,
80
+ daemonPort: ports.daemonPort ?? DEFAULT_DAEMON_PORT,
81
+ gatewayPort: ports.gatewayPort ?? DEFAULT_GATEWAY_PORT,
82
+ qdrantPort: ports.qdrantPort ?? DEFAULT_QDRANT_PORT,
83
+ cesPort: ports.cesPort ?? DEFAULT_CES_PORT,
84
+ pidFile: join(instanceDir, ".vellum", "vellum.pid"),
85
+ };
86
+ }
87
+
88
+ function makeEntry(
89
+ id: string,
90
+ instanceDir: string,
91
+ extra?: Partial<AssistantEntry>,
92
+ ): AssistantEntry {
93
+ return {
94
+ assistantId: id,
95
+ runtimeUrl: `http://localhost:${DEFAULT_GATEWAY_PORT}`,
96
+ cloud: "local",
97
+ resources: makeResources(instanceDir),
98
+ ...extra,
99
+ };
100
+ }
101
+
102
+ function writePidFile(
103
+ instanceDir: string,
104
+ name: "vellum" | "gateway" | "qdrant",
105
+ pid: number,
106
+ ): void {
107
+ const dir = join(instanceDir, ".vellum");
108
+ mkdirSync(dir, { recursive: true });
109
+ writeFileSync(join(dir, `${name}.pid`), String(pid));
110
+ }
111
+
112
+ describe("detectOrphanedProcesses", () => {
113
+ beforeEach(() => {
114
+ resetLockfile();
115
+ resetFakeHome();
116
+ });
117
+
118
+ test("scans every local entry's instanceDir/.vellum and reports each PID", async () => {
119
+ // GIVEN two local entries in the lockfile, each pointing at its own
120
+ // instance directory with a stale PID file
121
+ const instanceA = mkdtempSync(join(tmpdir(), "orphan-instance-a-"));
122
+ const instanceB = mkdtempSync(join(tmpdir(), "orphan-instance-b-"));
123
+ try {
124
+ saveAssistantEntry(makeEntry("alpha", instanceA));
125
+ saveAssistantEntry(
126
+ makeEntry("beta", instanceB, {
127
+ runtimeUrl: "http://localhost:8821",
128
+ }),
129
+ );
130
+ writePidFile(instanceA, "vellum", 111111);
131
+ writePidFile(instanceB, "gateway", 222222);
132
+
133
+ // WHEN we run orphan detection
134
+ const orphans = await detectOrphanedProcesses();
135
+
136
+ // THEN both containers are scanned and each PID is surfaced
137
+ const pidFileOrphans = orphans.filter((o) => o.source === "pid file");
138
+ const pids = pidFileOrphans.map((o) => o.pid);
139
+ expect(pids).toContain("111111");
140
+ expect(pids).toContain("222222");
141
+
142
+ const byName = new Map(pidFileOrphans.map((o) => [o.pid, o.name]));
143
+ expect(byName.get("111111")).toBe("assistant");
144
+ expect(byName.get("222222")).toBe("gateway");
145
+ } finally {
146
+ rmSync(instanceA, { recursive: true, force: true });
147
+ rmSync(instanceB, { recursive: true, force: true });
148
+ }
149
+ });
150
+
151
+ test("still scans legacy ~/.vellum/ when no lockfile entry covers it", async () => {
152
+ // GIVEN no local entries in the lockfile but a stale PID file in the
153
+ // legacy `~/.vellum/` root (pre-upgrade install)
154
+ resetLockfile();
155
+ writePidFile(fakeHome, "vellum", 333333);
156
+
157
+ // WHEN we run orphan detection
158
+ const orphans = await detectOrphanedProcesses();
159
+
160
+ // THEN the legacy root is still scanned and the PID surfaces
161
+ const pids = orphans
162
+ .filter((o) => o.source === "pid file")
163
+ .map((o) => o.pid);
164
+ expect(pids).toContain("333333");
165
+ });
166
+
167
+ test("legacy ~/.vellum/ is scanned alongside multi-instance entries", async () => {
168
+ // GIVEN a local entry AND a legacy `~/.vellum/` PID file at the same time
169
+ const instanceA = mkdtempSync(join(tmpdir(), "orphan-instance-coexist-"));
170
+ try {
171
+ saveAssistantEntry(makeEntry("alpha", instanceA));
172
+ writePidFile(instanceA, "vellum", 444444);
173
+ writePidFile(fakeHome, "gateway", 555555);
174
+
175
+ // WHEN we run orphan detection
176
+ const orphans = await detectOrphanedProcesses();
177
+
178
+ // THEN both the entry's instance dir and the legacy root are scanned
179
+ const pids = orphans
180
+ .filter((o) => o.source === "pid file")
181
+ .map((o) => o.pid);
182
+ expect(pids).toContain("444444");
183
+ expect(pids).toContain("555555");
184
+ } finally {
185
+ rmSync(instanceA, { recursive: true, force: true });
186
+ }
187
+ });
188
+
189
+ test("ignores remote entries — only local entries with resources are scanned", async () => {
190
+ // GIVEN a remote entry (no resources) alongside a local entry
191
+ const instanceA = mkdtempSync(join(tmpdir(), "orphan-instance-local-"));
192
+ try {
193
+ saveAssistantEntry({
194
+ assistantId: "cloud-box",
195
+ runtimeUrl: "http://10.0.0.1:7821",
196
+ cloud: "gcp",
197
+ });
198
+ saveAssistantEntry(makeEntry("alpha", instanceA));
199
+ writePidFile(instanceA, "qdrant", 666666);
200
+
201
+ // WHEN we run orphan detection
202
+ const orphans = await detectOrphanedProcesses();
203
+
204
+ // THEN the local entry's PID still surfaces (the remote entry is
205
+ // silently skipped)
206
+ const pids = orphans
207
+ .filter((o) => o.source === "pid file")
208
+ .map((o) => o.pid);
209
+ expect(pids).toContain("666666");
210
+ } finally {
211
+ rmSync(instanceA, { recursive: true, force: true });
212
+ }
213
+ });
214
+ });
@@ -0,0 +1,204 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import {
13
+ clearPlatformToken,
14
+ getPlatformUrl,
15
+ readPlatformToken,
16
+ savePlatformToken,
17
+ } from "../lib/platform-client.js";
18
+
19
+ describe("platform-client token path is env-scoped", () => {
20
+ let tempHome: string;
21
+ let savedXdg: string | undefined;
22
+ let savedEnv: string | undefined;
23
+
24
+ beforeEach(() => {
25
+ savedXdg = process.env.XDG_CONFIG_HOME;
26
+ savedEnv = process.env.VELLUM_ENVIRONMENT;
27
+ tempHome = mkdtempSync(join(tmpdir(), "cli-platform-client-test-"));
28
+ process.env.XDG_CONFIG_HOME = tempHome;
29
+ delete process.env.VELLUM_ENVIRONMENT;
30
+ });
31
+
32
+ afterEach(() => {
33
+ if (savedXdg === undefined) {
34
+ delete process.env.XDG_CONFIG_HOME;
35
+ } else {
36
+ process.env.XDG_CONFIG_HOME = savedXdg;
37
+ }
38
+ if (savedEnv === undefined) {
39
+ delete process.env.VELLUM_ENVIRONMENT;
40
+ } else {
41
+ process.env.VELLUM_ENVIRONMENT = savedEnv;
42
+ }
43
+ rmSync(tempHome, { recursive: true, force: true });
44
+ });
45
+
46
+ test("prod (VELLUM_ENVIRONMENT unset) writes to $XDG_CONFIG_HOME/vellum/platform-token", () => {
47
+ const token = "vak_prod_token_123";
48
+ savePlatformToken(token);
49
+
50
+ const prodPath = join(tempHome, "vellum", "platform-token");
51
+ expect(existsSync(prodPath)).toBe(true);
52
+ expect(readFileSync(prodPath, "utf-8").trim()).toBe(token);
53
+ expect(readPlatformToken()).toBe(token);
54
+ });
55
+
56
+ test("dev (VELLUM_ENVIRONMENT=dev) writes to $XDG_CONFIG_HOME/vellum-dev/platform-token", () => {
57
+ process.env.VELLUM_ENVIRONMENT = "dev";
58
+ const token = "vak_dev_token_456";
59
+ savePlatformToken(token);
60
+
61
+ const devPath = join(tempHome, "vellum-dev", "platform-token");
62
+ expect(existsSync(devPath)).toBe(true);
63
+ expect(readFileSync(devPath, "utf-8").trim()).toBe(token);
64
+
65
+ const prodPath = join(tempHome, "vellum", "platform-token");
66
+ expect(existsSync(prodPath)).toBe(false);
67
+
68
+ expect(readPlatformToken()).toBe(token);
69
+ });
70
+
71
+ test("prod and dev tokens are isolated on disk", () => {
72
+ // Save prod token
73
+ delete process.env.VELLUM_ENVIRONMENT;
74
+ savePlatformToken("prod-token");
75
+
76
+ // Switch to dev and save a different token
77
+ process.env.VELLUM_ENVIRONMENT = "dev";
78
+ savePlatformToken("dev-token");
79
+
80
+ // Dev read returns dev
81
+ expect(readPlatformToken()).toBe("dev-token");
82
+
83
+ // Switch back to prod — prod value is unchanged
84
+ delete process.env.VELLUM_ENVIRONMENT;
85
+ expect(readPlatformToken()).toBe("prod-token");
86
+
87
+ // Files live at distinct paths
88
+ expect(
89
+ readFileSync(join(tempHome, "vellum", "platform-token"), "utf-8").trim(),
90
+ ).toBe("prod-token");
91
+ expect(
92
+ readFileSync(
93
+ join(tempHome, "vellum-dev", "platform-token"),
94
+ "utf-8",
95
+ ).trim(),
96
+ ).toBe("dev-token");
97
+ });
98
+
99
+ test("clearPlatformToken removes only the env-scoped token", () => {
100
+ // Prod token
101
+ delete process.env.VELLUM_ENVIRONMENT;
102
+ savePlatformToken("prod-token");
103
+
104
+ // Dev token
105
+ process.env.VELLUM_ENVIRONMENT = "dev";
106
+ savePlatformToken("dev-token");
107
+
108
+ // Clear dev
109
+ clearPlatformToken();
110
+ expect(existsSync(join(tempHome, "vellum-dev", "platform-token"))).toBe(
111
+ false,
112
+ );
113
+
114
+ // Prod still there
115
+ expect(existsSync(join(tempHome, "vellum", "platform-token"))).toBe(true);
116
+ });
117
+ });
118
+
119
+ describe("getPlatformUrl resolution order", () => {
120
+ let tempLockDir: string;
121
+ let savedLockDir: string | undefined;
122
+ let savedEnv: string | undefined;
123
+ let savedPlatformUrl: string | undefined;
124
+
125
+ beforeEach(() => {
126
+ savedLockDir = process.env.VELLUM_LOCKFILE_DIR;
127
+ savedEnv = process.env.VELLUM_ENVIRONMENT;
128
+ savedPlatformUrl = process.env.VELLUM_PLATFORM_URL;
129
+ tempLockDir = mkdtempSync(join(tmpdir(), "cli-platform-url-test-"));
130
+ process.env.VELLUM_LOCKFILE_DIR = tempLockDir;
131
+ delete process.env.VELLUM_ENVIRONMENT;
132
+ delete process.env.VELLUM_PLATFORM_URL;
133
+ });
134
+
135
+ afterEach(() => {
136
+ if (savedLockDir === undefined) {
137
+ delete process.env.VELLUM_LOCKFILE_DIR;
138
+ } else {
139
+ process.env.VELLUM_LOCKFILE_DIR = savedLockDir;
140
+ }
141
+ if (savedEnv === undefined) {
142
+ delete process.env.VELLUM_ENVIRONMENT;
143
+ } else {
144
+ process.env.VELLUM_ENVIRONMENT = savedEnv;
145
+ }
146
+ if (savedPlatformUrl === undefined) {
147
+ delete process.env.VELLUM_PLATFORM_URL;
148
+ } else {
149
+ process.env.VELLUM_PLATFORM_URL = savedPlatformUrl;
150
+ }
151
+ rmSync(tempLockDir, { recursive: true, force: true });
152
+ });
153
+
154
+ function writeLockfile(data: Record<string, unknown>): void {
155
+ // VELLUM_ENVIRONMENT is unset → production env → `.vellum.lock.json`.
156
+ writeFileSync(
157
+ join(tempLockDir, ".vellum.lock.json"),
158
+ JSON.stringify(data, null, 2),
159
+ );
160
+ }
161
+
162
+ test("returns lockfile platformBaseUrl when set", () => {
163
+ writeLockfile({ platformBaseUrl: "https://staging.vellum.ai" });
164
+ expect(getPlatformUrl()).toBe("https://staging.vellum.ai");
165
+ });
166
+
167
+ test("lockfile platformBaseUrl takes priority over VELLUM_PLATFORM_URL", () => {
168
+ writeLockfile({ platformBaseUrl: "https://lockfile.vellum.ai" });
169
+ process.env.VELLUM_PLATFORM_URL = "https://env.vellum.ai";
170
+ expect(getPlatformUrl()).toBe("https://lockfile.vellum.ai");
171
+ });
172
+
173
+ test("falls back to VELLUM_PLATFORM_URL when lockfile is missing", () => {
174
+ process.env.VELLUM_PLATFORM_URL = "https://env-only.vellum.ai";
175
+ expect(getPlatformUrl()).toBe("https://env-only.vellum.ai");
176
+ });
177
+
178
+ test("falls back to VELLUM_PLATFORM_URL when lockfile has no platformBaseUrl", () => {
179
+ writeLockfile({ assistants: [] });
180
+ process.env.VELLUM_PLATFORM_URL = "https://env-fallback.vellum.ai";
181
+ expect(getPlatformUrl()).toBe("https://env-fallback.vellum.ai");
182
+ });
183
+
184
+ test("falls back to VELLUM_PLATFORM_URL when lockfile platformBaseUrl is blank", () => {
185
+ writeLockfile({ platformBaseUrl: " " });
186
+ process.env.VELLUM_PLATFORM_URL = "https://env-after-blank.vellum.ai";
187
+ expect(getPlatformUrl()).toBe("https://env-after-blank.vellum.ai");
188
+ });
189
+
190
+ test("falls back to prod env seed URL when lockfile and VELLUM_PLATFORM_URL are unset (prod env)", () => {
191
+ // VELLUM_ENVIRONMENT is unset → production → prod seed URL.
192
+ expect(getPlatformUrl()).toBe("https://platform.vellum.ai");
193
+ });
194
+
195
+ test("falls back to dev env seed URL when VELLUM_ENVIRONMENT=dev", () => {
196
+ process.env.VELLUM_ENVIRONMENT = "dev";
197
+ expect(getPlatformUrl()).toBe("https://dev-platform.vellum.ai");
198
+ });
199
+
200
+ test("trims whitespace from VELLUM_PLATFORM_URL", () => {
201
+ process.env.VELLUM_PLATFORM_URL = " https://trimmed.vellum.ai ";
202
+ expect(getPlatformUrl()).toBe("https://trimmed.vellum.ai");
203
+ });
204
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared CLI test preload — runs before every test file.
3
+ *
4
+ * Sets VELLUM_WORKSPACE_DIR to a temporary directory so that any CLI helper
5
+ * that resolves workspace paths won't accidentally touch the real workspace.
6
+ *
7
+ * Cleanup: the temp dir is removed after all tests in the file complete.
8
+ */
9
+
10
+ import { mkdtempSync, realpathSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { afterAll } from "bun:test";
14
+
15
+ const testDir = realpathSync(
16
+ mkdtempSync(join(tmpdir(), "vellum-cli-test-workspace-")),
17
+ );
18
+ process.env.VELLUM_WORKSPACE_DIR = testDir;
19
+
20
+ afterAll(() => {
21
+ delete process.env.VELLUM_WORKSPACE_DIR;
22
+ try {
23
+ rmSync(testDir, { recursive: true, force: true });
24
+ } catch {
25
+ /* best-effort cleanup */
26
+ }
27
+ });
@@ -0,0 +1,28 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { readFileSync } from "fs";
3
+ import { join } from "path";
4
+
5
+ // Regression guard for PR #25292: hatchAws/hatchGcp must abort before
6
+ // launching a cloud instance when sshUser is empty. Otherwise `useradd ""`
7
+ // in the generated startup script fails after billable resources are live.
8
+ describe("sshUser empty-string guard", () => {
9
+ const files = [
10
+ join(import.meta.dir, "..", "lib", "aws.ts"),
11
+ join(import.meta.dir, "..", "lib", "gcp.ts"),
12
+ ];
13
+
14
+ for (const file of files) {
15
+ test(`${file.split("/").slice(-2).join("/")} aborts on empty sshUser`, () => {
16
+ const source = readFileSync(file, "utf8");
17
+ const fallbackIdx = source.indexOf('sshUser = process.env.USER ?? ""');
18
+ expect(fallbackIdx).toBeGreaterThan(-1);
19
+
20
+ const afterFallback = source.slice(fallbackIdx);
21
+ const guardIdx = afterFallback.search(/if\s*\(\s*!sshUser\s*\)/);
22
+ expect(guardIdx).toBeGreaterThan(-1);
23
+
24
+ const guardBlock = afterFallback.slice(guardIdx, guardIdx + 400);
25
+ expect(guardBlock).toContain("process.exit(1)");
26
+ });
27
+ }
28
+ });