@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
package/src/lib/docker.ts CHANGED
@@ -6,6 +6,13 @@ import { dirname, join } from "path";
6
6
  // Direct import — bun embeds this at compile time so it works in compiled binaries.
7
7
  import cliPkg from "../../package.json";
8
8
 
9
+ // Pulled from skills/ — the Meet avatar device-path default is owned by the
10
+ // meet-join skill; importing here keeps the CLI's Docker wiring locked to the
11
+ // same value the bot and config schema use. The shared module is deliberately
12
+ // zero-dep so this import cannot drag unrelated surface into the compiled CLI
13
+ // binary.
14
+ import { AVATAR_DEVICE_PATH_DEFAULT } from "../../../skills/meet-join/shared/avatar-device-path.js";
15
+
9
16
  import {
10
17
  findAssistantByName,
11
18
  saveAssistantEntry,
@@ -13,9 +20,10 @@ import {
13
20
  } from "./assistant-config";
14
21
  import type { AssistantEntry } from "./assistant-config";
15
22
  import { writeInitialConfig } from "./config-utils";
16
- import { DEFAULT_GATEWAY_PORT } from "./constants";
17
23
  import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
18
24
  import type { Species } from "./constants";
25
+ import { getDefaultPorts } from "./environments/paths.js";
26
+ import { getCurrentEnvironment } from "./environments/resolve.js";
19
27
  import { leaseGuardianToken } from "./guardian-token";
20
28
  import { isVellumProcess, stopProcess } from "./process";
21
29
  import { generateInstanceName } from "./random-name";
@@ -39,13 +47,75 @@ export const DOCKERHUB_IMAGES: Record<ServiceName, string> = {
39
47
  };
40
48
 
41
49
  /** Internal ports exposed by each service's Dockerfile. */
42
- export const ASSISTANT_INTERNAL_PORT = 3001;
50
+ export const ASSISTANT_INTERNAL_PORT = 7821;
43
51
  export const GATEWAY_INTERNAL_PORT = 7830;
44
52
 
45
53
  /** Max time to wait for the assistant container to emit the readiness sentinel. */
46
54
  export const DOCKER_READY_TIMEOUT_MS = 3 * 60 * 1000;
47
55
 
56
+ /**
57
+ * Default virtual-camera device path when the Meet avatar feature is
58
+ * enabled. Re-exports the shared
59
+ * {@link ../../../skills/meet-join/shared/avatar-device-path.js AVATAR_DEVICE_PATH_DEFAULT}
60
+ * so the CLI's device-passthrough wiring cannot drift from the bot's
61
+ * Chrome-flag wiring or the workspace config default. Matches the
62
+ * `video_nr=10` value in the README's host-setup section
63
+ * (`skills/meet-join/bot/README.md`). Operators can override the path by
64
+ * setting `VELLUM_MEET_AVATAR_DEVICE` to something other than the default
65
+ * (e.g. `/dev/video11` if a different `video_nr` was used).
66
+ */
67
+ export const DEFAULT_MEET_AVATAR_DEVICE_PATH = AVATAR_DEVICE_PATH_DEFAULT;
68
+
69
+ /**
70
+ * Env-var opt-in for bind-mounting the v4l2loopback virtual-camera device
71
+ * into the assistant container. Set to a truthy value (`1`, `true`, `yes`)
72
+ * to enable; unset or falsy disables the passthrough entirely.
73
+ *
74
+ * Kept as an env-var rather than a config-schema field because the Meet
75
+ * avatar config schema lands in a later PR (PR 5 of the phase-4 plan) —
76
+ * threading the config through the CLI's boot flow now would force a
77
+ * forward dependency. Once the schema lands, the CLI can either keep this
78
+ * env-var as a pre-config override or move the opt-in into the workspace
79
+ * config.
80
+ */
81
+ export const MEET_AVATAR_ENV_VAR = "VELLUM_MEET_AVATAR";
82
+
83
+ /**
84
+ * Override for the virtual-camera device path. Defaults to
85
+ * {@link DEFAULT_MEET_AVATAR_DEVICE_PATH}.
86
+ */
87
+ export const MEET_AVATAR_DEVICE_ENV_VAR = "VELLUM_MEET_AVATAR_DEVICE";
88
+
89
+ /**
90
+ * Resolve the Meet avatar device path to pass through to the assistant
91
+ * container, or `null` if the feature is not opted into. Exported so tests
92
+ * can assert against the env-var parsing without reaching into the shell.
93
+ */
94
+ export function resolveMeetAvatarDevicePath(
95
+ env: NodeJS.ProcessEnv = process.env,
96
+ ): string | null {
97
+ const flag = env[MEET_AVATAR_ENV_VAR];
98
+ if (!flag) return null;
99
+ const normalized = flag.trim().toLowerCase();
100
+ if (
101
+ normalized === "" ||
102
+ normalized === "0" ||
103
+ normalized === "false" ||
104
+ normalized === "no"
105
+ ) {
106
+ return null;
107
+ }
108
+ const override = env[MEET_AVATAR_DEVICE_ENV_VAR];
109
+ return override && override.length > 0
110
+ ? override
111
+ : DEFAULT_MEET_AVATAR_DEVICE_PATH;
112
+ }
113
+
114
+ /** Default memory (GiB) allocated to the Colima VM. */
115
+ const COLIMA_DEFAULT_MEMORY_GIB = 8;
116
+
48
117
  /** Directory for user-local binary installs (no sudo required). */
118
+
49
119
  const LOCAL_BIN_DIR = join(
50
120
  process.env.HOME || process.env.USERPROFILE || ".",
51
121
  ".local",
@@ -294,7 +364,11 @@ async function ensureDockerInstalled(): Promise<void> {
294
364
 
295
365
  console.log("🚀 Docker daemon not running. Starting Colima...");
296
366
  try {
297
- await exec("colima", ["start"]);
367
+ await exec("colima", [
368
+ "start",
369
+ "--memory",
370
+ String(COLIMA_DEFAULT_MEMORY_GIB),
371
+ ]);
298
372
  } catch {
299
373
  // Colima may fail if a previous VM instance is in a corrupt state.
300
374
  // Attempt to delete the stale instance and retry once.
@@ -311,7 +385,11 @@ async function ensureDockerInstalled(): Promise<void> {
311
385
 
312
386
  try {
313
387
  console.log("🔄 Retrying colima start...");
314
- await exec("colima", ["start"]);
388
+ await exec("colima", [
389
+ "start",
390
+ "--memory",
391
+ String(COLIMA_DEFAULT_MEMORY_GIB),
392
+ ]);
315
393
  } catch (retryErr) {
316
394
  const message =
317
395
  retryErr instanceof Error ? retryErr.message : String(retryErr);
@@ -329,6 +407,7 @@ export function dockerResourceNames(instanceName: string) {
329
407
  assistantContainer: `${instanceName}-assistant`,
330
408
  cesContainer: `${instanceName}-credential-executor`,
331
409
  cesSecurityVolume: `${instanceName}-ces-sec`,
410
+ dockerdDataVolume: `${instanceName}-dockerd-data`,
332
411
  gatewayContainer: `${instanceName}-gateway`,
333
412
  gatewaySecurityVolume: `${instanceName}-gateway-sec`,
334
413
  network: `${instanceName}-net`,
@@ -388,6 +467,7 @@ export async function retireDocker(name: string): Promise<void> {
388
467
  res.workspaceVolume,
389
468
  res.cesSecurityVolume,
390
469
  res.gatewaySecurityVolume,
470
+ res.dockerdDataVolume,
391
471
  ]) {
392
472
  try {
393
473
  await exec("docker", ["volume", "rm", vol]);
@@ -501,8 +581,8 @@ function serviceImageConfigs(
501
581
  tag: imageTags["credential-executor"],
502
582
  },
503
583
  gateway: {
504
- context: join(repoRoot, "gateway"),
505
- dockerfile: "Dockerfile",
584
+ context: repoRoot,
585
+ dockerfile: "gateway/Dockerfile",
506
586
  tag: imageTags.gateway,
507
587
  },
508
588
  };
@@ -551,19 +631,53 @@ export function serviceDockerRunArgs(opts: {
551
631
  } = opts;
552
632
  return {
553
633
  assistant: () => {
634
+ // Run the assistant container in Docker-in-Docker (DinD) mode: the
635
+ // container runs its own `dockerd` so the Meet subsystem can spawn
636
+ // sibling meet-bot containers without needing access to the host's
637
+ // Docker engine. This requires:
638
+ // - `--privileged` so the inner dockerd can manage cgroups, iptables,
639
+ // overlayfs mounts, etc.
640
+ // - A dedicated named volume mounted at `/var/lib/docker` so the
641
+ // inner Docker image cache and container state survive restarts of
642
+ // the assistant container.
643
+ // The host's `/var/run/docker.sock` is intentionally NOT mounted — all
644
+ // Meet-bot spawning happens against the inner dockerd.
554
645
  const args: string[] = [
555
646
  "run",
556
647
  "--init",
557
648
  "-d",
649
+ "--privileged",
558
650
  "--name",
559
651
  res.assistantContainer,
560
652
  `--network=${res.network}`,
561
653
  "-p",
562
654
  `${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
655
+ // Published so the Meet subsystem's sibling bot containers can reach
656
+ // the daemon's internal HTTP API at host.docker.internal:<port>.
657
+ //
658
+ // Published on all host interfaces (no `127.0.0.1:` prefix) because on
659
+ // vanilla Linux Docker, `host.docker.internal:host-gateway` resolves
660
+ // to the Docker bridge gateway IP (e.g. 172.17.0.1), not loopback.
661
+ // Packets from sibling containers arrive at the host's bridge
662
+ // interface, and an iptables DNAT rule keyed on dest=127.0.0.1 would
663
+ // not match — causing connection refused. Docker Desktop (macOS/
664
+ // Windows) still works because its VM proxy forwards to the same
665
+ // published port regardless of the binding address.
666
+ //
667
+ // Security tradeoff: the daemon HTTP API is now reachable from the
668
+ // host's LAN (any device that can hit the host IP on this port).
669
+ // This matches the gateway port's existing posture and is acceptable
670
+ // for single-user self-hosted Docker mode per the Phase 1.8 security
671
+ // note. Managed/multi-tenant deployments are out of scope and would
672
+ // require a different design.
673
+ "-p",
674
+ `${ASSISTANT_INTERNAL_PORT}:${ASSISTANT_INTERNAL_PORT}`,
563
675
  "-v",
564
676
  `${res.workspaceVolume}:/workspace`,
565
677
  "-v",
566
678
  `${res.socketVolume}:/run/ces-bootstrap`,
679
+ "-v",
680
+ `${res.dockerdDataVolume}:/var/lib/docker`,
567
681
  "-e",
568
682
  "IS_CONTAINERIZED=true",
569
683
  "-e",
@@ -575,6 +689,10 @@ export function serviceDockerRunArgs(opts: {
575
689
  "-e",
576
690
  "VELLUM_WORKSPACE_DIR=/workspace",
577
691
  "-e",
692
+ "VELLUM_BACKUP_DIR=/workspace/.backups",
693
+ "-e",
694
+ "VELLUM_BACKUP_KEY_PATH=/workspace/.backup.key",
695
+ "-e",
578
696
  "CES_CREDENTIAL_URL=http://localhost:8090",
579
697
  "-e",
580
698
  `GATEWAY_INTERNAL_URL=http://localhost:${GATEWAY_INTERNAL_PORT}`,
@@ -596,6 +714,7 @@ export function serviceDockerRunArgs(opts: {
596
714
  }
597
715
  for (const envVar of [
598
716
  ...Object.values(PROVIDER_ENV_VAR_NAMES),
717
+ "VELLUM_ENVIRONMENT",
599
718
  "VELLUM_PLATFORM_URL",
600
719
  ]) {
601
720
  if (process.env[envVar]) {
@@ -607,6 +726,24 @@ export function serviceDockerRunArgs(opts: {
607
726
  args.push("-e", `${key}=${value}`);
608
727
  }
609
728
  }
729
+ // Optional Meet avatar (v4l2loopback) passthrough. When
730
+ // `VELLUM_MEET_AVATAR=1` is set in the caller's environment, bind the
731
+ // virtual-camera device node (default `/dev/video10`) into the
732
+ // assistant container so the nested `dockerd` can in turn pass it
733
+ // through to the Meet-bot container. The daemon-side equivalent of
734
+ // this opt-in lives on `DockerRunner.run()`'s `avatarDevicePath`
735
+ // option (see `skills/meet-join/daemon/docker-runner.ts`).
736
+ const avatarDevice = resolveMeetAvatarDevicePath();
737
+ if (avatarDevice) {
738
+ args.push(
739
+ "--device",
740
+ `${avatarDevice}:${avatarDevice}`,
741
+ "-e",
742
+ `${MEET_AVATAR_ENV_VAR}=1`,
743
+ "-e",
744
+ `${MEET_AVATAR_DEVICE_ENV_VAR}=${avatarDevice}`,
745
+ );
746
+ }
610
747
  args.push(imageTags.assistant);
611
748
  return args;
612
749
  },
@@ -644,6 +781,9 @@ export function serviceDockerRunArgs(opts: {
644
781
  ...(opts.bootstrapSecret
645
782
  ? ["-e", `GUARDIAN_BOOTSTRAP_SECRET=${opts.bootstrapSecret}`]
646
783
  : []),
784
+ ...(process.env.VELLUM_ENVIRONMENT
785
+ ? ["-e", `VELLUM_ENVIRONMENT=${process.env.VELLUM_ENVIRONMENT}`]
786
+ : []),
647
787
  ...(process.env.VELLUM_PLATFORM_URL
648
788
  ? ["-e", `VELLUM_PLATFORM_URL=${process.env.VELLUM_PLATFORM_URL}`]
649
789
  : []),
@@ -700,6 +840,16 @@ export async function startContainers(
700
840
  },
701
841
  log: (msg: string) => void,
702
842
  ): Promise<void> {
843
+ // Ensure the inner dockerd's data volume exists before mounting it.
844
+ // For instances hatched on Phase 1.10+, this is created in hatchDocker and
845
+ // is a no-op here. For instances that pre-date Phase 1.10 (DinD) and are
846
+ // upgrading in place, Docker would otherwise auto-create the volume on
847
+ // first `-v` mount without our standard ownership/labeling. Creating it
848
+ // explicitly keeps volume provenance consistent across fresh and upgraded
849
+ // instances. `docker volume create` is idempotent for an existing volume
850
+ // of the same name, so this is safe to run on every start.
851
+ await exec("docker", ["volume", "create", opts.res.dockerdDataVolume]);
852
+
703
853
  const runArgs = serviceDockerRunArgs(opts);
704
854
  for (const service of SERVICE_START_ORDER) {
705
855
  log(`🚀 Starting ${service} container...`);
@@ -1008,7 +1158,7 @@ export async function hatchDocker(
1008
1158
  await ensureDockerInstalled();
1009
1159
 
1010
1160
  const instanceName = generateInstanceName(species, name);
1011
- const gatewayPort = DEFAULT_GATEWAY_PORT;
1161
+ const gatewayPort = getDefaultPorts(getCurrentEnvironment()).gateway;
1012
1162
 
1013
1163
  const imageTags: Record<ServiceName, string> = {
1014
1164
  assistant: "",
@@ -1110,6 +1260,7 @@ export async function hatchDocker(
1110
1260
  await exec("docker", ["volume", "create", res.workspaceVolume]);
1111
1261
  await exec("docker", ["volume", "create", res.cesSecurityVolume]);
1112
1262
  await exec("docker", ["volume", "create", res.gatewaySecurityVolume]);
1263
+ await exec("docker", ["volume", "create", res.dockerdDataVolume]);
1113
1264
 
1114
1265
  // Set workspace volume ownership so non-root containers (UID 1001) can write.
1115
1266
  await exec("docker", [
@@ -1165,7 +1316,6 @@ export async function hatchDocker(
1165
1316
  cloud: "docker",
1166
1317
  species,
1167
1318
  hatchedAt: new Date().toISOString(),
1168
- serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
1169
1319
  containerInfo: {
1170
1320
  assistantImage: imageTags.assistant,
1171
1321
  gatewayImage: imageTags.gateway,
@@ -0,0 +1,228 @@
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 base defaults for a bare env with no portsOverride", () => {
204
+ // Bare env literal (no portsOverride) falls through to DEFAULT_PORTS.
205
+ // Real non-prod seeds populate portsOverride — see seeds.test cases.
206
+ expect(getDefaultPorts(dev)).toEqual(getDefaultPorts(prod));
207
+ });
208
+
209
+ test("merges env.portsOverride on top of defaults", () => {
210
+ const env: EnvironmentDefinition = {
211
+ ...dev,
212
+ portsOverride: { daemon: 9999, gateway: 9998 },
213
+ };
214
+ const ports = getDefaultPorts(env);
215
+ expect(ports.daemon).toBe(9999);
216
+ expect(ports.gateway).toBe(9998);
217
+ expect(ports.qdrant).toBe(6333);
218
+ expect(ports.ces).toBe(8090);
219
+ });
220
+
221
+ test("returns a fresh object — mutations do not affect future calls", () => {
222
+ const first = getDefaultPorts(prod);
223
+ first.daemon = 1;
224
+ const second = getDefaultPorts(prod);
225
+ expect(second.daemon).toBe(7821);
226
+ });
227
+ });
228
+ });