@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 +12 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +46 -0
- package/src/adapters/openclaw.ts +4 -2
- package/src/commands/backup.ts +151 -0
- package/src/commands/hatch.ts +14 -4
- package/src/commands/restore.ts +310 -0
- package/src/commands/sleep.ts +9 -1
- package/src/commands/ssh.ts +11 -1
- package/src/commands/upgrade.ts +51 -0
- package/src/commands/wake.ts +10 -1
- package/src/index.ts +6 -0
- package/src/lib/assistant-config.ts +29 -0
- package/src/lib/aws.ts +13 -5
- package/src/lib/constants.ts +12 -0
- package/src/lib/docker.ts +250 -11
- package/src/lib/gcp.ts +18 -6
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
|
@@ -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", () => {
|
package/src/adapters/openclaw.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { buildOpenclawRuntimeServer } from "../lib/openclaw-runtime-server";
|
|
|
3
3
|
|
|
4
4
|
export async function buildOpenclawStartupScript(
|
|
5
5
|
sshUser: string,
|
|
6
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/commands/hatch.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/commands/sleep.ts
CHANGED
|
@@ -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
|
}
|
package/src/commands/ssh.ts
CHANGED
|
@@ -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 === "
|
|
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) {
|