@vellumai/cli 0.6.2 → 0.6.4

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 (50) hide show
  1. package/AGENTS.md +12 -2
  2. package/README.md +3 -3
  3. package/bunfig.toml +6 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/assistant-config.test.ts +124 -0
  6. package/src/__tests__/env-drift.test.ts +87 -0
  7. package/src/__tests__/guardian-token.test.ts +172 -0
  8. package/src/__tests__/multi-local.test.ts +61 -14
  9. package/src/__tests__/orphan-detection.test.ts +214 -0
  10. package/src/__tests__/platform-client.test.ts +204 -0
  11. package/src/__tests__/preload.ts +27 -0
  12. package/src/__tests__/ssh-user-guard.test.ts +28 -0
  13. package/src/__tests__/teleport.test.ts +1073 -57
  14. package/src/commands/backup.ts +8 -0
  15. package/src/commands/hatch.ts +5 -28
  16. package/src/commands/login.ts +178 -9
  17. package/src/commands/logs.ts +652 -0
  18. package/src/commands/pair.ts +9 -1
  19. package/src/commands/ps.ts +37 -7
  20. package/src/commands/recover.ts +8 -4
  21. package/src/commands/restore.ts +124 -12
  22. package/src/commands/retire.ts +17 -3
  23. package/src/commands/rollback.ts +32 -33
  24. package/src/commands/sleep.ts +7 -0
  25. package/src/commands/ssh-apple-container.ts +162 -0
  26. package/src/commands/ssh.ts +7 -0
  27. package/src/commands/teleport.ts +307 -3
  28. package/src/commands/upgrade.ts +43 -52
  29. package/src/commands/wake.ts +21 -10
  30. package/src/components/DefaultMainScreen.tsx +7 -1
  31. package/src/index.ts +3 -0
  32. package/src/lib/__tests__/docker.test.ts +78 -0
  33. package/src/lib/assistant-config.ts +54 -87
  34. package/src/lib/aws.ts +12 -1
  35. package/src/lib/constants.ts +0 -10
  36. package/src/lib/docker.ts +73 -4
  37. package/src/lib/environments/__tests__/paths.test.ts +234 -0
  38. package/src/lib/environments/__tests__/resolve.test.ts +226 -0
  39. package/src/lib/environments/paths.ts +110 -0
  40. package/src/lib/environments/resolve.ts +96 -0
  41. package/src/lib/environments/seeds.ts +46 -0
  42. package/src/lib/environments/types.ts +60 -0
  43. package/src/lib/gcp.ts +12 -1
  44. package/src/lib/guardian-token.ts +8 -10
  45. package/src/lib/hatch-local.ts +30 -35
  46. package/src/lib/local.ts +46 -5
  47. package/src/lib/orphan-detection.ts +28 -12
  48. package/src/lib/platform-client.ts +261 -25
  49. package/src/lib/retire-apple-container.ts +102 -0
  50. package/src/lib/upgrade-lifecycle.ts +101 -28
@@ -16,16 +16,6 @@ export const DEFAULT_GATEWAY_PORT = 7830;
16
16
  export const DEFAULT_QDRANT_PORT = 6333;
17
17
  export const DEFAULT_CES_PORT = 8090;
18
18
 
