@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
@@ -4,12 +4,12 @@ import {
4
4
  findAssistantByName,
5
5
  getActiveAssistant,
6
6
  loadAllAssistants,
7
- updateServiceGroupVersion,
8
7
  type AssistantEntry,
9
8
  } from "../lib/assistant-config";
10
9
  import { loadGuardianToken } from "../lib/guardian-token";
11
10
  import { checkHealth, checkManagedHealth } from "../lib/health-check";
12
11
  import { dockerResourceNames } from "../lib/docker";
12
+ import { existsSync } from "fs";
13
13
  import {
14
14
  classifyProcess,
15
15
  detectOrphanedProcesses,
@@ -335,6 +335,31 @@ async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
335
335
  return;
336
336
  }
337
337
 
338
+ if (cloud === "apple-container") {
339
+ const mgmtSocket = entry.mgmtSocket as string | undefined;
340
+ const socketAlive = mgmtSocket ? existsSync(mgmtSocket) : false;
341
+ const rows: TableRow[] = [
342
+ {
343
+ name: "container",
344
+ status: withStatusEmoji(socketAlive ? "running" : "not running"),
345
+ info: socketAlive
346
+ ? `mgmt ${mgmtSocket}`
347
+ : "management socket not found",
348
+ },
349
+ ];
350
+ if (entry.runtimeUrl) {
351
+ const token = loadGuardianToken(entry.assistantId)?.accessToken;
352
+ const health = await checkHealth(entry.runtimeUrl, token);
353
+ rows.push({
354
+ name: "gateway",
355
+ status: withStatusEmoji(health.status),
356
+ info: entry.runtimeUrl + (health.detail ? ` | ${health.detail}` : ""),
357
+ });
358
+ }
359
+ printTable(rows);
360
+ return;
361
+ }
362
+
338
363
  let output: string;
