@vellumai/cli 0.6.3 → 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 (49) 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 -56
  14. package/src/commands/backup.ts +8 -0
  15. package/src/commands/hatch.ts +1 -1
  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 +8 -0
  22. package/src/commands/retire.ts +16 -9
  23. package/src/commands/rollback.ts +32 -33
  24. package/src/commands/ssh-apple-container.ts +162 -0
  25. package/src/commands/ssh.ts +7 -0
  26. package/src/commands/teleport.ts +226 -1
  27. package/src/commands/upgrade.ts +43 -52
  28. package/src/commands/wake.ts +14 -10
  29. package/src/components/DefaultMainScreen.tsx +7 -1
  30. package/src/index.ts +3 -0
  31. package/src/lib/__tests__/docker.test.ts +78 -0
  32. package/src/lib/assistant-config.ts +48 -87
  33. package/src/lib/aws.ts +12 -1
  34. package/src/lib/constants.ts +0 -10
  35. package/src/lib/docker.ts +70 -4
  36. package/src/lib/environments/__tests__/paths.test.ts +234 -0
  37. package/src/lib/environments/__tests__/resolve.test.ts +226 -0
  38. package/src/lib/environments/paths.ts +110 -0
  39. package/src/lib/environments/resolve.ts +96 -0
  40. package/src/lib/environments/seeds.ts +46 -0
  41. package/src/lib/environments/types.ts +60 -0
  42. package/src/lib/gcp.ts +12 -1
  43. package/src/lib/guardian-token.ts +8 -10
  44. package/src/lib/hatch-local.ts +24 -19
  45. package/src/lib/local.ts +46 -5
  46. package/src/lib/orphan-detection.ts +28 -12
  47. package/src/lib/platform-client.ts +220 -24
  48. package/src/lib/retire-apple-container.ts +102 -0
  49. package/src/lib/upgrade-lifecycle.ts +101 -28
@@ -0,0 +1,102 @@
1
+ import { createConnection } from "net";
2
+ import { existsSync } from "fs";
3
+
4
+ import type { AssistantEntry } from "./assistant-config.js";
5
+
6
+ /**
7
+ * Retire an Apple Container assistant by sending a retire command to the
8
+ * macOS app via the management socket. The app handles the full lifecycle:
9
+ * stop the pod, archive the instance directory, remove the guardian token,
10
+ * deregister from the platform, and remove the lockfile entry.
11
+ */
12
+ export async function retireAppleContainer(
13
+ name: string,
14
+ entry: AssistantEntry,
15
+ ): Promise<void> {
16
+ console.log(`\u{1F5D1}\ufe0f Retiring Apple Container assistant '${name}'...\n`);
17
+
18
+ const mgmtSocket = entry.mgmtSocket as string | undefined;
19
+ if (!mgmtSocket) {
20
+ console.error(
21
+ `No management socket found for '${name}'.\n` +
22
+ "The assistant may not be running. If the macOS app is closed, " +
23
+ "open it and try again.",
24
+ );
25
+ process.exit(1);
26
+ }
27
+
28
+ if (!existsSync(mgmtSocket)) {
29
+ console.error(
30
+ `Management socket not found at ${mgmtSocket}.\n` +
31
+ "The assistant may have been stopped. Open the macOS app and try again.",
32
+ );
33
+ process.exit(1);
34
+ }
35
+
36
+ const handshake = JSON.stringify({ action: "retire" }) + "\n";
37
+
38
+ return new Promise<void>((resolve, reject) => {
39
+ const socket = createConnection({ path: mgmtSocket }, () => {
40
+ socket.write(handshake);
41
+ });
42
+
43
+ const TIMEOUT_MS = 30_000;
44
+ const chunks: Buffer[] = [];
45
+ let totalLen = 0;
46
+
47
+ socket.setTimeout(TIMEOUT_MS);
48
+ socket.on("timeout", () => {
49
+ console.error("Timed out waiting for retire response from the macOS app.");
50
+ socket.destroy();
51
+ process.exit(1);
52
+ });
53
+
54
+ socket.on("data", (data: Buffer) => {
55
+ chunks.push(data);
56
+ totalLen += data.length;
57
+ const accumulated = Buffer.concat(chunks, totalLen);
58
+ const nlIndex = accumulated.indexOf(0x0a);
59
+ if (nlIndex === -1) return;
60
+
61
+ const responseLine = accumulated.slice(0, nlIndex).toString("utf-8");
62
+ socket.destroy();
63
+
64
+ let response: { status: string; message?: string };
65
+ try {
66
+ response = JSON.parse(responseLine) as {
67
+ status: string;
68
+ message?: string;
69
+ };
70
+ } catch {
71
+ reject(new Error("Invalid response from management socket."));
72
+ return;
73
+ }
74
+
75
+ if (response.status === "ok") {
76
+ console.log(`\u2705 Apple Container assistant '${name}' retired.`);
77
+ resolve();
78
+ } else {
79
+ reject(
80
+ new Error(
81
+ `Retire failed: ${response.message || "unknown error"}`,
82
+ ),
83
+ );
84
+ }
85
+ });
86
+
87
+ socket.on("error", (err) => {
88
+ reject(new Error(`Management socket error: ${err.message}`));
89
+ });
90
+
91
+ socket.on("end", () => {
92
+ if (chunks.length === 0) {
93
+ reject(
94
+ new Error(
95
+ "Management socket closed without responding. " +
96
+ "The macOS app may have crashed during retire.",
97
+ ),
98
+ );
99
+ }
100
+ });
101
+ });
102
+ }
@@ -125,6 +125,72 @@ export async function captureContainerEnv(
125
125
  return captured;
126
126
  }
