@vellumai/cli 0.5.3 → 0.5.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.
package/AGENTS.md CHANGED
@@ -40,3 +40,15 @@ Every command must have high-quality `--help` output. Follow the same standards
40
40
  3. **Write for machines**: Be precise about formats, constraints, and side effects.
41
41
  AI agents parse help text to decide which command to run and how. Avoid vague
42
42
  language — say exactly what the command does and where state is stored.
43
+
44
+ ## Docker Volume Management
45
+
46
+ The CLI creates and manages Docker volumes for containerized instances. See the root `AGENTS.md` § Docker Volume Architecture for the full volume layout.
47
+
48
+ **Volume creation** (`hatch`): Creates four volumes per instance — workspace, gateway-security, ces-security, and socket. The legacy data volume is no longer created.
49
+
50
+ **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.
51
+
52
+ **Volume cleanup** (`retire`): All volumes (including the legacy data volume if it exists) are removed when an instance is retired.
53
+
54
+ **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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -238,6 +238,7 @@ describe("migrateLegacyEntry", () => {
238
238
  expect(resources.daemonPort).toBe(7821);
239
239
  expect(resources.gatewayPort).toBe(7830);
240
240
  expect(resources.qdrantPort).toBe(6333);
241
+ expect(resources.cesPort).toBe(8090);
241
242
  expect(resources.pidFile).toContain("vellum.pid");
242
243
  });
243
244
 
@@ -310,6 +311,7 @@ describe("migrateLegacyEntry", () => {
310
311
  expect(resources.daemonPort).toBe(7821);
311
312
  expect(resources.gatewayPort).toBe(7830);
312
313
  expect(resources.qdrantPort).toBe(6333);
314
+ expect(resources.cesPort).toBe(8090);
313
315
  expect(resources.pidFile).toBe("/custom/path/.vellum/vellum.pid");
314
316
  });
315
317
 
@@ -328,6 +330,7 @@ describe("migrateLegacyEntry", () => {
328
330
  daemonPort: 8000,
329
331
  gatewayPort: 8001,
330
332
  qdrantPort: 8002,
333
+ cesPort: 8003,
331
334
  pidFile: "/my/path/.vellum/vellum.pid",
332
335
  },
333
336
  };
@@ -343,6 +346,7 @@ describe("migrateLegacyEntry", () => {
343
346
  expect(resources.daemonPort).toBe(8000);
344
347
  expect(resources.gatewayPort).toBe(8001);
345
348
  expect(resources.qdrantPort).toBe(8002);
349
+ expect(resources.cesPort).toBe(8003);
346
350
  });
347
351
 
348
352
  test("baseDataDir does not overwrite existing resources.instanceDir", () => {
@@ -377,6 +381,48 @@ describe("migrateLegacyEntry", () => {
377
381
  const resources = entry.resources as Record<string, unknown>;
378
382
  expect(resources.instanceDir).toBe("/new/path");
379
383
  });
384
+
385
+ test("backfills cesPort with default 8090", () => {
386
+ // GIVEN a local entry with resources that has no cesPort
387
+ const entry: Record<string, unknown> = {
388
+ assistantId: "no-ces-port",
389
+ runtimeUrl: "http://localhost:7830",
390
+ cloud: "local",
391
+ resources: {
392
+ instanceDir: "/custom/path",
393
+ daemonPort: 7821,
394
+ gatewayPort: 7830,
395
+ qdrantPort: 6333,
396
+ pidFile: "/custom/path/.vellum/vellum.pid",
397
+ },
398
+ };
399
+ // WHEN we migrate the entry
400
+ const changed = migrateLegacyEntry(entry);
401
+ // THEN cesPort should be backfilled
402
+ expect(changed).toBe(true);
403
+ const resources = entry.resources as Record<string, unknown>;
404
+ expect(resources.cesPort).toBe(8090);
405
+ });
406
+
407
+ test("does not overwrite existing cesPort", () => {
408
+ const entry: Record<string, unknown> = {
409
+ assistantId: "has-ces-port",
410
+ runtimeUrl: "http://localhost:7830",
411
+ cloud: "local",
412
+ resources: {
413
+ instanceDir: "/my/path",
414
+ daemonPort: 8000,
415
+ gatewayPort: 8001,
416
+ qdrantPort: 8002,
417
+ cesPort: 9090,
418
+ pidFile: "/my/path/.vellum/vellum.pid",
419
+ },
420
+ };
421
+ const changed = migrateLegacyEntry(entry);
422
+ expect(changed).toBe(false);
423
+ const resources = entry.resources as Record<string, unknown>;
424
+ expect(resources.cesPort).toBe(9090);
425
+ });
380
426
  });
