@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
package/AGENTS.md CHANGED
@@ -51,10 +51,20 @@ For example, the signing key used for JWT auth between the daemon and gateway is
51
51
 
52
52
  The CLI creates and manages Docker volumes for containerized instances. See the root `AGENTS.md` § Docker Volume Architecture for the full volume layout.
53
53
 
54
- **Volume creation** (`hatch`): Creates four volumes per instance — workspace, gateway-security, ces-security, and socket. The legacy data volume is no longer created.
54
+ **Volume creation** (`hatch`): Creates five volumes per instance — workspace, gateway-security, ces-security, socket, and dockerd-data (the last backs the inner Docker engine used for Meet; see below). The legacy data volume is no longer created.
55
55
 
56
56
  **Volume migration** (`wake`/`hatch`): On startup, existing instances that still have a legacy data volume are migrated. `migrateGatewaySecurityFiles()` and `migrateCesSecurityFiles()` in `lib/docker.ts` copy security files from the data volume to their respective security volumes. Migrations are idempotent and non-fatal.
57
57
 
58
58
  **Volume cleanup** (`retire`): All volumes (including the legacy data volume if it exists) are removed when an instance is retired.
59
59
 
60
- **Volume mount rules**: Each service container receives only the volumes it needs. The assistant never mounts `gateway-security` or `ces-security`. The gateway never mounts `ces-security`. The CES mounts the workspace volume as read-only.
60
+ **Volume mount rules**: Each service container receives only the volumes it needs. The assistant never mounts `gateway-security` or `ces-security`. The gateway never mounts `ces-security`. The CES mounts the workspace volume as read-only. The `dockerd-data` volume is mounted only on the assistant container.
61
+
62
+ **Meet Docker-in-Docker support** (assistant container only): The assistant container runs an inner `dockerd` that hosts the Meet-bot containers as nested children. The CLI supports this by:
63
+
64
+ - Creating a dedicated `<name>-dockerd-data` volume mounted at `/var/lib/docker` so pulled images and container state persist across assistant restarts.
65
+ - Running the assistant container with `--privileged` (or `CAP_SYS_ADMIN` + `CAP_NET_ADMIN`) so the inner dockerd can configure cgroups, overlay mounts, and container networking.
66
+ - No longer bind-mounting the host's `/var/run/docker.sock`; Meet-bot spawning happens entirely inside the assistant container.
67
+
68
+ Both are wired in `serviceDockerRunArgs()` in `lib/docker.ts`.
69
+
70
+ The privileged assistant container is acceptable for single-user local deployments. Managed/multi-tenant mode needs a different spawn model (e.g. a Kubernetes job runner) and is out of scope for this CLI.
package/README.md CHANGED
@@ -105,12 +105,12 @@ Delete a provisioned assistant instance. The cloud provider and connection detai
105
105
  vellum retire <name>