339
364
  try {
340
365
  if (cloud === "gcp") {
@@ -395,7 +420,8 @@ async function listAllAssistants(): Promise<void> {
395
420
  }
396
421
 
397
422
  const rows: TableRow[] = assistants.map((a) => {
398
- const infoParts = [a.runtimeUrl];
423
+ const infoParts: string[] = [];
424
+ if (a.runtimeUrl) infoParts.push(a.runtimeUrl);
399
425
  if (a.cloud) infoParts.push(`cloud: ${a.cloud}`);
400
426
  if (a.species) infoParts.push(`species: ${a.species}`);
401
427
  const prefix = a.assistantId === activeId ? "* " : " ";
@@ -445,6 +471,13 @@ async function listAllAssistants(): Promise<void> {
445
471
  const token = loadGuardianToken(a.assistantId)?.accessToken;
446
472
  health = await checkHealth(a.localUrl ?? a.runtimeUrl, token);
447
473
  }
474
+ } else if (a.cloud === "apple-container") {
475
+ // Apple containers are managed by the macOS app. Probe the gateway
476
+ // (runtimeUrl is always written to the lockfile during hatch).
477
+ const token = loadGuardianToken(a.assistantId)?.accessToken;
478
+ health = a.runtimeUrl
479
+ ? await checkHealth(a.runtimeUrl, token)
480
+ : { status: "unknown" as const, detail: "no runtime URL" };
448
481
  } else if (a.cloud === "vellum") {
449
482
  health = await checkManagedHealth(a.runtimeUrl, a.assistantId);
450
483
  } else {
@@ -452,11 +485,8 @@ async function listAllAssistants(): Promise<void> {
452
485
  health = await checkHealth(a.localUrl ?? a.runtimeUrl, token);
453
486
  }
454
487
 
455
- if (health.status === "healthy" && health.version) {
456
- updateServiceGroupVersion(a.assistantId, health.version);
457
- }
458
-
459
- const infoParts = [a.runtimeUrl];
488
+ const infoParts: string[] = [];
489
+ if (a.runtimeUrl) infoParts.push(a.runtimeUrl);
460
490
  if (a.cloud) infoParts.push(`cloud: ${a.cloud}`);
461
491
  if (a.species) infoParts.push(`species: ${a.species}`);
462
492
  if (health.detail) infoParts.push(health.detail);
@@ -51,16 +51,20 @@ export async function recover(): Promise<void> {
51
51
  );
52
52
  }
53
53
 
54
- // 3. Check ~/.vellum doesn't already exist
55
- const vellumDir = join(homedir(), ".vellum");
56
- if (existsSync(vellumDir)) {
54
+ // 3. Check that the recovering entry's own target directory is free.
55
+ const target = join(entry.resources.instanceDir, ".vellum");
56
+ if (existsSync(target)) {
57
57
  console.error(
58
- "Error: ~/.vellum already exists. Retire the current assistant first.",
58
+ `Error: ${target} already exists (owned by ${entry.assistantId}). ` +
59
+ `Retire the current assistant first.`,
59
60
  );
60
61
  process.exit(1);
61
62
  }
62
63
 
63
64
  // 4. Extract archive
65
+ // TODO: extraction target is hardcoded to homedir(); multi-instance entries
66
+ // whose instanceDir differs from homedir will extract to the wrong
67
+ // location. Tracked separately from the collision-check regression.
64
68
  await exec("tar", ["xzf", archivePath, "-C", homedir()]);
65
69
 
66
70
  // 5. Restore lockfile entry
@@ -563,6 +563,14 @@ export async function restore(): Promise<void> {
563
563
  // Detect topology and route platform assistants through Django import
564
564
  const cloud =
565
565
  entry.cloud || (entry.project ? "gcp" : entry.sshUser ? "custom" : "local");
566
+
567
+ if (cloud === "apple-container") {
568
+ console.error(
569
+ `Error: '${name}' uses the Apple Containers runtime. Restore is not yet supported for this topology.`,
570
+ );
571
+ process.exit(1);
572
+ }
573
+
566
574
  if (cloud === "vellum") {
567
575
  await restorePlatform(entry, name, bundleData, { version, dryRun });
568
576
  return;
@@ -12,6 +12,7 @@ import { retireInstance as retireAwsInstance } from "../lib/aws";
12
12
  import { retireDocker } from "../lib/docker";
13
13
  import { retireInstance as retireGcpInstance } from "../lib/gcp";
14
14
  import { retireLocal } from "../lib/retire-local";
15
+ import { retireAppleContainer } from "../lib/retire-apple-container";
15
16
  import { exec } from "../lib/step-runner";
16
17
  import {
17
18
  openLogFile,
@@ -100,7 +101,12 @@ async function retireVellum(
100
101
  headers: await authHeaders(token, runtimeUrl),
101
102
  });
102
103
 
103
- if (!response.ok) {
104
+ // Treat 404 as success: the assistant is already gone from the platform
105
+ // (previously retired, deleted from the web UI, or retired from another
106
+ // device) so the caller's job is done. Falling through to the lockfile
107
+ // cleanup avoids leaving a stale entry that would otherwise wedge the
108
+ // macOS app in a permanent health-check loop.
109
+ if (!response.ok && response.status !== 404) {
104
110
  const body = await response.text();
105
111
  console.error(
106
112
  `Error: Platform retire failed (${response.status}): ${body}`,
@@ -108,7 +114,13 @@ async function retireVellum(
108
114
  process.exit(1);
109
115
  }
110
116
 
111
- console.log("\u2705 Platform-hosted instance retired.");
117
+ if (response.status === 404) {
118
+ console.log(
119
+ "\u2705 Platform-hosted instance already retired (404) — cleaning up local state.",
120
+ );
121
+ } else {
122
+ console.log("\u2705 Platform-hosted instance retired.");
123
+ }
112
124
  }
113
125
 
114
126
  function parseSource(): string | undefined {
@@ -201,13 +213,8 @@ async function retireInner(): Promise<void> {
201
213
  const cloud = resolveCloud(entry);
202
214
 
203
215
  if (cloud === "apple-container") {
204
- console.error(
205
- `Error: '${name}' uses the Apple Containers runtime. Its lifecycle is managed by the macOS app — use the app to retire it.`,
206
- );
207
- process.exit(1);
208
- }
209
-
210
- if (cloud === "gcp") {
216
+ await retireAppleContainer(name, entry);
217
+ } else if (cloud === "gcp") {
211
218
  const project = entry.project;
212
219
  const zone = entry.zone;
213
220
  if (!project || !zone) {
@@ -29,12 +29,13 @@ import {
29
29
  captureContainerEnv,
30
30
  commitWorkspaceViaGateway,
31
31
  CONTAINER_ENV_EXCLUDE_KEYS,
32
+ fetchCurrentVersion,
33
+ fetchPreviousVersion,
32
34
  performDockerRollback,
33
35
  rollbackMigrations,
34
36
  UPGRADE_PROGRESS,
35
37
  waitForReady,
36
38
  } from "../lib/upgrade-lifecycle.js";
37
- import { compareVersions } from "../lib/version-compat.js";
38
39
 
39
40
  function parseArgs(): { name: string | null; version: string | null } {
40
41
  const args = process.argv.slice(3);
@@ -148,20 +149,7 @@ async function rollbackPlatformViaEndpoint(
148
149
  entry: AssistantEntry,
149
150
  version?: string,
150
151
  ): Promise<void> {
151
- const currentVersion = entry.serviceGroupVersion;
152
-
153
- // Step 1 — Version validation (only if version provided)
154
- if (version && currentVersion) {
155
- const cmp = compareVersions(version, currentVersion);
156
- if (cmp !== null && cmp >= 0) {
157
- const msg = `Target version ${version} is not older than the current version ${currentVersion}. Use \`vellum upgrade --version ${version}\` to upgrade.`;
158
- console.error(msg);
159
- emitCliError("VERSION_DIRECTION", msg);
160
- process.exit(1);
161
- }
162
- }
163
-
164
- // Step 2 — Authenticate
152
+ // Step 1 — Authenticate
165
153
  const token = readPlatformToken();
166
154
  if (!token) {
167
155
  const msg =
@@ -171,6 +159,9 @@ async function rollbackPlatformViaEndpoint(
171
159
  process.exit(1);
172
160
  }
173
161
 
162
+ // Fetch current version from health endpoint (best-effort)
163
+ const currentVersion = await fetchCurrentVersion(entry.runtimeUrl);
164
+
174
165
  // Step 3 — Call rollback endpoint
175
166
  if (version) {
176
167
  console.log(`Rolling back to ${version}...`);
@@ -215,7 +206,7 @@ async function rollbackPlatformViaEndpoint(
215
206
  await broadcastUpgradeEvent(
216
207
  entry.runtimeUrl,
217
208
  entry.assistantId,
218
- buildCompleteEvent(currentVersion ?? "unknown", false),
209
+ buildCompleteEvent(currentVersion ?? version ?? "unknown", false),
219
210
  );
220
211
  process.exit(1);
221
212
  }
@@ -234,6 +225,13 @@ export async function rollback(): Promise<void> {
234
225
  const entry = resolveTargetAssistant(name);
235
226
  const cloud = resolveCloud(entry);
236
227
 
228
+ if (cloud === "apple-container") {
229
+ console.error(
230
+ `Error: '${entry.assistantId}' uses the Apple Containers runtime. Rollback is not yet supported for this topology.`,
231
+ );
232
+ process.exit(1);
233
+ }
234
+
237
235
  // ---------- Managed (Vellum platform) rollback ----------
238
236
  if (cloud === "vellum") {
239
237
  await rollbackPlatformViaEndpoint(entry, version ?? undefined);
@@ -258,7 +256,7 @@ export async function rollback(): Promise<void> {
258
256
  await broadcastUpgradeEvent(
259
257
  entry.runtimeUrl,
260
258
  entry.assistantId,
261
- buildCompleteEvent(entry.serviceGroupVersion ?? "unknown", false),
259
+ buildCompleteEvent(version ?? "unknown", false),
262
260
  );
263
261
  emitCliError(categorizeUpgradeError(err), "Rollback failed", detail);
264
262
  process.exit(1);
@@ -268,8 +266,14 @@ export async function rollback(): Promise<void> {
268
266
 
269
267
  // ---------- Docker: Saved-state rollback (no --version) ----------
270
268
 
269
+ // Fetch current + previous version from live APIs
270
+ const currentVersion = await fetchCurrentVersion(entry.runtimeUrl);
271
+ const previousVersion =
272
+ (await fetchPreviousVersion(currentVersion, entry.previousVersion)) ??
273
+ "unknown";
274
+
271
275
  // Verify rollback state exists
272
- if (!entry.previousServiceGroupVersion || !entry.previousContainerInfo) {
276
+ if (!entry.previousContainerInfo) {
273
277
  const msg =
274
278
  "No rollback state available. Run `vellum upgrade` first to create a rollback point.";
275
279
  console.error(msg);
@@ -305,15 +309,15 @@ export async function rollback(): Promise<void> {
305
309
  buildUpgradeCommitMessage({
306
310
  action: "rollback",
307
311
  phase: "starting",
308
- from: entry.serviceGroupVersion ?? "unknown",
309
- to: entry.previousServiceGroupVersion ?? "unknown",
312
+ from: currentVersion ?? "unknown",
313
+ to: previousVersion,
310
314
  topology: "docker",
311
315
  assistantId: entry.assistantId,
312
316
  }),
313
317
  );
314
318
 
315
319
  console.log(
316
- `šŸ”„ Rolling back Docker assistant '${instanceName}' to ${entry.previousServiceGroupVersion}...\n`,
320
+ `šŸ”„ Rolling back Docker assistant '${instanceName}' to ${previousVersion}...\n`,
317
321
  );
318
322
 
319
323
  // Capture current container env
@@ -367,7 +371,7 @@ export async function rollback(): Promise<void> {
367
371
  await broadcastUpgradeEvent(
368
372
  entry.runtimeUrl,
369
373
  entry.assistantId,
370
- buildStartingEvent(entry.previousServiceGroupVersion),
374
+ buildStartingEvent(previousVersion),
371
375
  );
372
376
  // Brief pause to allow SSE delivery before containers stop.
373
377
  await new Promise((r) => setTimeout(r, 500));
@@ -428,7 +432,6 @@ export async function rollback(): Promise<void> {
428
432
  // Swap current/previous state to enable "rollback the rollback"
429
433
  const updatedEntry: AssistantEntry = {
430
434
  ...entry,
431
- serviceGroupVersion: entry.previousServiceGroupVersion,
432
435
  containerInfo: {
433
436
  assistantImage: prev.assistantImage ?? previousImageRefs.assistant,
434
437
  gatewayImage: prev.gatewayImage ?? previousImageRefs.gateway,
@@ -438,7 +441,6 @@ export async function rollback(): Promise<void> {
438
441
  cesDigest: newDigests?.["credential-executor"],
439
442
  networkName: res.network,
440
443
  },
441
- previousServiceGroupVersion: entry.serviceGroupVersion,
442
444
  previousContainerInfo: entry.containerInfo,
443
445
  // Clear the backup path — it belonged to the upgrade we just rolled back
444
446
  preUpgradeBackupPath: undefined,
@@ -451,7 +453,7 @@ export async function rollback(): Promise<void> {
451
453
  await broadcastUpgradeEvent(
452
454
  entry.runtimeUrl,
453
455
  entry.assistantId,
454
- buildCompleteEvent(entry.previousServiceGroupVersion, true),
456
+ buildCompleteEvent(previousVersion, true),
455
457
  );
456
458
 
457
459
  // Record successful rollback in workspace git history
@@ -461,8 +463,8 @@ export async function rollback(): Promise<void> {
461
463
  buildUpgradeCommitMessage({
462
464
  action: "rollback",
463
465
  phase: "complete",
464
- from: entry.serviceGroupVersion ?? "unknown",
465
- to: entry.previousServiceGroupVersion ?? "unknown",
466
+ from: currentVersion ?? "unknown",
467
+ to: previousVersion,
466
468
  topology: "docker",
467
469
  assistantId: entry.assistantId,
468
470
  result: "success",
@@ -470,7 +472,7 @@ export async function rollback(): Promise<void> {
470
472
  );
471
473
 
472
474
  console.log(
473
- `\nāœ… Docker assistant '${instanceName}' rolled back to ${entry.previousServiceGroupVersion}.`,
475
+ `\nāœ… Docker assistant '${instanceName}' rolled back to ${previousVersion}.`,
474
476
  );
475
477
  console.log(
476
478
  "\nTip: To also restore data from before the upgrade, use `vellum restore --from <backup-path>`.",
@@ -485,10 +487,7 @@ export async function rollback(): Promise<void> {
485
487
  await broadcastUpgradeEvent(
486
488
  entry.runtimeUrl,
487
489
  entry.assistantId,
488
- buildCompleteEvent(
489
- entry.previousServiceGroupVersion ?? "unknown",
490
- false,
491
- ),
490
+ buildCompleteEvent(previousVersion, false),
492
491
  );
493
492
  emitCliError(
494
493
  "READINESS_TIMEOUT",
@@ -502,7 +501,7 @@ export async function rollback(): Promise<void> {
502
501
  await broadcastUpgradeEvent(
503
502
  entry.runtimeUrl,
504
503
  entry.assistantId,
505
- buildCompleteEvent(entry.serviceGroupVersion ?? "unknown", false),
504
+ buildCompleteEvent(previousVersion, false),
506
505
  );
507
506
  emitCliError(categorizeUpgradeError(err), "Rollback failed", detail);
508
507
  process.exit(1);
@@ -6,6 +6,7 @@ import {
6
6
  } from "../lib/assistant-config";
7
7
  import type { AssistantEntry } from "../lib/assistant-config";
8
8
  import { dockerResourceNames } from "../lib/docker";
9
+ import { sshAppleContainer } from "../lib/ssh-apple-container";
9
10
 
10
11
  const SSH_OPTS = [
11
12
  "-o",
@@ -81,6 +82,12 @@ export async function ssh(): Promise<void> {
81
82
  process.exit(1);
82
83
  }
83
84
 
85
+ // Apple container: connect to the management socket for an interactive shell.
86
+ if (cloud === "apple-container") {
87
+ await sshAppleContainer(entry);
88
+ return;
89
+ }
90
+
84
91
  let child;
85
92
 
86
93
  if (cloud === "docker") {