@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
@@ -189,18 +189,9 @@ async function upgradeDocker(
189
189
  const versionTag =
190
190
  version ?? (cliPkg.version ? `v${cliPkg.version}` : "latest");
191
191
 
192
- // Reject downgrades `vellum upgrade` only handles forward version changes.
193
- // Users should use `vellum rollback --version <version>` for downgrades.
194
- const currentVersion = entry.serviceGroupVersion;
195
- if (currentVersion && versionTag) {
196
- const cmp = compareVersions(versionTag, currentVersion);
197
- if (cmp !== null && cmp < 0) {
198
- const msg = `Cannot upgrade to an older version (${versionTag} < ${currentVersion}). Use \`vellum rollback --version ${versionTag}\` instead.`;
199
- console.error(msg);
200
- emitCliError("VERSION_DIRECTION", msg);
201
- process.exit(1);
202
- }
203
- }
192
+ // Fetch the current running version from the health endpoint.
193
+ // This is used for logging, commit messages, and version-direction guards.
194
+ let currentVersion: string | undefined;
204
195
 
205
196
  console.log("🔍 Resolving image references...");
206
197
  const { imageTags } = await resolveImageRefs(versionTag);
@@ -225,7 +216,7 @@ async function upgradeDocker(
225
216
  );
226
217
  }
227
218
 
228
- // Capture current migration state for rollback targeting.
219
+ // Capture current migration state and running version for rollback targeting.
229
220
  // Must happen while daemon is still running (before containers are stopped).
230
221
  let preMigrationState: {
231
222
  dbVersion?: number;
@@ -240,26 +231,47 @@ async function upgradeDocker(
240
231
  );
241
232
  if (healthResp.ok) {
242
233
  const health = (await healthResp.json()) as {
234
+ version?: string;
243
235
  migrations?: { dbVersion?: number; lastWorkspaceMigrationId?: string };
244
236
  };
245
237
  preMigrationState = health.migrations ?? {};
238
+ currentVersion = health.version;
246
239
  }
247
240
  } catch {
248
241
  // Best-effort — if we can't get migration state, rollback will skip migration reversal
249
242
  }
250
243
 
244
+ // Reject downgrades — `vellum upgrade` only handles forward version changes.
245
+ // Users should use `vellum rollback --version <version>` for downgrades.
246
+ if (!currentVersion && versionTag) {
247
+ console.warn(
248
+ "⚠️ Could not determine current version from health endpoint — skipping version-direction check.\n",
249
+ );
250
+ }
251
+ if (currentVersion && versionTag) {
252
+ const cmp = compareVersions(versionTag, currentVersion);
253
+ if (cmp !== null && cmp < 0) {
254
+ const msg = `Cannot upgrade to an older version (${versionTag} < ${currentVersion}). Use \`vellum rollback --version ${versionTag}\` instead.`;
255
+ console.error(msg);
256
+ emitCliError("VERSION_DIRECTION", msg);
257
+ process.exit(1);
258
+ }
259
+ }
260
+
251
261
  // Persist rollback state to lockfile BEFORE any destructive changes.
252
262
  // This enables the `vellum rollback` command to restore the previous version.