127
127
 
128
+ /**
129
+ * Best-effort fetch of the running service group version from the gateway
130
+ * `/healthz` endpoint. Returns `undefined` when the endpoint is
131
+ * unreachable or does not include a version field.
132
+ */
133
+ export async function fetchCurrentVersion(
134
+ runtimeUrl: string,
135
+ ): Promise<string | undefined> {
136
+ try {
137
+ const resp = await fetch(`${runtimeUrl}/healthz`, {
138
+ signal: AbortSignal.timeout(5000),
139
+ });
140
+ if (resp.ok) {
141
+ const body = (await resp.json()) as { version?: string };
142
+ return body.version;
143
+ }
144
+ } catch {
145
+ // Best-effort
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ /**
151
+ * Determine the version that was running before the current one.
152
+ *
153
+ * Checks (in order):
154
+ * 1. `entry.previousVersion` (saved by the upgrade flow from health).
155
+ * 2. The releases list from the platform API — finds the version
156
+ * immediately before `currentVersion`.
157
+ *
158
+ * Returns `undefined` when neither source yields a result.
159
+ */
160
+ export async function fetchPreviousVersion(
161
+ currentVersion: string | undefined,
162
+ previousVersionFromLockfile: string | undefined,
163
+ ): Promise<string | undefined> {
164
+ // 1. Lockfile-cached value (written during upgrade from health endpoint)
165
+ if (previousVersionFromLockfile) return previousVersionFromLockfile;
166
+
167
+ // 2. Derive from releases list
168
+ if (!currentVersion) return undefined;
169
+ try {
170
+ const { getPlatformUrl } = await import("./platform-client.js");
171
+ const platformUrl = getPlatformUrl();
172
+ const resp = await fetch(`${platformUrl}/v1/releases/?stable=true`, {
173
+ signal: AbortSignal.timeout(10_000),
174
+ });
175
+ if (!resp.ok) return undefined;
176
+
177
+ const releases = (await resp.json()) as Array<{ version?: string }>;
178
+ const normalizedCurrent = currentVersion.replace(/^v/, "");
179
+
180
+ // Releases are ordered newest-first; find the entry right after the
181
+ // current version (i.e. the one that was running before the upgrade).
182
+ const idx = releases.findIndex(
183
+ (r) => (r.version ?? "").replace(/^v/, "") === normalizedCurrent,
184
+ );
185
+ if (idx >= 0 && idx + 1 < releases.length) {
186
+ return releases[idx + 1].version;
187
+ }
188
+ } catch {
189
+ // Best-effort
190
+ }
191
+ return undefined;
192
+ }
193
+
128
194
  /**
129
195
  * Poll the gateway `/readyz` endpoint until it returns 200 or the timeout
130
196
  * elapses. Returns whether the assistant became ready.
@@ -314,27 +380,8 @@ export async function performDockerRollback(
314
380
  throw new Error("targetVersion is required for performDockerRollback");
315
381
  }
316
382
 
317
- const currentVersion = entry.serviceGroupVersion;
318
-
319
- // Validate target version < current version
320
- if (currentVersion) {
321
- const cmp = compareVersions(targetVersion, currentVersion);
322
- if (cmp !== null) {
323
- if (cmp > 0) {
324
- const msg =
325
- "Cannot roll back to a newer version. Use `vellum upgrade` instead.";
326
- console.error(msg);
327
- emitCliError("VERSION_DIRECTION", msg);
328
- process.exit(1);
329
- }
330
- if (cmp === 0) {
331
- const msg = `Already on version ${targetVersion}. Nothing to roll back to.`;
332
- console.error(msg);
333
- emitCliError("VERSION_DIRECTION", msg);
334
- process.exit(1);
335
- }
336
- }
337
- }
383
+ // Fetch the current running version from the health endpoint.
384
+ let currentVersion: string | undefined;
338
385
 
339
386
  const instanceName = entry.assistantId;
340
387
  const res = dockerResourceNames(instanceName);
@@ -383,7 +430,7 @@ export async function performDockerRollback(
383
430
  console.log("📸 Capturing current image references for rollback...");
384
431
  const currentImageRefs = await captureImageRefs(res);
385
432
 
386
- // Capture current migration state for rollback targeting
433
+ // Capture current migration state and running version for rollback targeting
387
434
  let preMigrationState: {
388
435
  dbVersion?: number;
389
436
  lastWorkspaceMigrationId?: string;
@@ -395,25 +442,54 @@ export async function performDockerRollback(
395
442
  );
396
443
  if (healthResp.ok) {
397
444
  const health = (await healthResp.json()) as {
445
+ version?: string;
398
446
  migrations?: { dbVersion?: number; lastWorkspaceMigrationId?: string };
399
447
  };
400
448
  preMigrationState = health.migrations ?? {};
449
+ currentVersion = health.version;
401
450
  }
402
451
  } catch {
403
452
  // Best-effort
404
453
  }
405
454
 
455
+ // Validate target version < current version
456
+ if (!currentVersion) {
457
+ console.warn(
458
+ "⚠️ Could not determine current version from health endpoint — skipping version-direction check.\n",
459
+ );
460
+ }
461
+ if (currentVersion) {
462
+ const cmp = compareVersions(targetVersion, currentVersion);
463
+ if (cmp !== null) {
464
+ if (cmp > 0) {
465
+ const msg =
466
+ "Cannot roll back to a newer version. Use `vellum upgrade` instead.";
467
+ console.error(msg);
468
+ emitCliError("VERSION_DIRECTION", msg);
469
+ process.exit(1);
470
+ }
471
+ if (cmp === 0) {
472
+ const msg = `Already on version ${targetVersion}. Nothing to roll back to.`;
473
+ console.error(msg);
474
+ emitCliError("VERSION_DIRECTION", msg);
475
+ process.exit(1);
476
+ }
477
+ }
478
+ }
479
+
406
480
  // Persist rollback state to lockfile BEFORE any destructive changes
407
- if (entry.serviceGroupVersion && entry.containerInfo) {
481
+ if (entry.containerInfo) {
408
482
  const rollbackEntry: AssistantEntry = {
409
483
  ...entry,
410
- previousServiceGroupVersion: entry.serviceGroupVersion,
411
484
  previousContainerInfo: { ...entry.containerInfo },
485
+ previousVersion: currentVersion,
412
486
  previousDbMigrationVersion: preMigrationState.dbVersion,
413
487
  previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
414
488
  };
415
489
  saveAssistantEntry(rollbackEntry);
416
- console.log(` Saved rollback state: ${entry.serviceGroupVersion}\n`);
490
+ if (currentVersion) {
491
+ console.log(` Saved rollback state: ${currentVersion}\n`);
492
+ }
417
493
  }
418
494
 
419
495
  // Record rollback start in workspace git history
@@ -613,7 +689,6 @@ export async function performDockerRollback(
613
689
  // Swap current/previous state to enable "rollback the rollback"
614
690
  const updatedEntry: AssistantEntry = {
615
691
  ...entry,
616
- serviceGroupVersion: targetVersion,
617
692
  containerInfo: {
618
693
  assistantImage: targetImageTags.assistant,
619
694
  gatewayImage: targetImageTags.gateway,
@@ -623,7 +698,6 @@ export async function performDockerRollback(
623
698
  cesDigest: newDigests?.["credential-executor"],
624
699
  networkName: res.network,
625
700
  },
626
- previousServiceGroupVersion: entry.serviceGroupVersion,
627
701
  previousContainerInfo: entry.containerInfo,
628
702
  previousDbMigrationVersion: preMigrationState.dbVersion,
629
703
  previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
@@ -749,7 +823,6 @@ export async function performDockerRollback(
749
823
  currentImageRefs["credential-executor"],
750
824
  networkName: res.network,
751
825
  },
752
- previousServiceGroupVersion: undefined,
753
826
  previousContainerInfo: undefined,
754
827
  previousDbMigrationVersion: undefined,
755
828
  previousWorkspaceMigrationId: undefined,