381
427
 
382
428
  describe("legacy migration via loadAllAssistants", () => {
@@ -3,7 +3,7 @@ import { buildOpenclawRuntimeServer } from "../lib/openclaw-runtime-server";
3
3
 
4
4
  export async function buildOpenclawStartupScript(
5
5
  sshUser: string,
6
- anthropicApiKey: string,
6
+ providerApiKeys: Record<string, string>,
7
7
  timestampRedirect: string,
8
8
  userSetup: string,
9
9
  ownershipFixup: string,
@@ -110,7 +110,9 @@ echo -n "\$OPENCLAW_GW_TOKEN" > /tmp/openclaw-gateway-token
110
110
  chmod 600 /tmp/openclaw-gateway-token
111
111
 
112
112
  mkdir -p /root/.openclaw
113
- openclaw config set env.ANTHROPIC_API_KEY "${anthropicApiKey}"
113
+ ${Object.entries(providerApiKeys)
114
+ .map(([envVar, value]) => `openclaw config set env.${envVar} "${value}"`)
115
+ .join("\n")}
114
116
  openclaw config set agents.defaults.model.primary "anthropic/claude-opus-4-6"
115
117
  openclaw config set gateway.auth.token "\$OPENCLAW_GW_TOKEN"
116
118
 
@@ -0,0 +1,151 @@
1
+ import { mkdirSync, writeFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { dirname, join } from "path";
4
+
5
+ import { findAssistantByName } from "../lib/assistant-config";
6
+ import { loadGuardianToken, leaseGuardianToken } from "../lib/guardian-token";
7
+
8
+ function getBackupsDir(): string {
9
+ const dataHome =
10
+ process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share");
11
+ return join(dataHome, "vellum", "backups");
12
+ }
13
+
14
+ function formatSize(bytes: number): string {
15
+ if (bytes < 1024) return `${bytes} B`;
16
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
17
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
18
+ }
19
+
20
+ export async function backup(): Promise<void> {
21
+ const args = process.argv.slice(3);
22
+
23
+ if (args.includes("--help") || args.includes("-h")) {
24
+ console.log("Usage: vellum backup <name> [--output <path>]");
25
+ console.log("");
26
+ console.log(
27
+ "Export a backup of a running assistant as a .vbundle archive.",
28
+ );
29
+ console.log("");
30
+ console.log("Arguments:");
31
+ console.log(" <name> Name of the assistant to back up");
32
+ console.log("");
33
+ console.log("Options:");
34
+ console.log(" --output <path> Path to save the .vbundle file");
35
+ console.log(
36
+ " (default: ~/.local/share/vellum/backups/<name>-<timestamp>.vbundle)",
37
+ );
38
+ console.log("");
39
+ console.log("Examples:");
40
+ console.log(" vellum backup my-assistant");
41
+ console.log(
42
+ " vellum backup my-assistant --output ~/Desktop/backup.vbundle",
43
+ );
44
+ process.exit(0);
45
+ }
46
+
47
+ const name = args[0];
48
+ if (!name || name.startsWith("-")) {
49
+ console.error("Usage: vellum backup <name> [--output <path>]");
50
+ process.exit(1);
51
+ }
52
+
53
+ // Parse --output flag
54
+ let outputArg: string | undefined;
55
+ for (let i = 1; i < args.length; i++) {
56
+ if (args[i] === "--output" && args[i + 1]) {
57
+ outputArg = args[i + 1];
58
+ break;
59
+ }
60
+ }
61
+
62
+ // Look up the instance
63
+ const entry = findAssistantByName(name);
64
+ if (!entry) {
65
+ console.error(`No assistant found with name '${name}'.`);
66
+ console.error("Run 'vellum hatch' first, or check the instance name.");
67
+ process.exit(1);
68
+ }
69
+
70
+ // Obtain an auth token
71
+ let accessToken: string;
72
+ const tokenData = loadGuardianToken(entry.assistantId);
73
+ if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
74
+ accessToken = tokenData.accessToken;
75
+ } else {
76
+ try {
77
+ const freshToken = await leaseGuardianToken(
78
+ entry.runtimeUrl,
79
+ entry.assistantId,
80
+ );
81
+ accessToken = freshToken.accessToken;
82
+ } catch (err) {
83
+ const msg = err instanceof Error ? err.message : String(err);
84
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
85
+ console.error(
86
+ `Error: Could not connect to assistant '${name}'. Is it running?`,
87
+ );
88
+ console.error(`Try: vellum wake ${name}`);
89
+ process.exit(1);
90
+ }
91
+ throw err;
92
+ }
93
+ }
94
+
95
+ // Call the export endpoint
96
+ let response: Response;
97
+ try {
98
+ response = await fetch(`${entry.runtimeUrl}/v1/migrations/export`, {
99
+ method: "POST",
100
+ headers: {
101
+ Authorization: `Bearer ${accessToken}`,
102
+ "Content-Type": "application/json",
103
+ },
104
+ body: JSON.stringify({ description: "CLI backup" }),
105
+ signal: AbortSignal.timeout(120_000),
106
+ });
107
+ } catch (err) {
108
+ if (err instanceof Error && err.name === "TimeoutError") {
109
+ console.error("Error: Export request timed out after 2 minutes.");
110
+ process.exit(1);
111
+ }
112
+ const msg = err instanceof Error ? err.message : String(err);
113
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
114
+ console.error(
115
+ `Error: Could not connect to assistant '${name}'. Is it running?`,
116
+ );
117
+ console.error(`Try: vellum wake ${name}`);
118
+ process.exit(1);
119
+ }
120
+ throw err;
121
+ }
122
+
123
+ if (!response.ok) {
124
+ const body = await response.text();
125
+ console.error(`Error: Export failed (${response.status}): ${body}`);
126
+ process.exit(1);
127
+ }
128
+
129
+ // Read the response body
130
+ const arrayBuffer = await response.arrayBuffer();
131
+ const data = new Uint8Array(arrayBuffer);
132
+
133
+ // Determine output path
134
+ const isoTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
135
+ const outputPath =
136
+ outputArg || join(getBackupsDir(), `${name}-${isoTimestamp}.vbundle`);
137
+
138
+ // Ensure parent directory exists
139
+ mkdirSync(dirname(outputPath), { recursive: true });
140
+
141
+ // Write the archive to disk
142
+ writeFileSync(outputPath, data);
143
+
144
+ // Print success
145
+ const manifestSha = response.headers.get("X-Vbundle-Manifest-Sha256");
146
+ console.log(`Backup saved to ${outputPath}`);
147
+ console.log(`Size: ${formatSize(data.byteLength)}`);
148
+ if (manifestSha) {
149
+ console.log(`Manifest SHA-256: ${manifestSha}`);
150
+ }
151
+ }
@@ -96,7 +96,7 @@ chown -R "$SSH_USER:$SSH_USER" "$SSH_USER_HOME" 2>/dev/null || true
96
96
  export async function buildStartupScript(
97
97
  species: Species,
98
98
  sshUser: string,
99
- anthropicApiKey: string,
99
+ providerApiKeys: Record<string, string>,
100
100
  instanceName: string,
101
101
  cloud: RemoteHost,
102
102
  ): Promise<string> {
@@ -114,13 +114,22 @@ export async function buildStartupScript(
114
114
  if (species === "openclaw") {
115
115
  return await buildOpenclawStartupScript(
116
116
  sshUser,
117
- anthropicApiKey,
117
+ providerApiKeys,
118
118
  timestampRedirect,
119
119
  userSetup,
120
120
  ownershipFixup,
121
121
  );
122
122
  }
123
123
 
124
+ // Build bash lines that set each provider API key as a shell variable
125
+ // and corresponding dotenv lines for the env file.
126
+ const envSetLines = Object.entries(providerApiKeys)
127
+ .map(([envVar, value]) => `${envVar}=${value}`)
128
+ .join("\n");
129
+ const dotenvLines = Object.keys(providerApiKeys)
130
+ .map((envVar) => `${envVar}=\$${envVar}`)
131
+ .join("\n");
132
+
124
133
  return `#!/bin/bash
125
134
  set -e
126
135
 
@@ -128,11 +137,11 @@ ${timestampRedirect}
128
137
 
129
138
  trap 'EXIT_CODE=\$?; if [ \$EXIT_CODE -ne 0 ]; then echo "Startup script failed with exit code \$EXIT_CODE at line \$LINENO" > ${errorPath}; echo "Last 20 log lines:" >> ${errorPath}; tail -20 ${logPath} >> ${errorPath} 2>/dev/null || true; fi' EXIT
130
139
  ${userSetup}
131
- ANTHROPIC_API_KEY=${anthropicApiKey}
140
+ ${envSetLines}
132
141
  VELLUM_ASSISTANT_NAME=${instanceName}
133
142
  mkdir -p "\$HOME/.config/vellum"
134
143
  cat > "\$HOME/.config/vellum/env" << DOTENV_EOF
135
- ANTHROPIC_API_KEY=\$ANTHROPIC_API_KEY
144
+ ${dotenvLines}
136
145
  RUNTIME_HTTP_PORT=7821
137
146
  DOTENV_EOF
138
147
 
@@ -749,6 +758,7 @@ async function hatchLocal(
749
758
  cloud: "local",
750
759
  species,
751
760
  hatchedAt: new Date().toISOString(),
761
+ serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
752
762
  resources,
753
763
  };
754
764
  if (!daemonOnly && !restart) {
@@ -0,0 +1,310 @@
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 PreflightResponse {
93
+ can_import: boolean;
94
+ errors?: string[];
95
+ files?: PreflightFileEntry[];
96
+ summary?: {
97
+ create: number;
98
+ overwrite: number;
99
+ unchanged: number;
100
+ total: number;
101
+ };
102
+ conflicts?: string[];
103
+ }
104
+
105
+ interface ImportResponse {
106
+ success: boolean;
107
+ reason?: string;
108
+ errors?: string[];
109
+ warnings?: string[];
110
+ summary?: {
111
+ created: number;
112
+ overwritten: number;
113
+ skipped: number;
114
+ backups_created: number;
115
+ };
116
+ }
117
+
118
+ export async function restore(): Promise<void> {
119
+ const { name, fromPath, dryRun, help } = parseArgs(process.argv);
120
+
121
+ if (help) {
122
+ printUsage();
123
+ process.exit(0);
124
+ }
125
+
126
+ if (!name || !fromPath) {
127
+ console.error("Error: Both <name> and --from <path> are required.");
128
+ console.error("");
129
+ printUsage();
130
+ process.exit(1);
131
+ }
132
+
133
+ // Look up the instance
134
+ const entry = findAssistantByName(name);
135
+ if (!entry) {
136
+ console.error(`Error: No assistant found with name '${name}'.`);
137
+ console.error("Run 'vellum ps' to see available assistants.");
138
+ process.exit(1);
139
+ }
140
+
141
+ // Verify .vbundle file exists
142
+ if (!existsSync(fromPath)) {
143
+ console.error(`Error: File not found: ${fromPath}`);
144
+ process.exit(1);
145
+ }
146
+
147
+ // Read the .vbundle file
148
+ const bundleData = readFileSync(fromPath);
149
+ const sizeMB = (bundleData.byteLength / (1024 * 1024)).toFixed(2);
150
+ console.log(`Reading ${fromPath} (${sizeMB} MB)...`);
151
+
152
+ // Obtain auth token
153
+ const accessToken = await getAccessToken(
154
+ entry.runtimeUrl,
155
+ entry.assistantId,
156
+ name,
157
+ );
158
+
159
+ if (dryRun) {
160
+ // Preflight check
161
+ console.log("Running preflight analysis...\n");
162
+
163
+ let response: Response;
164
+ try {
165
+ response = await fetch(
166
+ `${entry.runtimeUrl}/v1/migrations/import-preflight`,
167
+ {
168
+ method: "POST",
169
+ headers: {
170
+ Authorization: `Bearer ${accessToken}`,
171
+ "Content-Type": "application/octet-stream",
172
+ },
173
+ body: bundleData,
174
+ signal: AbortSignal.timeout(120_000),
175
+ },
176
+ );
177
+ } catch (err) {
178
+ if (err instanceof Error && err.name === "TimeoutError") {
179
+ console.error("Error: Preflight request timed out after 2 minutes.");
180
+ process.exit(1);
181
+ }
182
+ const msg = err instanceof Error ? err.message : String(err);
183
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
184
+ console.error(
185
+ `Error: Could not connect to assistant '${name}'. Is it running?`,
186
+ );
187
+ console.error(`Try: vellum wake ${name}`);
188
+ process.exit(1);
189
+ }
190
+ throw err;
191
+ }
192
+
193
+ if (!response.ok) {
194
+ const body = await response.text();
195
+ console.error(
196
+ `Error: Preflight check failed (${response.status}): ${body}`,
197
+ );
198
+ process.exit(1);
199
+ }
200
+
201
+ const result = (await response.json()) as PreflightResponse;
202
+
203
+ if (!result.can_import) {
204
+ console.error("Import blocked by validation errors:");
205
+ for (const err of result.errors ?? []) {
206
+ console.error(` - ${err}`);
207
+ }
208
+ process.exit(1);
209
+ }
210
+
211
+ // Print summary table
212
+ const summary = result.summary ?? {
213
+ create: 0,
214
+ overwrite: 0,
215
+ unchanged: 0,
216
+ total: 0,
217
+ };
218
+ console.log("Preflight analysis:");
219
+ console.log(` Files to create: ${summary.create}`);
220
+ console.log(` Files to overwrite: ${summary.overwrite}`);
221
+ console.log(` Files unchanged: ${summary.unchanged}`);
222
+ console.log(` Total: ${summary.total}`);
223
+ console.log("");
224
+
225
+ const conflicts = result.conflicts ?? [];
226
+ console.log(
227
+ `Conflicts: ${conflicts.length > 0 ? conflicts.join(", ") : "none"}`,
228
+ );
229
+
230
+ // List individual files with their action
231
+ if (result.files && result.files.length > 0) {
232
+ console.log("");
233
+ console.log("Files:");
234
+ for (const file of result.files) {
235
+ console.log(` [${file.action}] ${file.path}`);
236
+ }
237
+ }
238
+ } else {
239
+ // Full import
240
+ console.log("Importing backup...\n");
241
+
242
+ let response: Response;
243
+ try {
244
+ response = await fetch(`${entry.runtimeUrl}/v1/migrations/import`, {
245
+ method: "POST",
246
+ headers: {
247
+ Authorization: `Bearer ${accessToken}`,
248
+ "Content-Type": "application/octet-stream",
249
+ },
250
+ body: bundleData,
251
+ signal: AbortSignal.timeout(120_000),
252
+ });
253
+ } catch (err) {
254
+ if (err instanceof Error && err.name === "TimeoutError") {
255
+ console.error("Error: Import request timed out after 2 minutes.");
256
+ process.exit(1);
257
+ }
258
+ const msg = err instanceof Error ? err.message : String(err);
259
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
260
+ console.error(
261
+ `Error: Could not connect to assistant '${name}'. Is it running?`,
262
+ );
263
+ console.error(`Try: vellum wake ${name}`);
264
+ process.exit(1);
265
+ }
266
+ throw err;
267
+ }
268
+
269
+ if (!response.ok) {
270
+ const body = await response.text();
271
+ console.error(`Error: Import failed (${response.status}): ${body}`);
272
+ process.exit(1);
273
+ }
274
+
275
+ const result = (await response.json()) as ImportResponse;
276
+
277
+ if (!result.success) {
278
+ console.error(
279
+ `Error: Import failed — ${result.reason ?? "unknown reason"}`,
280
+ );
281
+ for (const err of result.errors ?? []) {
282
+ console.error(` - ${err}`);
283
+ }
284
+ process.exit(1);
285
+ }
286
+
287
+ // Print import report
288
+ const summary = result.summary ?? {
289
+ created: 0,
290
+ overwritten: 0,
291
+ skipped: 0,
292
+ backups_created: 0,
293
+ };
294
+ console.log("✅ Restore complete.");
295
+ console.log(` Files created: ${summary.created}`);
296
+ console.log(` Files overwritten: ${summary.overwritten}`);
297
+ console.log(` Files skipped: ${summary.skipped}`);
298
+ console.log(` Backups created: ${summary.backups_created}`);
299
+
300
+ // Print warnings if any
301
+ const warnings = result.warnings ?? [];
302
+ if (warnings.length > 0) {
303
+ console.log("");
304
+ console.log("Warnings:");
305
+ for (const warning of warnings) {
306
+ console.log(` ⚠️ ${warning}`);
307
+ }
308
+ }
309
+ }
310
+ }
@@ -3,6 +3,7 @@ import { join } from "path";
3
3
 
4
4
  import { resolveTargetAssistant } from "../lib/assistant-config.js";
5
5
  import type { AssistantEntry } from "../lib/assistant-config.js";
6
+ import { dockerResourceNames, sleepContainers } from "../lib/docker.js";
6
7
  import { isProcessAlive, stopProcessByPidFile } from "../lib/process";
7
8
 
8
9
  const ACTIVE_CALL_LEASES_FILE = "active-call-leases.json";
@@ -64,9 +65,16 @@ export async function sleep(): Promise<void> {
64
65
  const nameArg = args.find((a) => !a.startsWith("-"));
65
66
  const entry = resolveTargetAssistant(nameArg);
66
67
 
68
+ if (entry.cloud === "docker") {
69
+ const res = dockerResourceNames(entry.assistantId);
70
+ await sleepContainers(res);
71
+ console.log("Docker containers stopped.");
72
+ return;
73
+ }
74
+
67
75
  if (entry.cloud && entry.cloud !== "local") {
68
76
  console.error(
69
- `Error: 'vellum sleep' only works with local assistants. '${entry.assistantId}' is a ${entry.cloud} instance.`,
77
+ `Error: 'vellum sleep' only works with local and docker assistants. '${entry.assistantId}' is a ${entry.cloud} instance.`,
70
78
  );
71
79
  process.exit(1);
72
80
  }
@@ -5,6 +5,7 @@ import {
5
5
  loadLatestAssistant,
6
6
  } from "../lib/assistant-config";
7
7
  import type { AssistantEntry } from "../lib/assistant-config";
8
+ import { dockerResourceNames } from "../lib/docker";
8
9
 
9
10
  const SSH_OPTS = [
10
11
  "-o",
@@ -82,7 +83,16 @@ export async function ssh(): Promise<void> {
82
83
 
83
84
  let child;
84
85
 
85
- if (cloud === "gcp") {
86
+ if (cloud === "docker") {
87
+ const res = dockerResourceNames(entry.assistantId);
88
+ console.log(`🔗 Connecting to ${entry.assistantId} via docker exec...\n`);
89
+
90
+ child = spawn(
91
+ "docker",
92
+ ["exec", "-it", res.assistantContainer, "/bin/sh"],
93
+ { stdio: "inherit" },
94
+ );
95
+ } else if (cloud === "gcp") {
86
96
  const project = entry.project;
87
97
  const zone = entry.zone;
88
98
  if (!project || !zone) {