19
- /**
20
- * Lockfile candidate filenames, checked in priority order.
21
- * `.vellum.lock.json` is the current name; `.vellum.lockfile.json` is the
22
- * legacy name kept for backwards compatibility with older installs.
23
- */
24
- export const LOCKFILE_NAMES = [
25
- ".vellum.lock.json",
26
- ".vellum.lockfile.json",
27
- ] as const;
28
-
29
19
  export const VALID_REMOTE_HOSTS = [
30
20
  "local",
31
21
  "gcp",
package/src/lib/docker.ts CHANGED
@@ -39,13 +39,17 @@ export const DOCKERHUB_IMAGES: Record<ServiceName, string> = {
39
39
  };
40
40
 
41
41
  /** Internal ports exposed by each service's Dockerfile. */
42
- export const ASSISTANT_INTERNAL_PORT = 3001;
42
+ export const ASSISTANT_INTERNAL_PORT = 7821;
43
43
  export const GATEWAY_INTERNAL_PORT = 7830;
44
44
 
45
45
  /** Max time to wait for the assistant container to emit the readiness sentinel. */
46
46
  export const DOCKER_READY_TIMEOUT_MS = 3 * 60 * 1000;
47
47
 
48
+ /** Default memory (GiB) allocated to the Colima VM. */
49
+ const COLIMA_DEFAULT_MEMORY_GIB = 8;
50
+
48
51
  /** Directory for user-local binary installs (no sudo required). */
52
+
49
53
  const LOCAL_BIN_DIR = join(
50
54
  process.env.HOME || process.env.USERPROFILE || ".",
51
55
  ".local",
@@ -294,7 +298,11 @@ async function ensureDockerInstalled(): Promise<void> {
294
298
 
295
299
  console.log("🚀 Docker daemon not running. Starting Colima...");
296
300
  try {
297
- await exec("colima", ["start"]);
301
+ await exec("colima", [
302
+ "start",
303
+ "--memory",
304
+ String(COLIMA_DEFAULT_MEMORY_GIB),
305
+ ]);
298
306
  } catch {
299
307
  // Colima may fail if a previous VM instance is in a corrupt state.
300
308
  // Attempt to delete the stale instance and retry once.
@@ -311,7 +319,11 @@ async function ensureDockerInstalled(): Promise<void> {
311
319
 
312
320
  try {
313
321
  console.log("🔄 Retrying colima start...");
314
- await exec("colima", ["start"]);
322
+ await exec("colima", [
323
+ "start",
324
+ "--memory",
325
+ String(COLIMA_DEFAULT_MEMORY_GIB),
326
+ ]);
315
327
  } catch (retryErr) {
316
328
  const message =
317
329
  retryErr instanceof Error ? retryErr.message : String(retryErr);
@@ -329,6 +341,7 @@ export function dockerResourceNames(instanceName: string) {
329
341
  assistantContainer: `${instanceName}-assistant`,
330
342
  cesContainer: `${instanceName}-credential-executor`,
331
343
  cesSecurityVolume: `${instanceName}-ces-sec`,
344
+ dockerdDataVolume: `${instanceName}-dockerd-data`,
332
345
  gatewayContainer: `${instanceName}-gateway`,
333
346
  gatewaySecurityVolume: `${instanceName}-gateway-sec`,
334
347
  network: `${instanceName}-net`,
@@ -388,6 +401,7 @@ export async function retireDocker(name: string): Promise<void> {
388
401
  res.workspaceVolume,
389
402
  res.cesSecurityVolume,
390
403
  res.gatewaySecurityVolume,
404
+ res.dockerdDataVolume,
391
405
  ]) {
392
406
  try {
393
407
  await exec("docker", ["volume", "rm", vol]);
@@ -551,19 +565,53 @@ export function serviceDockerRunArgs(opts: {
551
565
  } = opts;
552
566
  return {
553
567
  assistant: () => {
568
+ // Run the assistant container in Docker-in-Docker (DinD) mode: the
569
+ // container runs its own `dockerd` so the Meet subsystem can spawn
570
+ // sibling meet-bot containers without needing access to the host's
571
+ // Docker engine. This requires:
572
+ // - `--privileged` so the inner dockerd can manage cgroups, iptables,
573
+ // overlayfs mounts, etc.
574
+ // - A dedicated named volume mounted at `/var/lib/docker` so the
575
+ // inner Docker image cache and container state survive restarts of
576
+ // the assistant container.
577
+ // The host's `/var/run/docker.sock` is intentionally NOT mounted — all
578
+ // Meet-bot spawning happens against the inner dockerd.
554
579
  const args: string[] = [
555
580
  "run",
556
581
  "--init",
557
582
  "-d",
583
+ "--privileged",
558
584
  "--name",
559
585
  res.assistantContainer,
560
586
  `--network=${res.network}`,
561
587
  "-p",
562
588
  `${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
589
+ // Published so the Meet subsystem's sibling bot containers can reach
590
+ // the daemon's internal HTTP API at host.docker.internal:<port>.
591
+ //
592
+ // Published on all host interfaces (no `127.0.0.1:` prefix) because on
593
+ // vanilla Linux Docker, `host.docker.internal:host-gateway` resolves
594
+ // to the Docker bridge gateway IP (e.g. 172.17.0.1), not loopback.
595
+ // Packets from sibling containers arrive at the host's bridge
596
+ // interface, and an iptables DNAT rule keyed on dest=127.0.0.1 would
597
+ // not match — causing connection refused. Docker Desktop (macOS/
598
+ // Windows) still works because its VM proxy forwards to the same
599
+ // published port regardless of the binding address.
600
+ //
601
+ // Security tradeoff: the daemon HTTP API is now reachable from the
602
+ // host's LAN (any device that can hit the host IP on this port).
603
+ // This matches the gateway port's existing posture and is acceptable
604
+ // for single-user self-hosted Docker mode per the Phase 1.8 security
605
+ // note. Managed/multi-tenant deployments are out of scope and would
606
+ // require a different design.
607
+ "-p",
608
+ `${ASSISTANT_INTERNAL_PORT}:${ASSISTANT_INTERNAL_PORT}`,
563
609
  "-v",
564
610
  `${res.workspaceVolume}:/workspace`,
565
611
  "-v",
566
612
  `${res.socketVolume}:/run/ces-bootstrap`,
613
+ "-v",
614
+ `${res.dockerdDataVolume}:/var/lib/docker`,
567
615
  "-e",
568
616
  "IS_CONTAINERIZED=true",
569
617
  "-e",
@@ -575,6 +623,10 @@ export function serviceDockerRunArgs(opts: {
575
623
  "-e",
576
624
  "VELLUM_WORKSPACE_DIR=/workspace",
577
625
  "-e",
626
+ "VELLUM_BACKUP_DIR=/workspace/.backups",
627
+ "-e",
628
+ "VELLUM_BACKUP_KEY_PATH=/workspace/.backup.key",
629
+ "-e",
578
630
  "CES_CREDENTIAL_URL=http://localhost:8090",
579
631
  "-e",
580
632
  `GATEWAY_INTERNAL_URL=http://localhost:${GATEWAY_INTERNAL_PORT}`,
@@ -596,6 +648,7 @@ export function serviceDockerRunArgs(opts: {
596
648
  }
597
649
  for (const envVar of [
598
650
  ...Object.values(PROVIDER_ENV_VAR_NAMES),
651
+ "VELLUM_ENVIRONMENT",
599
652
  "VELLUM_PLATFORM_URL",
600
653
  ]) {
601
654
  if (process.env[envVar]) {
@@ -644,6 +697,12 @@ export function serviceDockerRunArgs(opts: {
644
697
  ...(opts.bootstrapSecret
645
698
  ? ["-e", `GUARDIAN_BOOTSTRAP_SECRET=${opts.bootstrapSecret}`]
646
699
  : []),
700
+ ...(process.env.VELLUM_ENVIRONMENT
701
+ ? ["-e", `VELLUM_ENVIRONMENT=${process.env.VELLUM_ENVIRONMENT}`]
702
+ : []),
703
+ ...(process.env.VELLUM_PLATFORM_URL
704
+ ? ["-e", `VELLUM_PLATFORM_URL=${process.env.VELLUM_PLATFORM_URL}`]
705
+ : []),
647
706
  imageTags.gateway,
648
707
  ],
649
708
  "credential-executor": () => [
@@ -697,6 +756,16 @@ export async function startContainers(
697
756
  },
698
757
  log: (msg: string) => void,
699
758
  ): Promise<void> {
759
+ // Ensure the inner dockerd's data volume exists before mounting it.
760
+ // For instances hatched on Phase 1.10+, this is created in hatchDocker and
761
+ // is a no-op here. For instances that pre-date Phase 1.10 (DinD) and are
762
+ // upgrading in place, Docker would otherwise auto-create the volume on
763
+ // first `-v` mount without our standard ownership/labeling. Creating it
764
+ // explicitly keeps volume provenance consistent across fresh and upgraded
765
+ // instances. `docker volume create` is idempotent for an existing volume
766
+ // of the same name, so this is safe to run on every start.
767
+ await exec("docker", ["volume", "create", opts.res.dockerdDataVolume]);
768
+
700
769
  const runArgs = serviceDockerRunArgs(opts);
701
770
  for (const service of SERVICE_START_ORDER) {
702
771
  log(`🚀 Starting ${service} container...`);
@@ -1107,6 +1176,7 @@ export async function hatchDocker(
1107
1176
  await exec("docker", ["volume", "create", res.workspaceVolume]);
1108
1177
  await exec("docker", ["volume", "create", res.cesSecurityVolume]);
1109
1178
  await exec("docker", ["volume", "create", res.gatewaySecurityVolume]);
1179
+ await exec("docker", ["volume", "create", res.dockerdDataVolume]);
1110
1180
 
1111
1181
  // Set workspace volume ownership so non-root containers (UID 1001) can write.
1112
1182
  await exec("docker", [
@@ -1162,7 +1232,6 @@ export async function hatchDocker(
1162
1232
  cloud: "docker",
1163
1233
  species,
1164
1234
  hatchedAt: new Date().toISOString(),
1165
- serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
1166
1235
  containerInfo: {
1167
1236
  assistantImage: imageTags.assistant,
1168
1237
  gatewayImage: imageTags.gateway,
@@ -0,0 +1,234 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { join } from "path";
3
+
4
+ const TEST_HOME = "/test/home";
5
+
6
+ // Mock homedir() so the helpers return predictable paths regardless of who
7
+ // is running the test. `os.homedir()` is read once per process on Bun/Node
8
+ // and does not reflect later $HOME changes, so setting process.env.HOME at
9
+ // test time does not work — module mocking is the recommended pattern (see
10
+ // cli/src/__tests__/multi-local.test.ts).
11
+ const realOs = await import("node:os");
12
+ mock.module("node:os", () => ({
13
+ ...realOs,
14
+ homedir: () => TEST_HOME,
15
+ }));
16
+ mock.module("os", () => ({
17
+ ...realOs,
18
+ homedir: () => TEST_HOME,
19
+ }));
20
+
21
+ // Imports that depend on the mocked `os` module must come after the
22
+ // mock.module() calls above.
23
+ const {
24
+ getConfigDir,
25
+ getDefaultPorts,
26
+ getLockfilePath,
27
+ getLockfilePaths,
28
+ getMultiInstanceDir,
29
+ } = await import("../paths.js");
30
+ type EnvironmentDefinition = import("../types.js").EnvironmentDefinition;
31
+
32
+ const prod: EnvironmentDefinition = {
33
+ name: "production",
34
+ platformUrl: "https://platform.vellum.ai",
35
+ };
36
+
37
+ const dev: EnvironmentDefinition = {
38
+ name: "dev",
39
+ platformUrl: "https://dev-platform.vellum.ai",
40
+ };
41
+
42
+ const XDG_ENV_VARS = ["XDG_DATA_HOME", "XDG_CONFIG_HOME"] as const;
43
+
44
+ describe("path helpers", () => {
45
+ let savedEnv: Record<string, string | undefined>;
46
+
47
+ beforeEach(() => {
48
+ savedEnv = {};
49
+ for (const key of XDG_ENV_VARS) {
50
+ savedEnv[key] = process.env[key];
51
+ delete process.env[key];
52
+ }
53
+ });
54
+
55
+ afterEach(() => {
56
+ for (const [key, value] of Object.entries(savedEnv)) {
57
+ if (value === undefined) {
58
+ delete process.env[key];
59
+ } else {
60
+ process.env[key] = value;
61
+ }
62
+ }
63
+ });
64
+
65
+ describe("getConfigDir", () => {
66
+ test("production returns ~/.config/vellum/", () => {
67
+ expect(getConfigDir(prod)).toBe(join(TEST_HOME, ".config", "vellum"));
68
+ });
69
+
70
+ test("dev returns ~/.config/vellum-dev/", () => {
71
+ expect(getConfigDir(dev)).toBe(join(TEST_HOME, ".config", "vellum-dev"));
72
+ });
73
+
74
+ test("respects XDG_CONFIG_HOME for non-prod envs", () => {
75
+ process.env.XDG_CONFIG_HOME = "/custom/config";
76
+ expect(getConfigDir(dev)).toBe("/custom/config/vellum-dev");
77
+ });
78
+
79
+ test("respects XDG_CONFIG_HOME for production too", () => {
80
+ // Production's XDG config dir already follows XDG conventions, so the
81
+ // standard XDG override applies.
82
+ process.env.XDG_CONFIG_HOME = "/custom/config";
83
+ expect(getConfigDir(prod)).toBe("/custom/config/vellum");
84
+ });
85
+
86
+ test("respects env.configDirOverride", () => {
87
+ const env: EnvironmentDefinition = {
88
+ ...dev,
89
+ configDirOverride: "/tmp/cfg",
90
+ };
91
+ expect(getConfigDir(env)).toBe("/tmp/cfg");
92
+ });
93
+ });
94
+
95
+ describe("getLockfilePath", () => {
96
+ test("production returns ~/.vellum.lock.json", () => {
97
+ expect(getLockfilePath(prod)).toBe(join(TEST_HOME, ".vellum.lock.json"));
98
+ });
99
+
100
+ test("dev returns ~/.config/vellum-dev/lockfile.json", () => {
101
+ expect(getLockfilePath(dev)).toBe(
102
+ join(TEST_HOME, ".config", "vellum-dev", "lockfile.json"),
103
+ );
104
+ });
105
+
106
+ test("non-prod respects configDirOverride", () => {
107
+ const env: EnvironmentDefinition = {
108
+ ...dev,
109
+ configDirOverride: "/tmp/cfg",
110
+ };
111
+ expect(getLockfilePath(env)).toBe("/tmp/cfg/lockfile.json");
112
+ });
113
+
114
+ test("production respects lockfileDirOverride", () => {
115
+ const env: EnvironmentDefinition = {
116
+ ...prod,
117
+ lockfileDirOverride: "/tmp/lock",
118
+ };
119
+ expect(getLockfilePath(env)).toBe("/tmp/lock/.vellum.lock.json");
120
+ });
121
+
122
+ test("non-prod respects lockfileDirOverride (overrides configDir)", () => {
123
+ const env: EnvironmentDefinition = {
124
+ ...dev,
125
+ configDirOverride: "/tmp/cfg",
126
+ lockfileDirOverride: "/tmp/lock",
127
+ };
128
+ expect(getLockfilePath(env)).toBe("/tmp/lock/lockfile.json");
129
+ });
130
+ });
131
+
132
+ describe("getLockfilePaths", () => {
133
+ test("production returns both current and legacy filenames in priority order", () => {
134
+ expect(getLockfilePaths(prod)).toEqual([
135
+ join(TEST_HOME, ".vellum.lock.json"),
136
+ join(TEST_HOME, ".vellum.lockfile.json"),
137
+ ]);
138
+ });
139
+
140
+ test("non-prod returns a single canonical path", () => {
141
+ expect(getLockfilePaths(dev)).toEqual([
142
+ join(TEST_HOME, ".config", "vellum-dev", "lockfile.json"),
143
+ ]);
144
+ });
145
+
146
+ test("production with lockfileDirOverride applies to both candidates", () => {
147
+ const env: EnvironmentDefinition = {
148
+ ...prod,
149
+ lockfileDirOverride: "/tmp/lock",
150
+ };
151
+ expect(getLockfilePaths(env)).toEqual([
152
+ "/tmp/lock/.vellum.lock.json",
153
+ "/tmp/lock/.vellum.lockfile.json",
154
+ ]);
155
+ });
156
+
157
+ test("non-prod with lockfileDirOverride overrides the config dir", () => {
158
+ const env: EnvironmentDefinition = {
159
+ ...dev,
160
+ lockfileDirOverride: "/tmp/lock",
161
+ };
162
+ expect(getLockfilePaths(env)).toEqual(["/tmp/lock/lockfile.json"]);
163
+ });
164
+
165
+ test("getLockfilePath returns the first entry from getLockfilePaths", () => {
166
+ expect(getLockfilePath(prod)).toBe(getLockfilePaths(prod)[0]);
167
+ expect(getLockfilePath(dev)).toBe(getLockfilePaths(dev)[0]);
168
+ });
169
+ });
170
+
171
+ describe("getMultiInstanceDir", () => {
172
+ test("production returns ~/.local/share/vellum/assistants", () => {
173
+ expect(getMultiInstanceDir(prod)).toBe(
174
+ join(TEST_HOME, ".local", "share", "vellum", "assistants"),
175
+ );
176
+ });
177
+
178
+ test("dev returns ~/.local/share/vellum-dev/assistants", () => {
179
+ expect(getMultiInstanceDir(dev)).toBe(
180
+ join(TEST_HOME, ".local", "share", "vellum-dev", "assistants"),
181
+ );
182
+ });
183
+
184
+ test("respects XDG_DATA_HOME", () => {
185
+ process.env.XDG_DATA_HOME = "/custom/data";
186
+ expect(getMultiInstanceDir(dev)).toBe(
187
+ "/custom/data/vellum-dev/assistants",
188
+ );
189
+ });
190
+ });
191
+
192
+ describe("getDefaultPorts", () => {
193
+ test("returns production defaults for production", () => {
194
+ const ports = getDefaultPorts(prod);
195
+ expect(ports.daemon).toBe(7821);
196
+ expect(ports.gateway).toBe(7830);
197
+ expect(ports.qdrant).toBe(6333);
198
+ expect(ports.ces).toBe(8090);
199
+ expect(ports.outboundProxy).toBe(8080);
200
+ expect(ports.tcp).toBe(8765);
201
+ });
202
+
203
+ test("returns identical defaults for dev (Phase 5 deferred)", () => {
204
+ expect(getDefaultPorts(dev)).toEqual(getDefaultPorts(prod));
205
+ });
206
+
207
+ test("returns identical defaults for staging", () => {
208
+ const staging: EnvironmentDefinition = {
209
+ name: "staging",
210
+ platformUrl: "https://staging-platform.vellum.ai",
211
+ };
212
+ expect(getDefaultPorts(staging)).toEqual(getDefaultPorts(prod));
213
+ });
214
+
215
+ test("merges env.portsOverride on top of defaults", () => {
216
+ const env: EnvironmentDefinition = {
217
+ ...dev,
218
+ portsOverride: { daemon: 9999, gateway: 9998 },
219
+ };
220
+ const ports = getDefaultPorts(env);
221
+ expect(ports.daemon).toBe(9999);
222
+ expect(ports.gateway).toBe(9998);
223
+ expect(ports.qdrant).toBe(6333);
224
+ expect(ports.ces).toBe(8090);
225
+ });
226
+
227
+ test("returns a fresh object — mutations do not affect future calls", () => {
228
+ const first = getDefaultPorts(prod);
229
+ first.daemon = 1;
230
+ const second = getDefaultPorts(prod);
231
+ expect(second.daemon).toBe(7821);
232
+ });
233
+ });
234
+ });
@@ -0,0 +1,226 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+
3
+ import { getCurrentEnvironment, getSeed } from "../resolve.js";
4
+
5
+ const ENV_VARS_TO_SAVE = [
6
+ "VELLUM_ENVIRONMENT",
7
+ "VELLUM_PLATFORM_URL",
8
+ "VELLUM_ASSISTANT_PLATFORM_URL",
9
+ "VELLUM_LOCKFILE_DIR",
10
+ ] as const;
11
+
12
+ describe("getCurrentEnvironment", () => {
13
+ let savedEnv: Record<string, string | undefined>;
14
+
15
+ beforeEach(() => {
16
+ savedEnv = {};
17
+ for (const key of ENV_VARS_TO_SAVE) {
18
+ savedEnv[key] = process.env[key];
19
+ delete process.env[key];
20
+ }
21
+ });
22
+
23
+ afterEach(() => {
24
+ for (const [key, value] of Object.entries(savedEnv)) {
25
+ if (value === undefined) {
26
+ delete process.env[key];
27
+ } else {
28
+ process.env[key] = value;
29
+ }
30
+ }
31
+ });
32
+
33
+ test("returns production seed when no override, no env var", () => {
34
+ const env = getCurrentEnvironment();
35
+ expect(env.name).toBe("production");
36
+ expect(env.platformUrl).toBe("https://platform.vellum.ai");
37
+ });
38
+
39
+ test("returns dev seed when VELLUM_ENVIRONMENT=dev", () => {
40
+ process.env.VELLUM_ENVIRONMENT = "dev";
41
+ const env = getCurrentEnvironment();
42
+ expect(env.name).toBe("dev");
43
+ expect(env.platformUrl).toBe("https://dev-platform.vellum.ai");
44
+ });
45
+
46
+ test("returns staging seed when VELLUM_ENVIRONMENT=staging", () => {
47
+ process.env.VELLUM_ENVIRONMENT = "staging";
48
+ expect(getCurrentEnvironment().platformUrl).toBe(
49
+ "https://staging-platform.vellum.ai",
50
+ );
51
+ });
52
+
53
+ test("returns local seed with localhost URL", () => {
54
+ process.env.VELLUM_ENVIRONMENT = "local";
55
+ expect(getCurrentEnvironment().platformUrl).toBe("http://localhost:8000");
56
+ });
57
+
58
+ test("override argument takes priority over VELLUM_ENVIRONMENT env var", () => {
59
+ process.env.VELLUM_ENVIRONMENT = "staging";
60
+ const env = getCurrentEnvironment("dev");
61
+ expect(env.name).toBe("dev");
62
+ expect(env.platformUrl).toBe("https://dev-platform.vellum.ai");
63
+ });
64
+
65
+ test("empty override argument falls through to env var", () => {
66
+ process.env.VELLUM_ENVIRONMENT = "dev";
67
+ const env = getCurrentEnvironment("");
68
+ expect(env.name).toBe("dev");
69
+ });
70
+
71
+ test("whitespace-only override argument falls through to env var", () => {
72
+ process.env.VELLUM_ENVIRONMENT = "dev";
73
+ const env = getCurrentEnvironment(" ");
74
+ expect(env.name).toBe("dev");
75
+ });
76
+
77
+ test("falls back to production seed for unknown env name via override, warns on stderr", () => {
78
+ const stderr = spyOn(process.stderr, "write").mockImplementation(
79
+ () => true,
80
+ );
81
+ try {
82
+ const env = getCurrentEnvironment("no-such-env");
83
+ expect(env.name).toBe("production");
84
+ expect(env.platformUrl).toBe("https://platform.vellum.ai");
85
+ expect(stderr).toHaveBeenCalledWith(
86
+ expect.stringContaining('unknown environment "no-such-env"'),
87
+ );
88
+ expect(stderr).toHaveBeenCalledWith(
89
+ expect.stringContaining('falling back to "production"'),
90
+ );
91
+ } finally {
92
+ stderr.mockRestore();
93
+ }
94
+ });
95
+
96
+ test("falls back to production seed for unknown env name via env var, warns on stderr", () => {
97
+ process.env.VELLUM_ENVIRONMENT = "nope";
98
+ const stderr = spyOn(process.stderr, "write").mockImplementation(
99
+ () => true,
100
+ );
101
+ try {
102
+ const env = getCurrentEnvironment();
103
+ expect(env.name).toBe("production");
104
+ expect(env.platformUrl).toBe("https://platform.vellum.ai");
105
+ expect(stderr).toHaveBeenCalledWith(
106
+ expect.stringContaining('unknown environment "nope"'),
107
+ );
108
+ } finally {
109
+ stderr.mockRestore();
110
+ }
111
+ });
112
+
113
+ test("VELLUM_ENVIRONMENT=production does not emit a warning", () => {
114
+ process.env.VELLUM_ENVIRONMENT = "production";
115
+ const stderr = spyOn(process.stderr, "write").mockImplementation(
116
+ () => true,
117
+ );
118
+ try {
119
+ const env = getCurrentEnvironment();
120
+ expect(env.name).toBe("production");
121
+ expect(stderr).not.toHaveBeenCalled();
122
+ } finally {
123
+ stderr.mockRestore();
124
+ }
125
+ });
126
+
127
+ test("VELLUM_PLATFORM_URL overrides platformUrl on the resolved definition", () => {
128
+ process.env.VELLUM_ENVIRONMENT = "dev";
129
+ process.env.VELLUM_PLATFORM_URL = "https://custom.example.com";
130
+ const env = getCurrentEnvironment();
131
+ expect(env.name).toBe("dev");
132
+ expect(env.platformUrl).toBe("https://custom.example.com");
133
+ });
134
+
135
+ test("VELLUM_PLATFORM_URL override does not affect the seed table", () => {
136
+ process.env.VELLUM_PLATFORM_URL = "https://custom.example.com";
137
+ getCurrentEnvironment();
138
+ delete process.env.VELLUM_PLATFORM_URL;
139
+ const env = getCurrentEnvironment();
140
+ expect(env.platformUrl).toBe("https://platform.vellum.ai");
141
+ });
142
+
143
+ test("VELLUM_ASSISTANT_PLATFORM_URL overrides assistantPlatformUrl", () => {
144
+ process.env.VELLUM_ASSISTANT_PLATFORM_URL =
145
+ "http://host.docker.internal:8000";
146
+ const env = getCurrentEnvironment();
147
+ expect(env.assistantPlatformUrl).toBe("http://host.docker.internal:8000");
148
+ });
149
+
150
+ test("VELLUM_ASSISTANT_PLATFORM_URL does not shadow platformUrl", () => {
151
+ process.env.VELLUM_ASSISTANT_PLATFORM_URL = "http://override";
152
+ const env = getCurrentEnvironment();
153
+ expect(env.platformUrl).toBe("https://platform.vellum.ai");
154
+ });
155
+
156
+ test("does not auto-materialize a new environment from VELLUM_PLATFORM_URL alone", () => {
157
+ // Unknown env names fall back to production (parity with daemon + Swift).
158
+ // The per-field VELLUM_PLATFORM_URL override is intentionally dropped on
159
+ // the fallback path — fallback returns a pristine production seed so a
160
+ // typo'd env var can't accidentally stitch together a new environment.
161
+ process.env.VELLUM_ENVIRONMENT = "my-custom";
162
+ process.env.VELLUM_PLATFORM_URL = "https://my-custom.example.com";
163
+ const stderr = spyOn(process.stderr, "write").mockImplementation(
164
+ () => true,
165
+ );
166
+ try {
167
+ const env = getCurrentEnvironment();
168
+ expect(env.name).toBe("production");
169
+ expect(env.platformUrl).toBe("https://platform.vellum.ai");
170
+ expect(stderr).toHaveBeenCalledWith(
171
+ expect.stringContaining('unknown environment "my-custom"'),
172
+ );
173
+ } finally {
174
+ stderr.mockRestore();
175
+ }
176
+ });
177
+
178
+ test("VELLUM_LOCKFILE_DIR populates lockfileDirOverride on the resolved definition", () => {
179
+ process.env.VELLUM_LOCKFILE_DIR = "/tmp/test-lockfile-dir";
180
+ const env = getCurrentEnvironment();
181
+ expect(env.lockfileDirOverride).toBe("/tmp/test-lockfile-dir");
182
+ });
183
+
184
+ test("lockfileDirOverride is undefined when VELLUM_LOCKFILE_DIR is unset", () => {
185
+ const env = getCurrentEnvironment();
186
+ expect(env.lockfileDirOverride).toBeUndefined();
187
+ });
188
+
189
+ test("lockfileDirOverride applies to non-prod envs too", () => {
190
+ process.env.VELLUM_ENVIRONMENT = "dev";
191
+ process.env.VELLUM_LOCKFILE_DIR = "/tmp/test-lockfile-dir";
192
+ const env = getCurrentEnvironment();
193
+ expect(env.name).toBe("dev");
194
+ expect(env.lockfileDirOverride).toBe("/tmp/test-lockfile-dir");
195
+ });
196
+ });
197
+
198
+ describe("getSeed", () => {
199
+ test("returns a definition for a known seed", () => {
200
+ const seed = getSeed("dev");
201
+ expect(seed).toBeDefined();
202
+ expect(seed?.name).toBe("dev");
203
+ expect(seed?.platformUrl).toBe("https://dev-platform.vellum.ai");
204
+ });
205
+
206
+ test("returns undefined for an unknown name", () => {
207
+ expect(getSeed("no-such-env")).toBeUndefined();
208
+ });
209
+
210
+ test("returned seed is a copy — mutations do not affect the table", () => {
211
+ const seed = getSeed("dev");
212
+ if (seed) {
213
+ seed.platformUrl = "mutated";
214
+ }
215
+ const second = getSeed("dev");
216
+ expect(second?.platformUrl).toBe("https://dev-platform.vellum.ai");
217
+ });
218
+
219
+ test("all five canonical seeds exist", () => {
220
+ expect(getSeed("production")).toBeDefined();
221
+ expect(getSeed("staging")).toBeDefined();
222
+ expect(getSeed("test")).toBeDefined();
223
+ expect(getSeed("dev")).toBeDefined();
224
+ expect(getSeed("local")).toBeDefined();
225
+ });
226
+ });