@vellumai/cli 0.5.4 → 0.5.6

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.
@@ -0,0 +1,330 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+
3
+ import { findAssistantByName } from "../lib/assistant-config.js";
4
+ import {
5
+ loadGuardianToken,
6
+ leaseGuardianToken,
7
+ } from "../lib/guardian-token.js";
8
+
9
+ function printUsage(): void {
10
+ console.log("Usage: vellum restore <name> --from <path> [--dry-run]");
11
+ console.log("");
12
+ console.log("Restore a .vbundle backup into a running assistant.");
13
+ console.log("");
14
+ console.log("Arguments:");
15
+ console.log(" <name> Name of the assistant to restore into");
16
+ console.log("");
17
+ console.log("Options:");
18
+ console.log(
19
+ " --from <path> Path to the .vbundle file to restore (required)",
20
+ );
21
+ console.log(" --dry-run Show what would change without applying");
22
+ console.log("");
23
+ console.log("Examples:");
24
+ console.log(" vellum restore my-assistant --from ~/Desktop/backup.vbundle");
25
+ console.log(
26
+ " vellum restore my-assistant --from ~/Desktop/backup.vbundle --dry-run",
27
+ );
28
+ }
29
+
30
+ function parseArgs(argv: string[]): {
31
+ name: string | undefined;
32
+ fromPath: string | undefined;
33
+ dryRun: boolean;
34
+ help: boolean;
35
+ } {
36
+ const args = argv.slice(3);
37
+
38
+ if (args.includes("--help") || args.includes("-h")) {
39
+ return { name: undefined, fromPath: undefined, dryRun: false, help: true };
40
+ }
41
+
42
+ let fromPath: string | undefined;
43
+ const dryRun = args.includes("--dry-run");
44
+ const positionals: string[] = [];
45
+
46
+ for (let i = 0; i < args.length; i++) {
47
+ if (args[i] === "--from" && args[i + 1]) {
48
+ fromPath = args[i + 1];
49
+ i++; // skip the value
50
+ } else if (args[i] === "--dry-run") {
51
+ // already handled above
52
+ } else if (!args[i].startsWith("-")) {
53
+ positionals.push(args[i]);
54
+ }
55
+ }
56
+
57
+ return { name: positionals[0], fromPath, dryRun, help: false };
58
+ }
59
+
60
+ async function getAccessToken(
61
+ runtimeUrl: string,
62
+ assistantId: string,
63
+ displayName: string,
64
+ ): Promise<string> {
65
+ const tokenData = loadGuardianToken(assistantId);
66
+
67
+ if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
68
+ return tokenData.accessToken;
69
+ }
70
+
71
+ try {
72
+ const freshToken = await leaseGuardianToken(runtimeUrl, assistantId);
73
+ return freshToken.accessToken;
74
+ } catch (err) {
75
+ const msg = err instanceof Error ? err.message : String(err);
76
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
77
+ console.error(
78
+ `Error: Could not connect to assistant '${displayName}'. Is it running?`,
79
+ );
80
+ console.error(`Try: vellum wake ${displayName}`);
81
+ process.exit(1);
82
+ }
83
+ throw err;
84
+ }
85
+ }
86
+
87
+ interface PreflightFileEntry {
88
+ path: string;
89
+ action: string;
90
+ }
91
+
92
+ interface StructuredError {
93
+ code: string;
94
+ message: string;
95
+ path?: string;
96
+ }
97
+
98
+ interface PreflightResponse {
99
+ can_import: boolean;
100
+ validation?: {
101
+ is_valid: false;
102
+ errors: StructuredError[];
103
+ };
104
+ files?: PreflightFileEntry[];
105
+ summary?: {
106
+ files_to_create: number;
107
+ files_to_overwrite: number;
108
+ files_unchanged: number;
109
+ total_files: number;
110
+ };
111
+ conflicts?: StructuredError[];
112
+ }
113
+
114
+ interface ImportResponse {
115
+ success: boolean;
116
+ reason?: string;
117
+ errors?: StructuredError[];
118
+ message?: string;
119
+ warnings?: string[];
120
+ summary?: {
121
+ total_files: number;
122
+ files_created: number;
123
+ files_overwritten: number;
124
+ files_skipped: number;
125
+ backups_created: number;
126
+ };
127
+ }
128
+
129
+ export async function restore(): Promise<void> {
130
+ const { name, fromPath, dryRun, help } = parseArgs(process.argv);
131
+
132
+ if (help) {
133
+ printUsage();
134
+ process.exit(0);
135
+ }
136
+
137
+ if (!name || !fromPath) {
138
+ console.error("Error: Both <name> and --from <path> are required.");
139
+ console.error("");
140
+ printUsage();
141
+ process.exit(1);
142
+ }
143
+
144
+ // Look up the instance
145
+ const entry = findAssistantByName(name);
146
+ if (!entry) {
147
+ console.error(`Error: No assistant found with name '${name}'.`);
148
+ console.error("Run 'vellum ps' to see available assistants.");
149
+ process.exit(1);
150
+ }
151
+
152
+ // Verify .vbundle file exists
153
+ if (!existsSync(fromPath)) {
154
+ console.error(`Error: File not found: ${fromPath}`);
155
+ process.exit(1);
156
+ }
157
+
158
+ // Read the .vbundle file
159
+ const bundleData = readFileSync(fromPath);
160
+ const sizeMB = (bundleData.byteLength / (1024 * 1024)).toFixed(2);
161
+ console.log(`Reading ${fromPath} (${sizeMB} MB)...`);
162
+
163
+ // Obtain auth token
164
+ const accessToken = await getAccessToken(
165
+ entry.runtimeUrl,
166
+ entry.assistantId,
167
+ name,
168
+ );
169
+
170
+ if (dryRun) {
171
+ // Preflight check
172
+ console.log("Running preflight analysis...\n");
173
+
174
+ let response: Response;
175
+ try {
176
+ response = await fetch(
177
+ `${entry.runtimeUrl}/v1/migrations/import-preflight`,
178
+ {
179
+ method: "POST",
180
+ headers: {
181
+ Authorization: `Bearer ${accessToken}`,
182
+ "Content-Type": "application/octet-stream",
183
+ },
184
+ body: bundleData,
185
+ signal: AbortSignal.timeout(120_000),
186
+ },
187
+ );
188
+ } catch (err) {
189
+ if (err instanceof Error && err.name === "TimeoutError") {
190
+ console.error("Error: Preflight request timed out after 2 minutes.");
191
+ process.exit(1);
192
+ }
193
+ const msg = err instanceof Error ? err.message : String(err);
194
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
195
+ console.error(
196
+ `Error: Could not connect to assistant '${name}'. Is it running?`,
197
+ );
198
+ console.error(`Try: vellum wake ${name}`);
199
+ process.exit(1);
200
+ }
201
+ throw err;
202
+ }
203
+
204
+ if (!response.ok) {
205
+ const body = await response.text();
206
+ console.error(
207
+ `Error: Preflight check failed (${response.status}): ${body}`,
208
+ );
209
+ process.exit(1);
210
+ }
211
+
212
+ const result = (await response.json()) as PreflightResponse;
213
+
214
+ if (!result.can_import) {
215
+ if (result.validation?.errors?.length) {
216
+ console.error("Import blocked by validation errors:");
217
+ for (const err of result.validation.errors) {
218
+ console.error(` - ${err.message}${err.path ? ` (${err.path})` : ""}`);
219
+ }
220
+ }
221
+ if (result.conflicts?.length) {
222
+ console.error("Import blocked by conflicts:");
223
+ for (const conflict of result.conflicts) {
224
+ console.error(` - ${conflict.message}${conflict.path ? ` (${conflict.path})` : ""}`);
225
+ }
226
+ }
227
+ process.exit(1);
228
+ }
229
+
230
+ // Print summary table
231
+ const summary = result.summary ?? {
232
+ files_to_create: 0,
233
+ files_to_overwrite: 0,
234
+ files_unchanged: 0,
235
+ total_files: 0,
236
+ };
237
+ console.log("Preflight analysis:");
238
+ console.log(` Files to create: ${summary.files_to_create}`);
239
+ console.log(` Files to overwrite: ${summary.files_to_overwrite}`);
240
+ console.log(` Files unchanged: ${summary.files_unchanged}`);
241
+ console.log(` Total: ${summary.total_files}`);
242
+ console.log("");
243
+
244
+ const conflicts = result.conflicts ?? [];
245
+ console.log(
246
+ `Conflicts: ${conflicts.length > 0 ? conflicts.map((c) => c.message).join(", ") : "none"}`,
247
+ );
248
+
249
+ // List individual files with their action
250
+ if (result.files && result.files.length > 0) {
251
+ console.log("");
252
+ console.log("Files:");
253
+ for (const file of result.files) {
254
+ console.log(` [${file.action}] ${file.path}`);
255
+ }
256
+ }
257
+ } else {
258
+ // Full import
259
+ console.log("Importing backup...\n");
260
+
261
+ let response: Response;
262
+ try {
263
+ response = await fetch(`${entry.runtimeUrl}/v1/migrations/import`, {
264
+ method: "POST",
265
+ headers: {
266
+ Authorization: `Bearer ${accessToken}`,
267
+ "Content-Type": "application/octet-stream",
268
+ },
269
+ body: bundleData,
270
+ signal: AbortSignal.timeout(120_000),
271
+ });
272
+ } catch (err) {
273
+ if (err instanceof Error && err.name === "TimeoutError") {
274
+ console.error("Error: Import request timed out after 2 minutes.");
275
+ process.exit(1);
276
+ }
277
+ const msg = err instanceof Error ? err.message : String(err);
278
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
279
+ console.error(
280
+ `Error: Could not connect to assistant '${name}'. Is it running?`,
281
+ );
282
+ console.error(`Try: vellum wake ${name}`);
283
+ process.exit(1);
284
+ }
285
+ throw err;
286
+ }
287
+
288
+ if (!response.ok) {
289
+ const body = await response.text();
290
+ console.error(`Error: Import failed (${response.status}): ${body}`);
291
+ process.exit(1);
292
+ }
293
+
294
+ const result = (await response.json()) as ImportResponse;
295
+
296
+ if (!result.success) {
297
+ console.error(
298
+ `Error: Import failed — ${result.message ?? result.reason ?? "unknown reason"}`,
299
+ );
300
+ for (const err of result.errors ?? []) {
301
+ console.error(` - ${err.message}${err.path ? ` (${err.path})` : ""}`);
302
+ }
303
+ process.exit(1);
304
+ }
305
+
306
+ // Print import report
307
+ const summary = result.summary ?? {
308
+ total_files: 0,
309
+ files_created: 0,
310
+ files_overwritten: 0,
311
+ files_skipped: 0,
312
+ backups_created: 0,
313
+ };
314
+ console.log("āœ… Restore complete.");
315
+ console.log(` Files created: ${summary.files_created}`);
316
+ console.log(` Files overwritten: ${summary.files_overwritten}`);
317
+ console.log(` Files skipped: ${summary.files_skipped}`);
318
+ console.log(` Backups created: ${summary.backups_created}`);
319
+
320
+ // Print warnings if any
321
+ const warnings = result.warnings ?? [];
322
+ if (warnings.length > 0) {
323
+ console.log("");
324
+ console.log("Warnings:");
325
+ for (const warning of warnings) {
326
+ console.log(` āš ļø ${warning}`);
327
+ }
328
+ }
329
+ }
330
+ }
@@ -0,0 +1,280 @@
1
+ import { randomBytes } from "crypto";
2
+
3
+ import {
4
+ findAssistantByName,
5
+ getActiveAssistant,
6
+ loadAllAssistants,
7
+ saveAssistantEntry,
8
+ } from "../lib/assistant-config";
9
+ import type { AssistantEntry } from "../lib/assistant-config";
10
+ import {
11
+ captureImageRefs,
12
+ clearSigningKeyBootstrapLock,
13
+ GATEWAY_INTERNAL_PORT,
14
+ dockerResourceNames,
15
+ migrateCesSecurityFiles,
16
+ migrateGatewaySecurityFiles,
17
+ startContainers,
18
+ stopContainers,
19
+ } from "../lib/docker";
20
+ import type { ServiceName } from "../lib/docker";
21
+ import { loadBootstrapSecret } from "../lib/guardian-token";
22
+ import {
23
+ broadcastUpgradeEvent,
24
+ captureContainerEnv,
25
+ waitForReady,
26
+ } from "./upgrade";
27
+
28
+ function parseArgs(): { name: string | null } {
29
+ const args = process.argv.slice(3);
30
+ let name: string | null = null;
31
+
32
+ for (let i = 0; i < args.length; i++) {
33
+ const arg = args[i];
34
+ if (arg === "--help" || arg === "-h") {
35
+ console.log("Usage: vellum rollback [<name>]");
36
+ console.log("");
37
+ console.log("Roll back a Docker assistant to the previous version.");
38
+ console.log("");
39
+ console.log("Arguments:");
40
+ console.log(
41
+ " <name> Name of the assistant to roll back (default: active or only assistant)",
42
+ );
43
+ console.log("");
44
+ console.log("Examples:");
45
+ console.log(
46
+ " vellum rollback # Roll back the active assistant",
47
+ );
48
+ console.log(
49
+ " vellum rollback my-assistant # Roll back a specific assistant by name",
50
+ );
51
+ process.exit(0);
52
+ } else if (!arg.startsWith("-")) {
53
+ name = arg;
54
+ } else {
55
+ console.error(`Error: Unknown option '${arg}'.`);
56
+ process.exit(1);
57
+ }
58
+ }
59
+
60
+ return { name };
61
+ }
62
+
63
+ function resolveCloud(entry: AssistantEntry): string {
64
+ if (entry.cloud) {
65
+ return entry.cloud;
66
+ }
67
+ if (entry.project) {
68
+ return "gcp";
69
+ }
70
+ if (entry.sshUser) {
71
+ return "custom";
72
+ }
73
+ return "local";
74
+ }
75
+
76
+ /**
77
+ * Resolve which assistant to target for the rollback command. Priority:
78
+ * 1. Explicit name argument
79
+ * 2. Active assistant set via `vellum use`
80
+ * 3. Sole assistant (when exactly one exists)
81
+ */
82
+ function resolveTargetAssistant(nameArg: string | null): AssistantEntry {
83
+ if (nameArg) {
84
+ const entry = findAssistantByName(nameArg);
85
+ if (!entry) {
86
+ console.error(`No assistant found with name '${nameArg}'.`);
87
+ process.exit(1);
88
+ }
89
+ return entry;
90
+ }
91
+
92
+ const active = getActiveAssistant();
93
+ if (active) {
94
+ const entry = findAssistantByName(active);
95
+ if (entry) return entry;
96
+ }
97
+
98
+ const all = loadAllAssistants();
99
+ if (all.length === 1) return all[0];
100
+
101
+ if (all.length === 0) {
102
+ console.error("No assistants found. Run 'vellum hatch' first.");
103
+ } else {
104
+ console.error(
105
+ "Multiple assistants found. Specify a name or set an active assistant with 'vellum use <name>'.",
106
+ );
107
+ }
108
+ process.exit(1);
109
+ }
110
+
111
+ export async function rollback(): Promise<void> {
112
+ const { name } = parseArgs();
113
+ const entry = resolveTargetAssistant(name);
114
+ const cloud = resolveCloud(entry);
115
+
116
+ // Only Docker assistants support rollback
117
+ if (cloud !== "docker") {
118
+ console.error(
119
+ "Rollback is only supported for Docker assistants. For managed assistants, use the version picker to upgrade to the previous version.",
120
+ );
121
+ process.exit(1);
122
+ }
123
+
124
+ // Verify rollback state exists
125
+ if (!entry.previousServiceGroupVersion || !entry.previousContainerInfo) {
126
+ console.error(
127
+ "No rollback state available. Run `vellum upgrade` first to create a rollback point.",
128
+ );
129
+ process.exit(1);
130
+ }
131
+
132
+ // Verify all three digest fields are present
133
+ const prev = entry.previousContainerInfo;
134
+ if (!prev.assistantDigest || !prev.gatewayDigest || !prev.cesDigest) {
135
+ console.error(
136
+ "Incomplete rollback state. Previous container digests are missing.",
137
+ );
138
+ process.exit(1);
139
+ }
140
+
141
+ // Build image refs from the previous digests
142
+ const previousImageRefs: Record<ServiceName, string> = {
143
+ assistant: prev.assistantDigest,
144
+ "credential-executor": prev.cesDigest,
145
+ gateway: prev.gatewayDigest,
146
+ };
147
+
148
+ const instanceName = entry.assistantId;
149
+ const res = dockerResourceNames(instanceName);
150
+
151
+ console.log(
152
+ `šŸ”„ Rolling back Docker assistant '${instanceName}' to ${entry.previousServiceGroupVersion}...\n`,
153
+ );
154
+
155
+ // Capture current container env
156
+ console.log("šŸ’¾ Capturing existing container environment...");
157
+ const capturedEnv = await captureContainerEnv(res.assistantContainer);
158
+ console.log(
159
+ ` Captured ${Object.keys(capturedEnv).length} env var(s) from ${res.assistantContainer}\n`,
160
+ );
161
+
162
+ // Extract CES_SERVICE_TOKEN from captured env, or generate fresh one
163
+ const cesServiceToken =
164
+ capturedEnv["CES_SERVICE_TOKEN"] || randomBytes(32).toString("hex");
165
+
166
+ // Retrieve or generate a bootstrap secret for the gateway.
167
+ const bootstrapSecret =
168
+ loadBootstrapSecret(instanceName) || randomBytes(32).toString("hex");
169
+
170
+ // Build extra env vars, excluding keys managed by serviceDockerRunArgs
171
+ const envKeysSetByRunArgs = new Set([
172
+ "CES_SERVICE_TOKEN",
173
+ "VELLUM_ASSISTANT_NAME",
174
+ "RUNTIME_HTTP_HOST",
175
+ "PATH",
176
+ ]);
177
+ for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
178
+ if (process.env[envVar]) {
179
+ envKeysSetByRunArgs.add(envVar);
180
+ }
181
+ }
182
+ const extraAssistantEnv: Record<string, string> = {};
183
+ for (const [key, value] of Object.entries(capturedEnv)) {
184
+ if (!envKeysSetByRunArgs.has(key)) {
185
+ extraAssistantEnv[key] = value;
186
+ }
187
+ }
188
+
189
+ // Parse gateway port from entry's runtimeUrl, fall back to default
190
+ let gatewayPort = GATEWAY_INTERNAL_PORT;
191
+ try {
192
+ const parsed = new URL(entry.runtimeUrl);
193
+ const port = parseInt(parsed.port, 10);
194
+ if (!isNaN(port)) {
195
+ gatewayPort = port;
196
+ }
197
+ } catch {
198
+ // use default
199
+ }
200
+
201
+ // Notify connected clients that a rollback is about to begin (best-effort)
202
+ console.log("šŸ“¢ Notifying connected clients...");
203
+ await broadcastUpgradeEvent(entry.runtimeUrl, entry.assistantId, {
204
+ type: "starting",
205
+ targetVersion: entry.previousServiceGroupVersion,
206
+ expectedDowntimeSeconds: 60,
207
+ });
208
+ // Brief pause to allow SSE delivery before containers stop.
209
+ await new Promise((r) => setTimeout(r, 500));
210
+
211
+ console.log("šŸ›‘ Stopping existing containers...");
212
+ await stopContainers(res);
213
+ console.log("āœ… Containers stopped\n");
214
+
215
+ // Run security file migrations and signing key cleanup
216
+ console.log("šŸ”„ Migrating security files to gateway volume...");
217
+ await migrateGatewaySecurityFiles(res, (msg) => console.log(msg));
218
+
219
+ console.log("šŸ”„ Migrating credential files to CES security volume...");
220
+ await migrateCesSecurityFiles(res, (msg) => console.log(msg));
221
+
222
+ console.log("šŸ”‘ Clearing signing key bootstrap lock...");
223
+ await clearSigningKeyBootstrapLock(res);
224
+
225
+ console.log("šŸš€ Starting containers with previous version...");
226
+ await startContainers(
227
+ {
228
+ bootstrapSecret,
229
+ cesServiceToken,
230
+ extraAssistantEnv,
231
+ gatewayPort,
232
+ imageTags: previousImageRefs,
233
+ instanceName,
234
+ res,
235
+ },
236
+ (msg) => console.log(msg),
237
+ );
238
+ console.log("āœ… Containers started\n");
239
+
240
+ console.log("Waiting for assistant to become ready...");
241
+ const ready = await waitForReady(entry.runtimeUrl);
242
+
243
+ if (ready) {
244
+ // Capture new digests from the rolled-back containers
245
+ const newDigests = await captureImageRefs(res);
246
+
247
+ // Swap current/previous state to enable "rollback the rollback"
248
+ const updatedEntry: AssistantEntry = {
249
+ ...entry,
250
+ serviceGroupVersion: entry.previousServiceGroupVersion,
251
+ containerInfo: {
252
+ assistantImage: prev.assistantImage ?? previousImageRefs.assistant,
253
+ gatewayImage: prev.gatewayImage ?? previousImageRefs.gateway,
254
+ cesImage: prev.cesImage ?? previousImageRefs["credential-executor"],
255
+ assistantDigest: newDigests?.assistant,
256
+ gatewayDigest: newDigests?.gateway,
257
+ cesDigest: newDigests?.["credential-executor"],
258
+ networkName: res.network,
259
+ },
260
+ previousServiceGroupVersion: entry.serviceGroupVersion,
261
+ previousContainerInfo: entry.containerInfo,
262
+ };
263
+ saveAssistantEntry(updatedEntry);
264
+
265
+ // Notify clients that the rollback succeeded
266
+ await broadcastUpgradeEvent(entry.runtimeUrl, entry.assistantId, {
267
+ type: "complete",
268
+ installedVersion: entry.previousServiceGroupVersion,
269
+ success: true,
270
+ });
271
+
272
+ console.log(
273
+ `\nāœ… Docker assistant '${instanceName}' rolled back to ${entry.previousServiceGroupVersion}.`,
274
+ );
275
+ } else {
276
+ console.error(`\nāŒ Containers failed to become ready within the timeout.`);
277
+ console.log(` Check logs with: docker logs -f ${res.assistantContainer}`);
278
+ process.exit(1);
279
+ }
280
+ }