@vellumai/cli 0.1.1
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/bun.lock +291 -0
- package/eslint.config.mjs +17 -0
- package/package.json +26 -0
- package/src/adapters/install.sh +99 -0
- package/src/adapters/openclaw-http-server.ts +189 -0
- package/src/adapters/openclaw.ts +118 -0
- package/src/commands/hatch.ts +806 -0
- package/src/components/DefaultMainScreen.tsx +217 -0
- package/src/index.ts +39 -0
- package/src/lib/constants.ts +75 -0
- package/src/lib/gcp.ts +261 -0
- package/src/lib/health-check.ts +38 -0
- package/src/lib/interfaces-seed.ts +25 -0
- package/src/lib/openclaw-runtime-server.ts +18 -0
- package/src/lib/random-name.ts +133 -0
- package/src/lib/status-emoji.ts +14 -0
- package/src/lib/step-runner.ts +103 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
3
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
4
|
+
import { homedir, tmpdir, userInfo } from "os";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
|
|
7
|
+
import { buildOpenclawStartupScript } from "../adapters/openclaw";
|
|
8
|
+
import {
|
|
9
|
+
FIREWALL_TAG,
|
|
10
|
+
GATEWAY_PORT,
|
|
11
|
+
SPECIES_CONFIG,
|
|
12
|
+
VALID_REMOTE_HOSTS,
|
|
13
|
+
VALID_SPECIES,
|
|
14
|
+
} from "../lib/constants";
|
|
15
|
+
import type { RemoteHost, Species } from "../lib/constants";
|
|
16
|
+
import type { FirewallRuleSpec } from "../lib/gcp";
|
|
17
|
+
import { getActiveProject, instanceExists, syncFirewallRules } from "../lib/gcp";
|
|
18
|
+
import { buildInterfacesSeed } from "../lib/interfaces-seed";
|
|
19
|
+
import { generateRandomSuffix } from "../lib/random-name";
|
|
20
|
+
import { exec, execOutput } from "../lib/step-runner";
|
|
21
|
+
|
|
22
|
+
const DEFAULT_ZONE = "us-central1-a";
|
|
23
|
+
const INSTALL_SCRIPT_REMOTE_PATH = "/tmp/vellum-install.sh";
|
|
24
|
+
const INSTALL_SCRIPT_PATH = join(import.meta.dir, "..", "adapters", "install.sh");
|
|
25
|
+
const MACHINE_TYPE = "e2-standard-4"; // 4 vCPUs, 16 GB memory
|
|
26
|
+
const HATCH_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
|
|
27
|
+
const DEFAULT_SPECIES: Species = "vellum";
|
|
28
|
+
|
|
29
|
+
const DESIRED_FIREWALL_RULES: FirewallRuleSpec[] = [
|
|
30
|
+
{
|
|
31
|
+
name: "allow-vellum-assistant-gateway",
|
|
32
|
+
direction: "INGRESS",
|
|
33
|
+
action: "ALLOW",
|
|
34
|
+
rules: `tcp:${GATEWAY_PORT}`,
|
|
35
|
+
sourceRanges: "0.0.0.0/0",
|
|
36
|
+
targetTags: FIREWALL_TAG,
|
|
37
|
+
description: `Allow gateway ingress on port ${GATEWAY_PORT} for vellum-assistant instances`,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "allow-vellum-assistant-egress",
|
|
41
|
+
direction: "EGRESS",
|
|
42
|
+
action: "ALLOW",
|
|
43
|
+
rules: "all",
|
|
44
|
+
destinationRanges: "0.0.0.0/0",
|
|
45
|
+
targetTags: FIREWALL_TAG,
|
|
46
|
+
description: "Allow all egress traffic for vellum-assistant instances",
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
51
|
+
|
|
52
|
+
function buildTimestampRedirect(): string {
|
|
53
|
+
return `exec > >(while IFS= read -r line; do printf '[%s] %s\\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$line"; done > /var/log/startup-script.log) 2>&1`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildUserSetup(sshUser: string): string {
|
|
57
|
+
return `
|
|
58
|
+
SSH_USER="${sshUser}"
|
|
59
|
+
if ! id "$SSH_USER" &>/dev/null; then
|
|
60
|
+
useradd -m -s /bin/bash "$SSH_USER"
|
|
61
|
+
fi
|
|
62
|
+
SSH_USER_HOME=$(eval echo "~$SSH_USER")
|
|
63
|
+
mkdir -p "$SSH_USER_HOME"
|
|
64
|
+
export HOME="$SSH_USER_HOME"
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildOwnershipFixup(): string {
|
|
69
|
+
return `
|
|
70
|
+
chown -R "$SSH_USER:$SSH_USER" "$SSH_USER_HOME" 2>/dev/null || true
|
|
71
|
+
`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildStartupScript(
|
|
75
|
+
species: Species,
|
|
76
|
+
bearerToken: string,
|
|
77
|
+
sshUser: string,
|
|
78
|
+
anthropicApiKey: string,
|
|
79
|
+
): string {
|
|
80
|
+
const timestampRedirect = buildTimestampRedirect();
|
|
81
|
+
const userSetup = buildUserSetup(sshUser);
|
|
82
|
+
const ownershipFixup = buildOwnershipFixup();
|
|
83
|
+
|
|
84
|
+
if (species === "openclaw") {
|
|
85
|
+
return buildOpenclawStartupScript(
|
|
86
|
+
bearerToken,
|
|
87
|
+
sshUser,
|
|
88
|
+
anthropicApiKey,
|
|
89
|
+
timestampRedirect,
|
|
90
|
+
userSetup,
|
|
91
|
+
ownershipFixup,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const interfacesSeed = buildInterfacesSeed();
|
|
96
|
+
|
|
97
|
+
return `#!/bin/bash
|
|
98
|
+
set -e
|
|
99
|
+
|
|
100
|
+
${timestampRedirect}
|
|
101
|
+
|
|
102
|
+
trap 'EXIT_CODE=\$?; if [ \$EXIT_CODE -ne 0 ]; then echo "Startup script failed with exit code \$EXIT_CODE" > /var/log/startup-error; fi' EXIT
|
|
103
|
+
${userSetup}
|
|
104
|
+
ANTHROPIC_API_KEY=${anthropicApiKey}
|
|
105
|
+
GATEWAY_RUNTIME_PROXY_ENABLED=true
|
|
106
|
+
RUNTIME_PROXY_BEARER_TOKEN=${bearerToken}
|
|
107
|
+
${interfacesSeed}
|
|
108
|
+
mkdir -p "\$HOME/.vellum"
|
|
109
|
+
cat > "\$HOME/.vellum/.env" << DOTENV_EOF
|
|
110
|
+
ANTHROPIC_API_KEY=\$ANTHROPIC_API_KEY
|
|
111
|
+
GATEWAY_RUNTIME_PROXY_ENABLED=\$GATEWAY_RUNTIME_PROXY_ENABLED
|
|
112
|
+
RUNTIME_PROXY_BEARER_TOKEN=\$RUNTIME_PROXY_BEARER_TOKEN
|
|
113
|
+
INTERFACES_SEED_DIR=\$INTERFACES_SEED_DIR
|
|
114
|
+
DOTENV_EOF
|
|
115
|
+
|
|
116
|
+
mkdir -p "\$HOME/.vellum/workspace"
|
|
117
|
+
cat > "\$HOME/.vellum/workspace/config.json" << CONFIG_EOF
|
|
118
|
+
{
|
|
119
|
+
"logFile": {
|
|
120
|
+
"dir": "\$HOME/.vellum/workspace/data/logs"
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
CONFIG_EOF
|
|
124
|
+
|
|
125
|
+
${ownershipFixup}
|
|
126
|
+
|
|
127
|
+
export VELLUM_SSH_USER="\$SSH_USER"
|
|
128
|
+
curl -fsSL https://assistant.vellum.ai/install.sh -o ${INSTALL_SCRIPT_REMOTE_PATH}
|
|
129
|
+
chmod +x ${INSTALL_SCRIPT_REMOTE_PATH}
|
|
130
|
+
source ${INSTALL_SCRIPT_REMOTE_PATH}
|
|
131
|
+
`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const DEFAULT_REMOTE: RemoteHost = "local";
|
|
135
|
+
|
|
136
|
+
interface HatchArgs {
|
|
137
|
+
species: Species;
|
|
138
|
+
detached: boolean;
|
|
139
|
+
name: string | null;
|
|
140
|
+
remote: RemoteHost;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseArgs(): HatchArgs {
|
|
144
|
+
const args = process.argv.slice(3);
|
|
145
|
+
let species: Species = DEFAULT_SPECIES;
|
|
146
|
+
let detached = false;
|
|
147
|
+
let name: string | null = null;
|
|
148
|
+
let remote: RemoteHost = DEFAULT_REMOTE;
|
|
149
|
+
|
|
150
|
+
for (let i = 0; i < args.length; i++) {
|
|
151
|
+
const arg = args[i];
|
|
152
|
+
if (arg === "-d") {
|
|
153
|
+
detached = true;
|
|
154
|
+
} else if (arg === "--name") {
|
|
155
|
+
const next = args[i + 1];
|
|
156
|
+
if (!next || next.startsWith("-")) {
|
|
157
|
+
console.error("Error: --name requires a value");
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
name = next;
|
|
161
|
+
i++;
|
|
162
|
+
} else if (arg === "--remote") {
|
|
163
|
+
const next = args[i + 1];
|
|
164
|
+
if (!next || !VALID_REMOTE_HOSTS.includes(next as RemoteHost)) {
|
|
165
|
+
console.error(
|
|
166
|
+
`Error: --remote requires one of: ${VALID_REMOTE_HOSTS.join(", ")}`,
|
|
167
|
+
);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
remote = next as RemoteHost;
|
|
171
|
+
i++;
|
|
172
|
+
} else if (VALID_SPECIES.includes(arg as Species)) {
|
|
173
|
+
species = arg as Species;
|
|
174
|
+
} else {
|
|
175
|
+
console.error(
|
|
176
|
+
`Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
|
|
177
|
+
);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { species, detached, name, remote };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface PollResult {
|
|
186
|
+
lastLine: string | null;
|
|
187
|
+
done: boolean;
|
|
188
|
+
failed: boolean;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function pollInstance(
|
|
192
|
+
instanceName: string,
|
|
193
|
+
project: string,
|
|
194
|
+
zone: string,
|
|
195
|
+
): Promise<PollResult> {
|
|
196
|
+
try {
|
|
197
|
+
const remoteCmd =
|
|
198
|
+
"L=$(tail -1 /var/log/startup-script.log 2>/dev/null || true); " +
|
|
199
|
+
"S=$(systemctl is-active google-startup-scripts.service 2>/dev/null || true); " +
|
|
200
|
+
"E=$(cat /var/log/startup-error 2>/dev/null || true); " +
|
|
201
|
+
'printf "%s\\n===HATCH_SEP===\\n%s\\n===HATCH_ERR===\\n%s" "$L" "$S" "$E"';
|
|
202
|
+
const output = await execOutput("gcloud", [
|
|
203
|
+
"compute",
|
|
204
|
+
"ssh",
|
|
205
|
+
instanceName,
|
|
206
|
+
`--project=${project}`,
|
|
207
|
+
`--zone=${zone}`,
|
|
208
|
+
"--quiet",
|
|
209
|
+
"--ssh-flag=-o StrictHostKeyChecking=no",
|
|
210
|
+
"--ssh-flag=-o UserKnownHostsFile=/dev/null",
|
|
211
|
+
"--ssh-flag=-o ConnectTimeout=10",
|
|
212
|
+
"--ssh-flag=-o LogLevel=ERROR",
|
|
213
|
+
`--command=${remoteCmd}`,
|
|
214
|
+
]);
|
|
215
|
+
const sepIdx = output.indexOf("===HATCH_SEP===");
|
|
216
|
+
if (sepIdx === -1) {
|
|
217
|
+
return { lastLine: output.trim() || null, done: false, failed: false };
|
|
218
|
+
}
|
|
219
|
+
const errIdx = output.indexOf("===HATCH_ERR===");
|
|
220
|
+
const lastLine = output.substring(0, sepIdx).trim() || null;
|
|
221
|
+
const statusEnd = errIdx === -1 ? undefined : errIdx;
|
|
222
|
+
const status = output.substring(sepIdx + "===HATCH_SEP===".length, statusEnd).trim();
|
|
223
|
+
const errorContent =
|
|
224
|
+
errIdx === -1 ? "" : output.substring(errIdx + "===HATCH_ERR===".length).trim();
|
|
225
|
+
const done = lastLine !== null && status !== "active" && status !== "activating";
|
|
226
|
+
const failed = errorContent.length > 0 || status === "failed";
|
|
227
|
+
return { lastLine, done, failed };
|
|
228
|
+
} catch {
|
|
229
|
+
return { lastLine: null, done: false, failed: false };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function formatElapsed(ms: number): string {
|
|
234
|
+
const secs = Math.floor(ms / 1000);
|
|
235
|
+
const m = Math.floor(secs / 60);
|
|
236
|
+
const s = secs % 60;
|
|
237
|
+
return m > 0 ? `${m}m ${s.toString().padStart(2, "0")}s` : `${s}s`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function pickMessage(messages: string[], elapsedMs: number): string {
|
|
241
|
+
const idx = Math.floor(elapsedMs / 15000) % messages.length;
|
|
242
|
+
return messages[idx];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function getPhaseIcon(hasLogs: boolean, elapsedMs: number, species: Species): string {
|
|
246
|
+
if (!hasLogs) {
|
|
247
|
+
return elapsedMs < 30000 ? "🥚" : "🪺";
|
|
248
|
+
}
|
|
249
|
+
return elapsedMs < 120000 ? "🐣" : SPECIES_CONFIG[species].hatchedEmoji;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function checkCurlFailure(
|
|
253
|
+
instanceName: string,
|
|
254
|
+
project: string,
|
|
255
|
+
zone: string,
|
|
256
|
+
): Promise<boolean> {
|
|
257
|
+
try {
|
|
258
|
+
const output = await execOutput("gcloud", [
|
|
259
|
+
"compute",
|
|
260
|
+
"ssh",
|
|
261
|
+
instanceName,
|
|
262
|
+
`--project=${project}`,
|
|
263
|
+
`--zone=${zone}`,
|
|
264
|
+
"--quiet",
|
|
265
|
+
"--ssh-flag=-o StrictHostKeyChecking=no",
|
|
266
|
+
"--ssh-flag=-o UserKnownHostsFile=/dev/null",
|
|
267
|
+
"--ssh-flag=-o ConnectTimeout=10",
|
|
268
|
+
"--ssh-flag=-o LogLevel=ERROR",
|
|
269
|
+
`--command=test -s ${INSTALL_SCRIPT_REMOTE_PATH} && echo EXISTS || echo MISSING`,
|
|
270
|
+
]);
|
|
271
|
+
return output.trim() === "MISSING";
|
|
272
|
+
} catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function recoverFromCurlFailure(
|
|
278
|
+
instanceName: string,
|
|
279
|
+
project: string,
|
|
280
|
+
zone: string,
|
|
281
|
+
sshUser: string,
|
|
282
|
+
): Promise<void> {
|
|
283
|
+
if (!existsSync(INSTALL_SCRIPT_PATH)) {
|
|
284
|
+
throw new Error(`Install script not found at ${INSTALL_SCRIPT_PATH}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
console.log("📋 Uploading install script to instance...");
|
|
288
|
+
await exec("gcloud", [
|
|
289
|
+
"compute",
|
|
290
|
+
"scp",
|
|
291
|
+
INSTALL_SCRIPT_PATH,
|
|
292
|
+
`${instanceName}:${INSTALL_SCRIPT_REMOTE_PATH}`,
|
|
293
|
+
`--zone=${zone}`,
|
|
294
|
+
`--project=${project}`,
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
console.log("🔧 Running install script on instance...");
|
|
298
|
+
await exec("gcloud", [
|
|
299
|
+
"compute",
|
|
300
|
+
"ssh",
|
|
301
|
+
`${sshUser}@${instanceName}`,
|
|
302
|
+
`--zone=${zone}`,
|
|
303
|
+
`--project=${project}`,
|
|
304
|
+
`--command=source ${INSTALL_SCRIPT_REMOTE_PATH}`,
|
|
305
|
+
]);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function watchHatching(
|
|
309
|
+
instanceName: string,
|
|
310
|
+
project: string,
|
|
311
|
+
zone: string,
|
|
312
|
+
startTime: number,
|
|
313
|
+
species: Species,
|
|
314
|
+
): Promise<boolean> {
|
|
315
|
+
let spinnerIdx = 0;
|
|
316
|
+
let lastLogLine: string | null = null;
|
|
317
|
+
let linesDrawn = 0;
|
|
318
|
+
let finished = false;
|
|
319
|
+
let failed = false;
|
|
320
|
+
let pollInFlight = false;
|
|
321
|
+
let nextPollAt = Date.now() + 15000;
|
|
322
|
+
|
|
323
|
+
function draw(): void {
|
|
324
|
+
if (linesDrawn > 0) {
|
|
325
|
+
process.stdout.write(`\x1b[${linesDrawn}A`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const elapsed = Date.now() - startTime;
|
|
329
|
+
|
|
330
|
+
const hasLogs = lastLogLine !== null;
|
|
331
|
+
const icon = finished
|
|
332
|
+
? failed
|
|
333
|
+
? "💀"
|
|
334
|
+
: SPECIES_CONFIG[species].hatchedEmoji
|
|
335
|
+
: getPhaseIcon(hasLogs, elapsed, species);
|
|
336
|
+
const spinner = finished
|
|
337
|
+
? failed
|
|
338
|
+
? "✘"
|
|
339
|
+
: "✔"
|
|
340
|
+
: SPINNER_FRAMES[spinnerIdx % SPINNER_FRAMES.length];
|
|
341
|
+
const config = SPECIES_CONFIG[species];
|
|
342
|
+
const message = finished
|
|
343
|
+
? failed
|
|
344
|
+
? "❌ Startup script failed"
|
|
345
|
+
: "✨ Your assistant has hatched!"
|
|
346
|
+
: hasLogs
|
|
347
|
+
? lastLogLine!.length > 68
|
|
348
|
+
? lastLogLine!.substring(0, 65) + "..."
|
|
349
|
+
: lastLogLine!
|
|
350
|
+
: pickMessage(config.waitingMessages, elapsed);
|
|
351
|
+
spinnerIdx++;
|
|
352
|
+
|
|
353
|
+
const lines = ["", ` ${icon} ${spinner} ${message} ⏱ ${formatElapsed(elapsed)}`, ""];
|
|
354
|
+
|
|
355
|
+
for (const line of lines) {
|
|
356
|
+
process.stdout.write(`\x1b[K${line}\n`);
|
|
357
|
+
}
|
|
358
|
+
linesDrawn = lines.length;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function poll(): Promise<void> {
|
|
362
|
+
if (pollInFlight || finished) return;
|
|
363
|
+
pollInFlight = true;
|
|
364
|
+
try {
|
|
365
|
+
const result = await pollInstance(instanceName, project, zone);
|
|
366
|
+
if (result.lastLine) {
|
|
367
|
+
lastLogLine = result.lastLine;
|
|
368
|
+
}
|
|
369
|
+
if (result.done) {
|
|
370
|
+
finished = true;
|
|
371
|
+
failed = result.failed;
|
|
372
|
+
}
|
|
373
|
+
} finally {
|
|
374
|
+
pollInFlight = false;
|
|
375
|
+
nextPollAt = Date.now() + 5000;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return new Promise<boolean>((resolve) => {
|
|
380
|
+
const interval = setInterval(() => {
|
|
381
|
+
if (finished) {
|
|
382
|
+
draw();
|
|
383
|
+
clearInterval(interval);
|
|
384
|
+
resolve(!failed);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const elapsed = Date.now() - startTime;
|
|
389
|
+
if (elapsed >= HATCH_TIMEOUT_MS) {
|
|
390
|
+
clearInterval(interval);
|
|
391
|
+
console.log("");
|
|
392
|
+
console.log(` ⏰ Timed out after ${formatElapsed(elapsed)}. Instance is still running.`);
|
|
393
|
+
console.log(` Monitor with: vel logs ${instanceName}`);
|
|
394
|
+
console.log("");
|
|
395
|
+
resolve(true);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (Date.now() >= nextPollAt) {
|
|
400
|
+
poll();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
draw();
|
|
404
|
+
}, 80);
|
|
405
|
+
|
|
406
|
+
process.on("SIGINT", () => {
|
|
407
|
+
clearInterval(interval);
|
|
408
|
+
console.log("");
|
|
409
|
+
console.log(` ⚠️ Detaching. Instance is still running.`);
|
|
410
|
+
console.log(` Monitor with: vel logs ${instanceName}`);
|
|
411
|
+
console.log("");
|
|
412
|
+
process.exit(0);
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
interface CloudCredentials {
|
|
418
|
+
provider: string;
|
|
419
|
+
projectId?: string;
|
|
420
|
+
serviceAccountKey?: string;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
interface WorkspaceConfig {
|
|
424
|
+
cloudCredentials?: CloudCredentials;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function activateGcpCredentialsFromConfig(): Promise<void> {
|
|
428
|
+
const configPath = join(homedir(), ".vellum", "workspace", "config.json");
|
|
429
|
+
let config: WorkspaceConfig;
|
|
430
|
+
try {
|
|
431
|
+
config = JSON.parse(readFileSync(configPath, "utf8")) as WorkspaceConfig;
|
|
432
|
+
} catch {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const creds = config.cloudCredentials;
|
|
437
|
+
if (!creds || creds.provider !== "gcp" || !creds.serviceAccountKey || !creds.projectId) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const keyPath = join(tmpdir(), `vellum-sa-key-${Date.now()}.json`);
|
|
442
|
+
writeFileSync(keyPath, creds.serviceAccountKey);
|
|
443
|
+
try {
|
|
444
|
+
await exec("gcloud", [
|
|
445
|
+
"auth",
|
|
446
|
+
"activate-service-account",
|
|
447
|
+
`--key-file=${keyPath}`,
|
|
448
|
+
]);
|
|
449
|
+
await exec("gcloud", ["config", "set", "project", creds.projectId]);
|
|
450
|
+
} finally {
|
|
451
|
+
try {
|
|
452
|
+
unlinkSync(keyPath);
|
|
453
|
+
} catch {}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function hatchGcp(
|
|
458
|
+
species: Species,
|
|
459
|
+
detached: boolean,
|
|
460
|
+
name: string | null,
|
|
461
|
+
): Promise<void> {
|
|
462
|
+
const startTime = Date.now();
|
|
463
|
+
try {
|
|
464
|
+
await activateGcpCredentialsFromConfig();
|
|
465
|
+
const project = process.env.GCP_PROJECT ?? (await getActiveProject());
|
|
466
|
+
let instanceName: string;
|
|
467
|
+
|
|
468
|
+
if (name) {
|
|
469
|
+
instanceName = name;
|
|
470
|
+
} else {
|
|
471
|
+
const suffix = generateRandomSuffix();
|
|
472
|
+
instanceName = `${species}-${suffix}`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
console.log(`🥚 Creating new assistant: ${instanceName}`);
|
|
476
|
+
console.log(` Species: ${species}`);
|
|
477
|
+
console.log(` Cloud: GCP`);
|
|
478
|
+
console.log(` Project: ${project}`);
|
|
479
|
+
console.log(` Zone: ${DEFAULT_ZONE}`);
|
|
480
|
+
console.log(` Machine type: ${MACHINE_TYPE}`);
|
|
481
|
+
console.log("");
|
|
482
|
+
|
|
483
|
+
if (name) {
|
|
484
|
+
if (await instanceExists(name, project, DEFAULT_ZONE)) {
|
|
485
|
+
console.error(
|
|
486
|
+
`Error: Instance name '${name}' is already taken. Please choose a different name.`,
|
|
487
|
+
);
|
|
488
|
+
process.exit(1);
|
|
489
|
+
}
|
|
490
|
+
} else {
|
|
491
|
+
while (await instanceExists(instanceName, project, DEFAULT_ZONE)) {
|
|
492
|
+
console.log(`⚠️ Instance name ${instanceName} already exists, generating a new name...`);
|
|
493
|
+
const suffix = generateRandomSuffix();
|
|
494
|
+
instanceName = `${species}-${suffix}`;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const sshUser = userInfo().username;
|
|
499
|
+
const bearerToken = randomBytes(32).toString("hex");
|
|
500
|
+
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
|
501
|
+
if (!anthropicApiKey) {
|
|
502
|
+
console.error("Error: ANTHROPIC_API_KEY environment variable is not set.");
|
|
503
|
+
process.exit(1);
|
|
504
|
+
}
|
|
505
|
+
const startupScript = buildStartupScript(species, bearerToken, sshUser, anthropicApiKey);
|
|
506
|
+
const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
|
|
507
|
+
writeFileSync(startupScriptPath, startupScript);
|
|
508
|
+
|
|
509
|
+
console.log("🔨 Creating instance with startup script...");
|
|
510
|
+
try {
|
|
511
|
+
await exec("gcloud", [
|
|
512
|
+
"compute",
|
|
513
|
+
"instances",
|
|
514
|
+
"create",
|
|
515
|
+
instanceName,
|
|
516
|
+
`--project=${project}`,
|
|
517
|
+
`--zone=${DEFAULT_ZONE}`,
|
|
518
|
+
`--machine-type=${MACHINE_TYPE}`,
|
|
519
|
+
"--image-family=debian-11",
|
|
520
|
+
"--image-project=debian-cloud",
|
|
521
|
+
"--boot-disk-size=50GB",
|
|
522
|
+
"--boot-disk-type=pd-standard",
|
|
523
|
+
`--metadata-from-file=startup-script=${startupScriptPath}`,
|
|
524
|
+
`--labels=species=${species},vellum-assistant=true`,
|
|
525
|
+
"--tags=vellum-assistant",
|
|
526
|
+
]);
|
|
527
|
+
} finally {
|
|
528
|
+
try {
|
|
529
|
+
unlinkSync(startupScriptPath);
|
|
530
|
+
} catch {}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
console.log("🔒 Syncing firewall rules...");
|
|
534
|
+
await syncFirewallRules(DESIRED_FIREWALL_RULES, project, FIREWALL_TAG);
|
|
535
|
+
|
|
536
|
+
console.log(`✅ Instance ${instanceName} created successfully\n`);
|
|
537
|
+
|
|
538
|
+
let externalIp: string | null = null;
|
|
539
|
+
try {
|
|
540
|
+
const ipOutput = await execOutput("gcloud", [
|
|
541
|
+
"compute",
|
|
542
|
+
"instances",
|
|
543
|
+
"describe",
|
|
544
|
+
instanceName,
|
|
545
|
+
`--project=${project}`,
|
|
546
|
+
`--zone=${DEFAULT_ZONE}`,
|
|
547
|
+
"--format=get(networkInterfaces[0].accessConfigs[0].natIP)",
|
|
548
|
+
]);
|
|
549
|
+
externalIp = ipOutput.trim() || null;
|
|
550
|
+
} catch {
|
|
551
|
+
console.log("⚠️ Could not retrieve external IP yet (instance may still be starting)");
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const runtimeUrl = externalIp
|
|
555
|
+
? `http://${externalIp}:${GATEWAY_PORT}`
|
|
556
|
+
: `http://${instanceName}:${GATEWAY_PORT}`;
|
|
557
|
+
const entryFilePath = process.env.VELLUM_HATCH_ENTRY_FILE;
|
|
558
|
+
if (entryFilePath) {
|
|
559
|
+
writeFileSync(
|
|
560
|
+
entryFilePath,
|
|
561
|
+
JSON.stringify({
|
|
562
|
+
assistantId: instanceName,
|
|
563
|
+
runtimeUrl,
|
|
564
|
+
bearerToken,
|
|
565
|
+
project,
|
|
566
|
+
zone: DEFAULT_ZONE,
|
|
567
|
+
species,
|
|
568
|
+
sshUser,
|
|
569
|
+
hatchedAt: new Date().toISOString(),
|
|
570
|
+
}),
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (detached) {
|
|
575
|
+
console.log("🚀 Startup script is running on the instance...");
|
|
576
|
+
console.log("");
|
|
577
|
+
console.log("✅ Assistant is hatching!\n");
|
|
578
|
+
console.log("Instance details:");
|
|
579
|
+
console.log(` Name: ${instanceName}`);
|
|
580
|
+
console.log(` Project: ${project}`);
|
|
581
|
+
console.log(` Zone: ${DEFAULT_ZONE}`);
|
|
582
|
+
if (externalIp) {
|
|
583
|
+
console.log(` External IP: ${externalIp}`);
|
|
584
|
+
}
|
|
585
|
+
console.log("");
|
|
586
|
+
} else {
|
|
587
|
+
console.log(" Press Ctrl+C to detach (instance will keep running)");
|
|
588
|
+
console.log("");
|
|
589
|
+
|
|
590
|
+
const success = await watchHatching(
|
|
591
|
+
instanceName,
|
|
592
|
+
project,
|
|
593
|
+
DEFAULT_ZONE,
|
|
594
|
+
startTime,
|
|
595
|
+
species,
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
if (!success) {
|
|
599
|
+
if (
|
|
600
|
+
species === "vellum" &&
|
|
601
|
+
(await checkCurlFailure(instanceName, project, DEFAULT_ZONE))
|
|
602
|
+
) {
|
|
603
|
+
console.log("");
|
|
604
|
+
console.log("🔄 Detected install script curl failure, attempting recovery...");
|
|
605
|
+
await recoverFromCurlFailure(instanceName, project, DEFAULT_ZONE, sshUser);
|
|
606
|
+
console.log("✅ Recovery successful!");
|
|
607
|
+
} else {
|
|
608
|
+
console.log("");
|
|
609
|
+
process.exit(1);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
console.log("Instance details:");
|
|
614
|
+
console.log(` Name: ${instanceName}`);
|
|
615
|
+
console.log(` Project: ${project}`);
|
|
616
|
+
console.log(` Zone: ${DEFAULT_ZONE}`);
|
|
617
|
+
if (externalIp) {
|
|
618
|
+
console.log(` External IP: ${externalIp}`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
} catch (error) {
|
|
622
|
+
console.error("❌ Error:", error instanceof Error ? error.message : error);
|
|
623
|
+
process.exit(1);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function buildSshArgs(host: string): string[] {
|
|
628
|
+
return [
|
|
629
|
+
host,
|
|
630
|
+
"-o", "StrictHostKeyChecking=no",
|
|
631
|
+
"-o", "UserKnownHostsFile=/dev/null",
|
|
632
|
+
"-o", "ConnectTimeout=10",
|
|
633
|
+
"-o", "LogLevel=ERROR",
|
|
634
|
+
];
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function extractHostname(host: string): string {
|
|
638
|
+
return host.includes("@") ? host.split("@")[1] : host;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function hatchCustom(
|
|
642
|
+
species: Species,
|
|
643
|
+
detached: boolean,
|
|
644
|
+
name: string | null,
|
|
645
|
+
): Promise<void> {
|
|
646
|
+
const host = process.env.VELLUM_CUSTOM_HOST;
|
|
647
|
+
if (!host) {
|
|
648
|
+
console.error("Error: VELLUM_CUSTOM_HOST environment variable is required when using --remote custom (e.g., user@hostname)");
|
|
649
|
+
process.exit(1);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
try {
|
|
653
|
+
const hostname = extractHostname(host);
|
|
654
|
+
const instanceName = name ?? `${species}-${generateRandomSuffix()}`;
|
|
655
|
+
|
|
656
|
+
console.log(`🥚 Creating new assistant: ${instanceName}`);
|
|
657
|
+
console.log(` Species: ${species}`);
|
|
658
|
+
console.log(` Cloud: Custom`);
|
|
659
|
+
console.log(` Host: ${host}`);
|
|
660
|
+
console.log("");
|
|
661
|
+
|
|
662
|
+
const sshUser = host.includes("@") ? host.split("@")[0] : userInfo().username;
|
|
663
|
+
const bearerToken = randomBytes(32).toString("hex");
|
|
664
|
+
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
|
665
|
+
if (!anthropicApiKey) {
|
|
666
|
+
console.error("Error: ANTHROPIC_API_KEY environment variable is not set.");
|
|
667
|
+
process.exit(1);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const startupScript = buildStartupScript(species, bearerToken, sshUser, anthropicApiKey);
|
|
671
|
+
const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
|
|
672
|
+
writeFileSync(startupScriptPath, startupScript);
|
|
673
|
+
|
|
674
|
+
try {
|
|
675
|
+
console.log("📋 Uploading install script to instance...");
|
|
676
|
+
await exec("scp", [
|
|
677
|
+
"-o", "StrictHostKeyChecking=no",
|
|
678
|
+
"-o", "UserKnownHostsFile=/dev/null",
|
|
679
|
+
"-o", "LogLevel=ERROR",
|
|
680
|
+
INSTALL_SCRIPT_PATH,
|
|
681
|
+
`${host}:${INSTALL_SCRIPT_REMOTE_PATH}`,
|
|
682
|
+
]);
|
|
683
|
+
|
|
684
|
+
console.log("📋 Uploading startup script to instance...");
|
|
685
|
+
const remoteStartupPath = `/tmp/${instanceName}-startup.sh`;
|
|
686
|
+
await exec("scp", [
|
|
687
|
+
"-o", "StrictHostKeyChecking=no",
|
|
688
|
+
"-o", "UserKnownHostsFile=/dev/null",
|
|
689
|
+
"-o", "LogLevel=ERROR",
|
|
690
|
+
startupScriptPath,
|
|
691
|
+
`${host}:${remoteStartupPath}`,
|
|
692
|
+
]);
|
|
693
|
+
|
|
694
|
+
console.log("🔨 Running startup script on instance...");
|
|
695
|
+
await exec("ssh", [
|
|
696
|
+
...buildSshArgs(host),
|
|
697
|
+
`chmod +x ${remoteStartupPath} ${INSTALL_SCRIPT_REMOTE_PATH} && bash ${remoteStartupPath}`,
|
|
698
|
+
]);
|
|
699
|
+
} finally {
|
|
700
|
+
try {
|
|
701
|
+
unlinkSync(startupScriptPath);
|
|
702
|
+
} catch {}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const runtimeUrl = `http://${hostname}:${GATEWAY_PORT}`;
|
|
706
|
+
const entryFilePath = process.env.VELLUM_HATCH_ENTRY_FILE;
|
|
707
|
+
if (entryFilePath) {
|
|
708
|
+
writeFileSync(
|
|
709
|
+
entryFilePath,
|
|
710
|
+
JSON.stringify({
|
|
711
|
+
assistantId: instanceName,
|
|
712
|
+
runtimeUrl,
|
|
713
|
+
bearerToken,
|
|
714
|
+
species,
|
|
715
|
+
sshUser,
|
|
716
|
+
hatchedAt: new Date().toISOString(),
|
|
717
|
+
}),
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (detached) {
|
|
722
|
+
console.log("");
|
|
723
|
+
console.log("✅ Assistant is hatching!\n");
|
|
724
|
+
} else {
|
|
725
|
+
console.log("");
|
|
726
|
+
console.log("✅ Assistant has been set up!");
|
|
727
|
+
}
|
|
728
|
+
console.log("Instance details:");
|
|
729
|
+
console.log(` Name: ${instanceName}`);
|
|
730
|
+
console.log(` Host: ${host}`);
|
|
731
|
+
console.log(` Runtime URL: ${runtimeUrl}`);
|
|
732
|
+
console.log("");
|
|
733
|
+
} catch (error) {
|
|
734
|
+
console.error("❌ Error:", error instanceof Error ? error.message : error);
|
|
735
|
+
process.exit(1);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
async function hatchLocal(species: Species, name: string | null): Promise<void> {
|
|
740
|
+
const instanceName = name ?? `${species}-${generateRandomSuffix()}`;
|
|
741
|
+
|
|
742
|
+
console.log(`🥚 Hatching local assistant: ${instanceName}`);
|
|
743
|
+
console.log(` Species: ${species}`);
|
|
744
|
+
console.log("");
|
|
745
|
+
|
|
746
|
+
console.log("🔨 Starting local daemon...");
|
|
747
|
+
const child = spawn("bunx", ["vellum", "daemon", "start"], {
|
|
748
|
+
stdio: "inherit",
|
|
749
|
+
env: { ...process.env },
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
await new Promise<void>((resolve, reject) => {
|
|
753
|
+
child.on("close", (code) => {
|
|
754
|
+
if (code === 0) {
|
|
755
|
+
resolve();
|
|
756
|
+
} else {
|
|
757
|
+
reject(new Error(`Daemon start exited with code ${code}`));
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
child.on("error", reject);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
const runtimeUrl = `http://localhost:${GATEWAY_PORT}`;
|
|
764
|
+
const entryFilePath = process.env.VELLUM_HATCH_ENTRY_FILE;
|
|
765
|
+
if (entryFilePath) {
|
|
766
|
+
writeFileSync(
|
|
767
|
+
entryFilePath,
|
|
768
|
+
JSON.stringify({
|
|
769
|
+
assistantId: instanceName,
|
|
770
|
+
runtimeUrl,
|
|
771
|
+
species,
|
|
772
|
+
hatchedAt: new Date().toISOString(),
|
|
773
|
+
}),
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
console.log("");
|
|
778
|
+
console.log(`✅ Local assistant hatched!`);
|
|
779
|
+
console.log("");
|
|
780
|
+
console.log("Instance details:");
|
|
781
|
+
console.log(` Name: ${instanceName}`);
|
|
782
|
+
console.log(` Runtime: ${runtimeUrl}`);
|
|
783
|
+
console.log("");
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export async function hatch(): Promise<void> {
|
|
787
|
+
const { species, detached, name, remote } = parseArgs();
|
|
788
|
+
|
|
789
|
+
if (remote === "local") {
|
|
790
|
+
await hatchLocal(species, name);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (remote === "gcp") {
|
|
795
|
+
await hatchGcp(species, detached, name);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (remote === "custom") {
|
|
800
|
+
await hatchCustom(species, detached, name);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
console.error(`Error: Remote host '${remote}' is not yet supported.`);
|
|
805
|
+
process.exit(1);
|
|
806
|
+
}
|