106
106
  ```
107
107
 
108
- The CLI looks up the instance by name in `~/.vellum.lock.json` and determines how to retire it based on the saved `cloud` field:
108
+ The CLI looks up the instance by name in the production lockfile (`~/.vellum.lock.json`) or the env-scoped lockfile under `$XDG_CONFIG_HOME/vellum-<env>/lockfile.json` for non-production environments, then determines how to retire it based on the saved `cloud` field:
109
109
 
110
110
  - **`gcp`** -- Deletes the GCP Compute Engine instance via `gcloud compute instances delete`.
111
111
  - **`aws`** -- Terminates the AWS EC2 instance by looking up the instance ID from its Name tag.
112
- - **`local`** -- Stops the local assistant (`vellum sleep`) and removes the `~/.vellum` directory.
113
- - **`custom`** -- SSHs to the remote host to stop the assistant/gateway and remove the `~/.vellum` directory.
112
+ - **`local`** -- Stops the local assistant (`vellum sleep`) and removes the assistant's instance directory (`resources.instanceDir` in the lockfile; typically `~/.local/share/vellum/assistants/<name>/` for new hatches, or `~/.vellum/` for legacy entries).
113
+ - **`custom`** -- SSHs to the remote host to stop the assistant/gateway and remove the remote `~/.vellum` directory.
114
114
 
115
115
  #### Examples
116
116
 
package/bunfig.toml ADDED
@@ -0,0 +1,6 @@
1
+ [install]
2
+ exact = true
3
+
4
+ [test]
5
+ root = "./src"
6
+ preload = ["./src/__tests__/preload.ts"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -474,3 +474,127 @@ describe("legacy migration via loadAllAssistants", () => {
474
474
  );
475
475
  });
476
476
  });
477
+
478
+ describe("env-scoped lockfile and migration", () => {
479
+ test("migrateLegacyEntry uses env-scoped multi-instance dir in non-prod", () => {
480
+ // GIVEN VELLUM_ENVIRONMENT=dev and an XDG_DATA_HOME override
481
+ const prevEnv = process.env.VELLUM_ENVIRONMENT;
482
+ const prevXdg = process.env.XDG_DATA_HOME;
483
+ const xdgDataHome = mkdtempSync(join(tmpdir(), "cli-xdg-data-"));
484
+ process.env.VELLUM_ENVIRONMENT = "dev";
485
+ process.env.XDG_DATA_HOME = xdgDataHome;
486
+ try {
487
+ // AND a legacy local entry with no resources
488
+ const entry: Record<string, unknown> = {
489
+ assistantId: "dev-bot",
490
+ runtimeUrl: "http://localhost:7830",
491
+ cloud: "local",
492
+ };
493
+
494
+ // WHEN we migrate it
495
+ const changed = migrateLegacyEntry(entry);
496
+
497
+ // THEN resources.instanceDir points at the env-scoped multi-instance dir
498
+ expect(changed).toBe(true);
499
+ const resources = entry.resources as Record<string, unknown>;
500
+ expect(resources.instanceDir).toBe(
501
+ join(xdgDataHome, "vellum-dev", "assistants", "dev-bot"),
502
+ );
503
+ } finally {
504
+ if (prevEnv !== undefined) {
505
+ process.env.VELLUM_ENVIRONMENT = prevEnv;
506
+ } else {
507
+ delete process.env.VELLUM_ENVIRONMENT;
508
+ }
509
+ if (prevXdg !== undefined) {
510
+ process.env.XDG_DATA_HOME = prevXdg;
511
+ } else {
512
+ delete process.env.XDG_DATA_HOME;
513
+ }
514
+ rmSync(xdgDataHome, { recursive: true, force: true });
515
+ }
516
+ });
517
+
518
+ test("readLockfile/writeLockfile use $XDG_CONFIG_HOME/vellum-<env>/lockfile.json in non-prod", () => {
519
+ // The env package's xdgConfigHome() reads process.env.XDG_CONFIG_HOME
520
+ // fresh on every call, so redirecting via that env var works without
521
+ // mocking `os`. We temporarily unset VELLUM_LOCKFILE_DIR so the env
522
+ // package falls through to getConfigDir(env).
523
+ const prevEnv = process.env.VELLUM_ENVIRONMENT;
524
+ const prevXdgConfig = process.env.XDG_CONFIG_HOME;
525
+ const prevLockDir = process.env.VELLUM_LOCKFILE_DIR;
526
+ const xdgConfigHome = mkdtempSync(join(tmpdir(), "cli-xdg-config-"));
527
+ process.env.VELLUM_ENVIRONMENT = "dev";
528
+ process.env.XDG_CONFIG_HOME = xdgConfigHome;
529
+ delete process.env.VELLUM_LOCKFILE_DIR;
530
+ try {
531
+ // WHEN we save an assistant entry (which triggers writeLockfile)
532
+ saveAssistantEntry({
533
+ assistantId: "dev-env-bot",
534
+ runtimeUrl: "http://localhost:7830",
535
+ cloud: "local",
536
+ });
537
+
538
+ // THEN the lockfile lives at the env-scoped XDG config path
539
+ const expectedPath = join(xdgConfigHome, "vellum-dev", "lockfile.json");
540
+ const raw = JSON.parse(readFileSync(expectedPath, "utf-8"));
541
+ expect(raw.assistants).toHaveLength(1);
542
+ expect(raw.assistants[0].assistantId).toBe("dev-env-bot");
543
+
544
+ // AND readLockfile reads it back via loadAllAssistants()
545
+ const all = loadAllAssistants();
546
+ expect(all).toHaveLength(1);
547
+ expect(all[0].assistantId).toBe("dev-env-bot");
548
+ } finally {
549
+ if (prevEnv !== undefined) {
550
+ process.env.VELLUM_ENVIRONMENT = prevEnv;
551
+ } else {
552
+ delete process.env.VELLUM_ENVIRONMENT;
553
+ }
554
+ if (prevXdgConfig !== undefined) {
555
+ process.env.XDG_CONFIG_HOME = prevXdgConfig;
556
+ } else {
557
+ delete process.env.XDG_CONFIG_HOME;
558
+ }
559
+ if (prevLockDir !== undefined) {
560
+ process.env.VELLUM_LOCKFILE_DIR = prevLockDir;
561
+ }
562
+ rmSync(xdgConfigHome, { recursive: true, force: true });
563
+ }
564
+ });
565
+
566
+ test("production lockfile path is unchanged — uses .vellum.lock.json", () => {
567
+ // With VELLUM_ENVIRONMENT unset, the env package resolves production,
568
+ // whose canonical lockfile filename is `.vellum.lock.json`. This test
569
+ // uses the existing VELLUM_LOCKFILE_DIR=testDir override to route the
570
+ // lockfile to a scratch directory but verifies the FILENAME matches
571
+ // the production convention (not the non-prod "lockfile.json").
572
+ const prevEnv = process.env.VELLUM_ENVIRONMENT;
573
+ delete process.env.VELLUM_ENVIRONMENT;
574
+ try {
575
+ // Clear any existing lockfile from earlier tests
576
+ try {
577
+ rmSync(join(testDir, ".vellum.lock.json"));
578
+ } catch {
579
+ // ignore
580
+ }
581
+
582
+ saveAssistantEntry({
583
+ assistantId: "prod-bot",
584
+ runtimeUrl: "http://localhost:7830",
585
+ cloud: "local",
586
+ });
587
+
588
+ // The file should land at testDir/.vellum.lock.json — the production
589
+ // filename — rather than testDir/lockfile.json.
590
+ const prodPath = join(testDir, ".vellum.lock.json");
591
+ const raw = JSON.parse(readFileSync(prodPath, "utf-8"));
592
+ expect(raw.assistants).toHaveLength(1);
593
+ expect(raw.assistants[0].assistantId).toBe("prod-bot");
594
+ } finally {
595
+ if (prevEnv !== undefined) {
596
+ process.env.VELLUM_ENVIRONMENT = prevEnv;
597
+ }
598
+ }
599
+ });
600
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ import { SEEDS } from "../lib/environments/seeds.js";
6
+
7
+ // Drift guard for the three TypeScript sites that each hardcode the set of
8
+ // known environment names:
9
+ //
10
+ // 1. cli/src/lib/environments/seeds.ts — SEEDS record (source of truth)
11
+ // 2. assistant/src/util/platform.ts — KNOWN_ENVIRONMENTS set
12
+ // 3. clients/chrome-extension/native-host/
13
+ // src/lockfile.ts — NON_PRODUCTION_ENVIRONMENTS set
14
+ //
15
+ // Cross-package relative imports don't work here: assistant's tsconfig
16
+ // restricts `include` to its own src tree, and the native host is a
17
+ // standalone TS project with `rootDir: ./src`. So this test parses the
18
+ // literal sets out of the two external files and asserts they agree with
19
+ // CLI's SEEDS.
20
+ //
21
+ // FOLLOW-UP: split the env name list into a shared `packages/environments`
22
+ // package (mirroring `packages/ces-contracts`, `credential-storage`) so
23
+ // all three sites can `import { KNOWN_ENVIRONMENTS }` from one place and
24
+ // this drift guard becomes a compile-time check. Planned alongside CLI-
25
+ // driven context support — see the "Environments" design doc.
26
+
27
+ const REPO_ROOT = join(import.meta.dir, "..", "..", "..");
28
+ const ASSISTANT_PLATFORM = join(
29
+ REPO_ROOT,
30
+ "assistant",
31
+ "src",
32
+ "util",
33
+ "platform.ts",
34
+ );
35
+ const NATIVE_HOST_LOCKFILE = join(
36
+ REPO_ROOT,
37
+ "clients",
38
+ "chrome-extension",
39
+ "native-host",
40
+ "src",
41
+ "lockfile.ts",
42
+ );
43
+
44
+ /**
45
+ * Extract the string literals from a Set constructor body in a TS source
46
+ * file. Looks for `<setName>: ReadonlySet<string> = new Set([ ... ])` and
47
+ * pulls out every `"..."` entry within the array. The match is anchored to
48
+ * the `setName` to avoid picking up unrelated sets that happen to live in
49
+ * the same file.
50
+ */
51
+ function extractSetLiterals(source: string, setName: string): string[] {
52
+ const pattern = new RegExp(
53
+ `${setName}\\s*:\\s*ReadonlySet<string>\\s*=\\s*new Set\\(\\[([^\\]]*)\\]`,
54
+ "m",
55
+ );
56
+ const match = source.match(pattern);
57
+ if (!match) {
58
+ throw new Error(
59
+ `Could not find Set literal for ${setName}. Update the drift-guard regex in env-drift.test.ts.`,
60
+ );
61
+ }
62
+ const body = match[1];
63
+ const literals = body.match(/"([^"]+)"/g) ?? [];
64
+ return literals.map((lit) => lit.slice(1, -1));
65
+ }
66
+
67
+ describe("KNOWN_ENVIRONMENTS drift guard (TS-side)", () => {
68
+ const seedNames = new Set(Object.keys(SEEDS));
69
+
70
+ test("assistant/src/util/platform.ts KNOWN_ENVIRONMENTS matches CLI SEEDS", () => {
71
+ const source = readFileSync(ASSISTANT_PLATFORM, "utf8");
72
+ const assistantNames = new Set(
73
+ extractSetLiterals(source, "KNOWN_ENVIRONMENTS"),
74
+ );
75
+ expect([...assistantNames].sort()).toEqual([...seedNames].sort());
76
+ });
77
+
78
+ test("native-host/src/lockfile.ts NON_PRODUCTION_ENVIRONMENTS matches CLI SEEDS minus production", () => {
79
+ const source = readFileSync(NATIVE_HOST_LOCKFILE, "utf8");
80
+ const nativeNames = new Set(
81
+ extractSetLiterals(source, "NON_PRODUCTION_ENVIRONMENTS"),
82
+ );
83
+ const expected = new Set(seedNames);
84
+ expected.delete("production");
85
+ expect([...nativeNames].sort()).toEqual([...expected].sort());
86
+ });
87
+ });
@@ -0,0 +1,172 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import {
7
+ getOrCreatePersistedDeviceId,
8
+ loadGuardianToken,
9
+ saveGuardianToken,
10
+ type GuardianTokenData,
11
+ } from "../lib/guardian-token.js";
12
+
13
+ function makeTokenData(suffix: string): GuardianTokenData {
14
+ const now = new Date().toISOString();
15
+ return {
16
+ guardianPrincipalId: `principal-${suffix}`,
17
+ accessToken: `access-${suffix}`,
18
+ accessTokenExpiresAt: now,
19
+ refreshToken: `refresh-${suffix}`,
20
+ refreshTokenExpiresAt: now,
21
+ refreshAfter: now,
22
+ isNew: true,
23
+ deviceId: `device-${suffix}`,
24
+ leasedAt: now,
25
+ };
26
+ }
27
+
28
+ describe("guardian-token paths are env-scoped", () => {
29
+ let tempHome: string;
30
+ let savedXdg: string | undefined;
31
+ let savedEnv: string | undefined;
32
+
33
+ beforeEach(() => {
34
+ savedXdg = process.env.XDG_CONFIG_HOME;
35
+ savedEnv = process.env.VELLUM_ENVIRONMENT;
36
+ tempHome = mkdtempSync(join(tmpdir(), "cli-guardian-token-test-"));
37
+ process.env.XDG_CONFIG_HOME = tempHome;
38
+ delete process.env.VELLUM_ENVIRONMENT;
39
+ });
40
+
41
+ afterEach(() => {
42
+ if (savedXdg === undefined) {
43
+ delete process.env.XDG_CONFIG_HOME;
44
+ } else {
45
+ process.env.XDG_CONFIG_HOME = savedXdg;
46
+ }
47
+ if (savedEnv === undefined) {
48
+ delete process.env.VELLUM_ENVIRONMENT;
49
+ } else {
50
+ process.env.VELLUM_ENVIRONMENT = savedEnv;
51
+ }
52
+ rmSync(tempHome, { recursive: true, force: true });
53
+ });
54
+
55
+ test("prod: guardian token lands at $XDG_CONFIG_HOME/vellum/assistants/<id>/guardian-token.json", () => {
56
+ const data = makeTokenData("prod");
57
+ saveGuardianToken("alpha", data);
58
+
59
+ const prodPath = join(
60
+ tempHome,
61
+ "vellum",
62
+ "assistants",
63
+ "alpha",
64
+ "guardian-token.json",
65
+ );
66
+ expect(existsSync(prodPath)).toBe(true);
67
+ const parsed = JSON.parse(readFileSync(prodPath, "utf-8"));
68
+ expect(parsed.guardianPrincipalId).toBe("principal-prod");
69
+
70
+ const loaded = loadGuardianToken("alpha");
71
+ expect(loaded).not.toBeNull();
72
+ expect(loaded!.guardianPrincipalId).toBe("principal-prod");
73
+ });
74
+
75
+ test("dev: guardian token lands at $XDG_CONFIG_HOME/vellum-dev/assistants/<id>/guardian-token.json", () => {
76
+ process.env.VELLUM_ENVIRONMENT = "dev";
77
+ const data = makeTokenData("dev");
78
+ saveGuardianToken("alpha", data);
79
+
80
+ const devPath = join(
81
+ tempHome,
82
+ "vellum-dev",
83
+ "assistants",
84
+ "alpha",
85
+ "guardian-token.json",
86
+ );
87
+ expect(existsSync(devPath)).toBe(true);
88
+
89
+ // Prod directory must NOT have this token
90
+ const prodPath = join(
91
+ tempHome,
92
+ "vellum",
93
+ "assistants",
94
+ "alpha",
95
+ "guardian-token.json",
96
+ );
97
+ expect(existsSync(prodPath)).toBe(false);
98
+
99
+ const loaded = loadGuardianToken("alpha");
100
+ expect(loaded).not.toBeNull();
101
+ expect(loaded!.guardianPrincipalId).toBe("principal-dev");
102
+ });
103
+
104
+ test("same assistant id in prod and dev is isolated on disk", () => {
105
+ // Write prod token for assistant 'alpha'
106
+ delete process.env.VELLUM_ENVIRONMENT;
107
+ saveGuardianToken("alpha", makeTokenData("prod"));
108
+
109
+ // Write dev token for assistant 'alpha'
110
+ process.env.VELLUM_ENVIRONMENT = "dev";
111
+ saveGuardianToken("alpha", makeTokenData("dev"));
112
+
113
+ // Dev load returns dev
114
+ expect(loadGuardianToken("alpha")!.guardianPrincipalId).toBe(
115
+ "principal-dev",
116
+ );
117
+
118
+ // Back to prod — prod token is unchanged
119
+ delete process.env.VELLUM_ENVIRONMENT;
120
+ expect(loadGuardianToken("alpha")!.guardianPrincipalId).toBe(
121
+ "principal-prod",
122
+ );
123
+
124
+ // Both files exist at distinct paths
125
+ const prodPath = join(
126
+ tempHome,
127
+ "vellum",
128
+ "assistants",
129
+ "alpha",
130
+ "guardian-token.json",
131
+ );
132
+ const devPath = join(
133
+ tempHome,
134
+ "vellum-dev",
135
+ "assistants",
136
+ "alpha",
137
+ "guardian-token.json",
138
+ );
139
+ expect(existsSync(prodPath)).toBe(true);
140
+ expect(existsSync(devPath)).toBe(true);
141
+ expect(prodPath).not.toBe(devPath);
142
+ });
143
+
144
+ test("prod: persisted device id lands at $XDG_CONFIG_HOME/vellum/device-id", () => {
145
+ const id = getOrCreatePersistedDeviceId();
146
+ expect(id.length).toBeGreaterThan(0);
147
+
148
+ const prodPath = join(tempHome, "vellum", "device-id");
149
+ expect(existsSync(prodPath)).toBe(true);
150
+ expect(readFileSync(prodPath, "utf-8").trim()).toBe(id);
151
+ });
152
+
153
+ test("dev: persisted device id lands at $XDG_CONFIG_HOME/vellum-dev/device-id", () => {
154
+ process.env.VELLUM_ENVIRONMENT = "dev";
155
+ const id = getOrCreatePersistedDeviceId();
156
+ expect(id.length).toBeGreaterThan(0);
157
+
158
+ const devPath = join(tempHome, "vellum-dev", "device-id");
159
+ expect(existsSync(devPath)).toBe(true);
160
+ expect(readFileSync(devPath, "utf-8").trim()).toBe(id);
161
+
162
+ const prodPath = join(tempHome, "vellum", "device-id");
163
+ expect(existsSync(prodPath)).toBe(false);
164
+ });
165
+
166
+ test("device id is stable across repeated calls in the same env", () => {
167
+ delete process.env.VELLUM_ENVIRONMENT;
168
+ const first = getOrCreatePersistedDeviceId();
169
+ const second = getOrCreatePersistedDeviceId();
170
+ expect(first).toBe(second);
171
+ });
172
+ });
@@ -94,22 +94,69 @@ 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");
102
-
103
- // THEN it gets the home directory as its instance root
104
- expect(res.instanceDir).toBe(testDir);
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
+ });
105
131
 
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);
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");
110
142
 
111
- // AND the PID file is under ~/.vellum/
112
- expect(res.pidFile).toBe(join(testDir, ".vellum", "vellum.pid"));
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
+ }
113
160
  });
114
161
 
115
162
  test("second instance gets distinct ports and dir when first instance is saved", async () => {