253
- if (entry.serviceGroupVersion && entry.containerInfo) {
263
+ if (entry.containerInfo) {
254
264
  const rollbackEntry: AssistantEntry = {
255
265
  ...entry,
256
- previousServiceGroupVersion: entry.serviceGroupVersion,
257
266
  previousContainerInfo: { ...entry.containerInfo },
267
+ previousVersion: currentVersion,
258
268
  previousDbMigrationVersion: preMigrationState.dbVersion,
259
269
  previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
260
270
  };
261
271
  saveAssistantEntry(rollbackEntry);
262
- console.log(` Saved rollback state: ${entry.serviceGroupVersion}\n`);
272
+ if (currentVersion) {
273
+ console.log(` Saved rollback state: ${currentVersion}\n`);
274
+ }
263
275
  }
264
276
 
265
277
  // Record version transition start in workspace git history
@@ -269,7 +281,7 @@ async function upgradeDocker(
269
281
  buildUpgradeCommitMessage({
270
282
  action: "upgrade",
271
283
  phase: "starting",
272
- from: entry.serviceGroupVersion ?? "unknown",
284
+ from: currentVersion ?? "unknown",
273
285
  to: versionTag,
274
286
  topology: "docker",
275
287
  assistantId: entry.assistantId,
@@ -321,7 +333,7 @@ async function upgradeDocker(
321
333
  await broadcastUpgradeEvent(
322
334
  entry.runtimeUrl,
323
335
  entry.assistantId,
324
- buildCompleteEvent(entry.serviceGroupVersion ?? "unknown", false),
336
+ buildCompleteEvent(currentVersion ?? "unknown", false),
325
337
  );
326
338
  emitCliError("IMAGE_PULL_FAILED", "Failed to pull Docker images", detail);
327
339
  process.exit(1);
@@ -361,7 +373,7 @@ async function upgradeDocker(
361
373
  console.log("📦 Creating pre-upgrade backup...");
362
374
  const backupPath = await createBackup(entry.runtimeUrl, entry.assistantId, {
363
375
  prefix: `${entry.assistantId}-pre-upgrade`,
364
- description: `Pre-upgrade snapshot before ${entry.serviceGroupVersion ?? "unknown"} → ${versionTag}`,
376
+ description: `Pre-upgrade snapshot before ${currentVersion ?? "unknown"} → ${versionTag}`,
365
377
  });
366
378
  if (backupPath) {
367
379
  console.log(` Backup saved: ${backupPath}\n`);
@@ -434,7 +446,6 @@ async function upgradeDocker(
434
446
  const newDigests = await captureImageRefs(res);
435
447
  const updatedEntry: AssistantEntry = {
436
448
  ...entry,
437
- serviceGroupVersion: versionTag,
438
449
  containerInfo: {
439
450
  assistantImage: imageTags.assistant,
440
451
  gatewayImage: imageTags.gateway,
@@ -444,7 +455,6 @@ async function upgradeDocker(
444
455
  cesDigest: newDigests?.["credential-executor"],
445
456
  networkName: res.network,
446
457
  },
447
- previousServiceGroupVersion: entry.serviceGroupVersion,
448
458
  previousContainerInfo: entry.containerInfo,
449
459
  previousDbMigrationVersion: preMigrationState.dbVersion,
450
460
  previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
@@ -467,7 +477,7 @@ async function upgradeDocker(
467
477
  buildUpgradeCommitMessage({
468
478
  action: "upgrade",
469
479
  phase: "complete",
470
- from: entry.serviceGroupVersion ?? "unknown",
480
+ from: currentVersion ?? "unknown",
471
481
  to: versionTag,
472
482
  topology: "docker",
473
483
  assistantId: entry.assistantId,
@@ -584,7 +594,6 @@ async function upgradeDocker(
584
594
  previousImageRefs["credential-executor"],
585
595
  networkName: res.network,
586
596
  },
587
- previousServiceGroupVersion: undefined,
588
597
  previousContainerInfo: undefined,
589
598
  previousDbMigrationVersion: undefined,
590
599
  previousWorkspaceMigrationId: undefined,
@@ -598,9 +607,9 @@ async function upgradeDocker(
598
607
  entry.runtimeUrl,
599
608
  entry.assistantId,
600
609
  buildCompleteEvent(
601
- entry.serviceGroupVersion ?? "unknown",
610
+ currentVersion ?? "unknown",
602
611
  false,
603
- entry.serviceGroupVersion,
612
+ currentVersion,
604
613
  ),
605
614
  );
606
615
 
@@ -621,7 +630,7 @@ async function upgradeDocker(
621
630
  await broadcastUpgradeEvent(
622
631
  entry.runtimeUrl,
623
632
  entry.assistantId,
624
- buildCompleteEvent(entry.serviceGroupVersion ?? "unknown", false),
633
+ buildCompleteEvent(currentVersion ?? "unknown", false),
625
634
  );
626
635
  emitCliError(
627
636
  "ROLLBACK_FAILED",
@@ -641,7 +650,7 @@ async function upgradeDocker(
641
650
  await broadcastUpgradeEvent(
642
651
  entry.runtimeUrl,
643
652
  entry.assistantId,
644
- buildCompleteEvent(entry.serviceGroupVersion ?? "unknown", false),
653
+ buildCompleteEvent(currentVersion ?? "unknown", false),
645
654
  );
646
655
  emitCliError(
647
656
  "ROLLBACK_FAILED",
@@ -657,7 +666,7 @@ async function upgradeDocker(
657
666
  await broadcastUpgradeEvent(
658
667
  entry.runtimeUrl,
659
668
  entry.assistantId,
660
- buildCompleteEvent(entry.serviceGroupVersion ?? "unknown", false),
669
+ buildCompleteEvent(currentVersion ?? "unknown", false),
661
670
  );
662
671
  emitCliError(
663
672
  "ROLLBACK_NO_STATE",
@@ -678,22 +687,6 @@ async function upgradePlatform(
678
687
  entry: AssistantEntry,
679
688
  version: string | null,
680
689
  ): Promise<void> {
681
- // Reject downgrades — `vellum upgrade` only handles forward version changes.
682
- // Users should use `vellum rollback --version <version>` for downgrades.
683
- // Only enforce this guard when the user explicitly passed `--version`.
684
- // When version is null the platform API decides the actual target, so
685
- // we must not block the request based on the local CLI version.
686
- const currentVersion = entry.serviceGroupVersion;
687
- if (version && currentVersion) {
688
- const cmp = compareVersions(version, currentVersion);
689
- if (cmp !== null && cmp < 0) {
690
- const msg = `Cannot upgrade to an older version (${version} < ${currentVersion}). Use \`vellum rollback --version ${version}\` instead.`;
691
- console.error(msg);
692
- emitCliError("VERSION_DIRECTION", msg);
693
- process.exit(1);
694
- }
695
- }
696
-
697
690
  console.log(
698
691
  `🔄 Upgrading platform-hosted assistant '${entry.assistantId}'...\n`,
699
692
  );
@@ -733,7 +726,7 @@ async function upgradePlatform(
733
726
  await broadcastUpgradeEvent(
734
727
  entry.runtimeUrl,
735
728
  entry.assistantId,
736
- buildCompleteEvent(entry.serviceGroupVersion ?? "unknown", false),
729
+ buildCompleteEvent("unknown", false),
737
730
  );
738
731
  } catch {
739
732
  // Best-effort — broadcast may fail if the assistant is unreachable
@@ -755,7 +748,7 @@ async function upgradePlatform(
755
748
  await broadcastUpgradeEvent(
756
749
  entry.runtimeUrl,
757
750
  entry.assistantId,
758
- buildCompleteEvent(entry.serviceGroupVersion ?? "unknown", false),
751
+ buildCompleteEvent("unknown", false),
759
752
  );
760
753
  } catch {
761
754
  // Best-effort — broadcast may fail if the assistant is unreachable
@@ -788,8 +781,8 @@ async function upgradePrepare(
788
781
  entry: AssistantEntry,
789
782
  version: string | null,
790
783
  ): Promise<void> {
791
- const targetVersion = version ?? entry.serviceGroupVersion ?? "unknown";
792
- const currentVersion = entry.serviceGroupVersion ?? "unknown";
784
+ const targetVersion = version ?? "unknown";
785
+ const currentVersion = "unknown";
793
786
 
794
787
  // 1. Broadcast "starting" so the UI shows the progress spinner
795
788
  await broadcastUpgradeEvent(
@@ -857,9 +850,7 @@ async function upgradeFinalize(
857
850
  }
858
851
 
859
852
  const fromVersion = version;
860
- const currentVersion = cliPkg.version
861
- ? `v${cliPkg.version}`
862
- : (entry.serviceGroupVersion ?? "unknown");
853
+ const currentVersion = cliPkg.version ? `v${cliPkg.version}` : "unknown";
863
854
 
864
855
  // 1. Broadcast "complete" so the UI clears the progress spinner
865
856
  await broadcastUpgradeEvent(
@@ -911,7 +902,7 @@ export async function upgrade(): Promise<void> {
911
902
  await broadcastUpgradeEvent(
912
903
  entry.runtimeUrl,
913
904
  entry.assistantId,
914
- buildCompleteEvent(entry.serviceGroupVersion ?? "unknown", false),
905
+ buildCompleteEvent("unknown", false),
915
906
  );
916
907
  emitCliError(categorizeUpgradeError(err), "Upgrade failed", detail);
917
908
  process.exit(1);
@@ -59,6 +59,13 @@ export async function wake(): Promise<void> {
59
59
  return;
60
60
  }
61
61
 
62
+ if (entry.cloud === "apple-container") {
63
+ console.error(
64
+ `Error: '${entry.assistantId}' uses the Apple Containers runtime. Its lifecycle is managed by the macOS app — use the app to start it.`,
65
+ );
66
+ process.exit(1);
67
+ }
68
+
62
69
  if (entry.cloud && entry.cloud !== "local") {
63
70
  console.error(
64
71
  `Error: 'vellum wake' only works with local and docker assistants. '${entry.assistantId}' is a ${entry.cloud} instance.`,
@@ -176,18 +183,22 @@ export async function wake(): Promise<void> {
176
183
  }
177
184
 
178
185
  // Auto-start ngrok if webhook integrations (e.g. Telegram) are configured.
179
- // Set BASE_DATA_DIR so ngrok reads the correct instance config.
186
+ // Scope BASE_DATA_DIR to the woken instance so ngrok reads the correct
187
+ // instance config, then restore on any exit path.
180
188
  const prevBaseDataDir = process.env.BASE_DATA_DIR;
181
189
  process.env.BASE_DATA_DIR = resources.instanceDir;
182
- const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
183
- if (ngrokChild?.pid) {
184
- const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
185
- writeFileSync(ngrokPidFile, String(ngrokChild.pid));
186
- }
187
- if (prevBaseDataDir !== undefined) {
188
- process.env.BASE_DATA_DIR = prevBaseDataDir;
189
- } else {
190
- delete process.env.BASE_DATA_DIR;
190
+ try {
191
+ const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
192
+ if (ngrokChild?.pid) {
193
+ const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
194
+ writeFileSync(ngrokPidFile, String(ngrokChild.pid));
195
+ }
196
+ } finally {
197
+ if (prevBaseDataDir !== undefined) {
198
+ process.env.BASE_DATA_DIR = prevBaseDataDir;
199
+ } else {
200
+ delete process.env.BASE_DATA_DIR;
201
+ }
191
202
  }
192
203
 
193
204
  console.log("Wake complete.");
@@ -1939,8 +1939,14 @@ function ChatApp({
1939
1939
  );
1940
1940
  }
1941
1941
 
1942
+ let username: string;
1943
+ try {
1944
+ username = userInfo().username;
1945
+ } catch {
1946
+ username = "";
1947
+ }
1942
1948
  const hostId = createHash("sha256")
1943
- .update(hostname() + userInfo().username)
1949
+ .update(hostname() + username)
1944
1950
  .digest("hex");
1945
1951
  const payload = JSON.stringify({
1946
1952
  type: "vellum-assistant",
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import { client } from "./commands/client";
7
7
  import { events } from "./commands/events";
8
8
  import { hatch } from "./commands/hatch";
9
9
  import { login, logout, whoami } from "./commands/login";
10
+ import { logs } from "./commands/logs";
10
11
  import { message } from "./commands/message";
11
12
  import { pair } from "./commands/pair";
12
13
  import { ps } from "./commands/ps";
@@ -39,6 +40,7 @@ const commands = {
39
40
  hatch,
40
41
  login,
41
42
  logout,
43
+ logs,
42
44
  message,
43
45
  pair,
44
46
  ps,
@@ -68,6 +70,7 @@ function printHelp(): void {
68
70
  console.log(" client Connect to a hatched assistant");
69
71
  console.log(" events Stream events from a running assistant");
70
72
  console.log(" hatch Create a new assistant instance");
73
+ console.log(" logs View logs from an assistant instance");
71
74
  console.log(" login Log in to the Vellum platform");
72
75
  console.log(" logout Log out of the Vellum platform");
73
76
  console.log(" message Send a message to a running assistant");
@@ -0,0 +1,78 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ ASSISTANT_INTERNAL_PORT,
4
+ dockerResourceNames,
5
+ serviceDockerRunArgs,
6
+ type ServiceName,
7
+ } from "../docker.js";
8
+
9
+ const instanceName = "test-instance";
10
+ const imageTags: Record<ServiceName, string> = {
11
+ assistant: "vellumai/vellum-assistant:test",
12
+ "credential-executor": "vellumai/vellum-credential-executor:test",
13
+ gateway: "vellumai/vellum-gateway:test",
14
+ };
15
+
16
+ function buildAssistantArgs(): string[] {
17
+ const res = dockerResourceNames(instanceName);
18
+ const builders = serviceDockerRunArgs({
19
+ gatewayPort: 7830,
20
+ imageTags,
21
+ instanceName,
22
+ res,
23
+ });
24
+ return builders.assistant();
25
+ }
26
+
27
+ describe("serviceDockerRunArgs — assistant", () => {
28
+ test("runs privileged so the inner dockerd can manage cgroups/iptables/overlayfs", () => {
29
+ const args = buildAssistantArgs();
30
+ expect(args).toContain("--privileged");
31
+ });
32
+
33
+ test("mounts a dedicated named volume at /var/lib/docker for the inner dockerd data store", () => {
34
+ const args = buildAssistantArgs();
35
+ const spec = `${instanceName}-dockerd-data:/var/lib/docker`;
36
+ const mountIndex = args.indexOf(spec);
37
+ expect(mountIndex).toBeGreaterThan(0);
38
+ expect(args[mountIndex - 1]).toBe("-v");
39
+ });
40
+
41
+ test("does NOT bind-mount the host Docker socket (DinD replaces host-socket access)", () => {
42
+ const args = buildAssistantArgs();
43
+ expect(args).not.toContain("/var/run/docker.sock:/var/run/docker.sock");
44
+ });
45
+
46
+ test("does NOT set VELLUM_WORKSPACE_VOLUME_NAME (legacy Phase 1.8 hint, no longer needed in DinD)", () => {
47
+ const args = buildAssistantArgs();
48
+ expect(
49
+ args.some((a) => a.startsWith("VELLUM_WORKSPACE_VOLUME_NAME=")),
50
+ ).toBe(false);
51
+ });
52
+
53
+ test("keeps existing workspace and socket volume mounts intact", () => {
54
+ const args = buildAssistantArgs();
55
+ expect(args).toContain(`${instanceName}-workspace:/workspace`);
56
+ expect(args).toContain(`${instanceName}-socket:/run/ces-bootstrap`);
57
+ });
58
+
59
+ test("preserves existing required env vars", () => {
60
+ const args = buildAssistantArgs();
61
+ expect(args).toContain("IS_CONTAINERIZED=true");
62
+ expect(args).toContain("VELLUM_WORKSPACE_DIR=/workspace");
63
+ expect(args).toContain(`VELLUM_ASSISTANT_NAME=${instanceName}`);
64
+ });
65
+
66
+ test("publishes the assistant HTTP port on all host interfaces so sibling bot containers can reach the daemon via host.docker.internal on both Docker Desktop and Linux", () => {
67
+ const args = buildAssistantArgs();
68
+ // The port mapping is expressed as two adjacent args: "-p" then the spec.
69
+ // Bound to all interfaces (no `127.0.0.1:` prefix) because on vanilla
70
+ // Linux Docker, host.docker.internal:host-gateway resolves to the Docker
71
+ // bridge gateway IP — packets arrive at the bridge interface, not
72
+ // loopback, so a 127.0.0.1 DNAT rule would not match.
73
+ const portSpec = `${ASSISTANT_INTERNAL_PORT}:${ASSISTANT_INTERNAL_PORT}`;
74
+ const portIndex = args.indexOf(portSpec);
75
+ expect(portIndex).toBeGreaterThan(0);
76
+ expect(args[portIndex - 1]).toBe("-p");
77
+ });
78
+ });
@@ -8,7 +8,7 @@ import {
8
8
  writeFileSync,
9
9
  } from "fs";
10
10
  import { homedir } from "os";
11
- import { join } from "path";
11
+ import { dirname, join } from "path";
12
12
 
13
13
  import {
14
14
  DAEMON_INTERNAL_ASSISTANT_ID,
@@ -16,8 +16,13 @@ import {
16
16
  DEFAULT_DAEMON_PORT,
17
17
  DEFAULT_GATEWAY_PORT,
18
18
  DEFAULT_QDRANT_PORT,
19
- LOCKFILE_NAMES,
20
19
  } from "./constants.js";
20
+ import {
21
+ getLockfilePath,
22
+ getLockfilePaths,
23
+ getMultiInstanceDir,
24
+ } from "./environments/paths.js";
25
+ import { getCurrentEnvironment } from "./environments/resolve.js";
21
26
  import { probePort } from "./port-probe.js";
22
27
 
23
28
  /**
@@ -27,10 +32,11 @@ import { probePort } from "./port-probe.js";
27
32
  */
28
33
  export interface LocalInstanceResources {
29
34
  /**
30
- * Instance-specific data root. The first local assistant uses `~` (home
31
- * directory) with default ports. Subsequent instances are placed under
32
- * `~/.local/share/vellum/assistants/<name>/`.
33
- * The daemon's `.vellum/` directory lives inside it.
35
+ * Instance-specific data root. New local assistants are placed under
36
+ * `$XDG_DATA_HOME/vellum{-env}/assistants/<name>/`. Legacy entries
37
+ * (pre env-data-layout) may still point at `~` — the read path honors
38
+ * whatever `instanceDir` is stored. The daemon's `.vellum/` directory
39
+ * lives inside it.
34
40
  */
35
41
  instanceDir: string;
36
42
  /** HTTP port for the daemon runtime server */
@@ -84,18 +90,17 @@ export interface AssistantEntry {
84
90
  resources?: LocalInstanceResources;
85
91
  /** PID of the file watcher process for docker instances hatched with --watch. */
86
92
  watcherPid?: number;
87
- /** Last-known version of the service group, populated at hatch and updated by health checks. */
88
- serviceGroupVersion?: string;
89
93
  /** Docker image metadata for rollback. Only present for docker topology entries. */
90
94
  containerInfo?: ContainerInfo;
91
- /** The service group version that was running before the last upgrade. */
92
- previousServiceGroupVersion?: string;
93
95
  /** Docker image metadata from before the last upgrade. Enables rollback to the prior version. */
94
96
  previousContainerInfo?: ContainerInfo;
95
97
  /** Path to the .vbundle backup created for the most recent upgrade. Used by rollback to restore
96
98
  * only the backup from the specific upgrade being rolled back — never a stale backup from a
97
99
  * previous upgrade cycle. */
98
100
  preUpgradeBackupPath?: string;
101
+ /** Running version of the service group at the time of the last upgrade, as reported by
102
+ * the health endpoint. Used by saved-state rollback for logging / broadcast events. */
103
+ previousVersion?: string;
99
104
  /** Pre-upgrade DB migration version — used by rollback to know how far back to revert. */
100
105
  previousDbMigrationVersion?: number;
101
106
  /** Pre-upgrade workspace migration ID — used by rollback to know how far back to revert. */
@@ -114,15 +119,8 @@ export function getBaseDir(): string {
114
119
  return process.env.BASE_DATA_DIR?.trim() || homedir();
115
120
  }
116
121
 
117
- /** The lockfile always lives under the home directory. */
118
- function getLockfileDir(): string {
119
- return process.env.VELLUM_LOCKFILE_DIR?.trim() || homedir();
120
- }
121
-
122
122
  function readLockfile(): LockfileData {
123
- const base = getLockfileDir();
124
- const candidates = LOCKFILE_NAMES.map((name) => join(base, name));
125
- for (const lockfilePath of candidates) {
123
+ for (const lockfilePath of getLockfilePaths(getCurrentEnvironment())) {
126
124
  if (!existsSync(lockfilePath)) continue;
127
125
  try {
128
126
  const raw = readFileSync(lockfilePath, "utf-8");
@@ -138,7 +136,8 @@ function readLockfile(): LockfileData {
138
136
  }
139
137
 
140
138
  function writeLockfile(data: LockfileData): void {
141
- const lockfilePath = join(getLockfileDir(), LOCKFILE_NAMES[0]);
139
+ const lockfilePath = getLockfilePath(getCurrentEnvironment());
140
+ mkdirSync(dirname(lockfilePath), { recursive: true });
142
141
  const tmpPath = `${lockfilePath}.${randomBytes(4).toString("hex")}.tmp`;
143
142
  try {
144
143
  writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n");
@@ -181,6 +180,13 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
181
180
  return false;
182
181
  }
183
182
 
183
+ // Apple-containers entries are fully managed by the macOS app.
184
+ // Skip legacy migration to avoid corrupting their fields.
185
+ if (raw.cloud === "apple-container") {
186
+ return false;
187
+ }
188
+
189
+ const env = getCurrentEnvironment();
184
190
  let mutated = false;
185
191
 
186
192
  // Migrate top-level `baseDataDir` → `resources.instanceDir`
@@ -202,11 +208,7 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
202
208
  const gatewayPort =
203
209
  parsePortFromUrl(raw.runtimeUrl) ?? DEFAULT_GATEWAY_PORT;
204
210
  const instanceDir = join(
205
- homedir(),
206
- ".local",
207
- "share",
208
- "vellum",
209
- "assistants",
211
+ getMultiInstanceDir(env),
210
212
  typeof raw.assistantId === "string"
211
213
  ? raw.assistantId
212
214
  : DAEMON_INTERNAL_ASSISTANT_ID,
@@ -225,11 +227,7 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
225
227
  const res = raw.resources as Record<string, unknown>;
226
228
  if (!res.instanceDir) {
227
229
  res.instanceDir = join(
228
- homedir(),
229
- ".local",
230
- "share",
231
- "vellum",
232
- "assistants",
230
+ getMultiInstanceDir(env),
233
231
  typeof raw.assistantId === "string"
234
232
  ? raw.assistantId
235
233
  : DAEMON_INTERNAL_ASSISTANT_ID,
@@ -388,23 +386,6 @@ export function saveAssistantEntry(entry: AssistantEntry): void {
388
386
  writeAssistants(entries);
389
387
  }
390
388
 
391
- /**
392
- * Update just the serviceGroupVersion field on a lockfile entry.
393
- * Reads the current entry, updates the version if changed, and writes back.
394
- * No-op if the entry doesn't exist or the version hasn't changed.
395
- */
396
- export function updateServiceGroupVersion(
397
- assistantId: string,
398
- version: string,
399
- ): void {
400
- const entries = readAssistants();
401
- const entry = entries.find((e) => e.assistantId === assistantId);
402
- if (!entry) return;
403
- if (entry.serviceGroupVersion === version) return;
404
- entry.serviceGroupVersion = version;
405
- writeAssistants(entries);
406
- }
407
-
408
389
  /**
409
390
  * Scan upward from `basePort` to find an available port. A port is considered
410
391
  * available when `probePort()` returns false (nothing listening). Scans up to
@@ -428,58 +409,32 @@ async function findAvailablePort(
428
409
 
429
410
  /**
430
411
  * Allocate an isolated set of resources for a named local instance.
431
- * The first local assistant uses the home directory with default ports.
432
- * Subsequent assistants are placed under
433
- * `~/.local/share/vellum/assistants/<name>/` with scanned ports.
412
+ * Every new local assistant is allocated under
413
+ * `$XDG_DATA_HOME/vellum{-env}/assistants/<name>/`. The legacy `~/.vellum/`
414
+ * path is only reached via existing lockfile entries from before this change
415
+ * — the read path honors whatever `resources.instanceDir` is stored, so
416
+ * production users' existing first-local assistants keep their `~/.vellum/`
417
+ * roots unchanged.
434
418
  */
435
419
  export async function allocateLocalResources(
436
420
  instanceName: string,
437
421
  ): Promise<LocalInstanceResources> {
438
- // First local assistant gets the home directory with default ports.
439
- // Respect BASE_DATA_DIR when set (e.g. in e2e tests) so the daemon,
440
- // gateway, and credential store all resolve paths under the same root.
441
- const existingLocals = loadAllAssistants().filter((e) => e.cloud === "local");
442
- if (existingLocals.length === 0) {
443
- const baseDir = getBaseDir();
444
- const vellumDir = join(baseDir, ".vellum");
445
- return {
446
- instanceDir: baseDir,
447
- daemonPort: DEFAULT_DAEMON_PORT,
448
- gatewayPort: DEFAULT_GATEWAY_PORT,
449
- qdrantPort: DEFAULT_QDRANT_PORT,
450
- cesPort: DEFAULT_CES_PORT,
451
- pidFile: join(vellumDir, "vellum.pid"),
452
- };
453
- }
454
-
455
- const instanceDir = join(
456
- homedir(),
457
- ".local",
458
- "share",
459
- "vellum",
460
- "assistants",
461
- instanceName,
462
- );
422
+ const env = getCurrentEnvironment();
423
+ const instanceDir = join(getMultiInstanceDir(env), instanceName);
463
424
  mkdirSync(instanceDir, { recursive: true });
464
425
 
465
426
  // Collect ports already assigned to other local instances in the lockfile.
466
- // Even if those instances are stopped, we must avoid reusing their ports
467
- // to prevent binding collisions when both are woken.
468
427
  const reservedPorts: number[] = [];
469
428
  for (const entry of loadAllAssistants()) {
470
- if (entry.cloud !== "local") continue;
471
- if (entry.resources) {
472
- reservedPorts.push(
473
- entry.resources.daemonPort,
474
- entry.resources.gatewayPort,
475
- entry.resources.qdrantPort,
476
- entry.resources.cesPort,
477
- );
478
- }
429
+ if (entry.cloud !== "local" || !entry.resources) continue;
430
+ reservedPorts.push(
431
+ entry.resources.daemonPort,
432
+ entry.resources.gatewayPort,
433
+ entry.resources.qdrantPort,
434
+ entry.resources.cesPort,
435
+ );
479
436
  }
480
437
 
481
- // Allocate ports sequentially to avoid overlapping ranges assigning the
482
- // same port to multiple services (e.g. daemon 7821-7920 overlaps gateway 7830-7929).
483
438
  const daemonPort = await findAvailablePort(
484
439
  DEFAULT_DAEMON_PORT,
485
440
  reservedPorts,
@@ -510,6 +465,18 @@ export async function allocateLocalResources(
510
465
  };
511
466
  }
512
467
 
468
+ /**
469
+ * Return `platformBaseUrl` from the lockfile, if set. This is the value
470
+ * persisted by {@link syncConfigToLockfile} the last time the active
471
+ * assistant was hatched/waked, and is the source of truth for "which
472
+ * platform does the currently-active assistant target".
473
+ */
474
+ export function getLockfilePlatformBaseUrl(): string | undefined {
475
+ const url = readLockfile().platformBaseUrl;
476
+ if (typeof url === "string" && url.trim()) return url.trim();
477
+ return undefined;
478
+ }
479
+
513
480
  /**
514
481
  * Read the assistant config file and sync client-relevant values to the
515
482
  * lockfile. This lets external tools (e.g. vel) discover the platform URL
package/src/lib/aws.ts CHANGED
@@ -411,7 +411,18 @@ export async function hatchAws(
411
411
  }
412
412
  }
413
413
 
414
- const sshUser = userInfo().username;
414
+ let sshUser: string;
415
+ try {
416
+ sshUser = userInfo().username;
417
+ } catch {
418
+ sshUser = process.env.USER ?? "";
419
+ }
420
+ if (!sshUser) {
421
+ console.error(
422
+ "Error: Could not determine SSH username. Set the USER environment variable and try again.",
423
+ );
424
+ process.exit(1);
425
+ }
415
426
  const hatchedBy = process.env.VELLUM_HATCHED_BY;
416
427
  const providerApiKeys: Record<string, string> = {};
417
428
  for (const [, envVar] of Object.entries(PROVIDER_ENV_VAR_NAMES)) {