@vellumai/cli 0.8.5 → 0.8.7
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 +6 -0
- package/bun.lock +8 -0
- package/knip.json +6 -1
- package/node_modules/@vellumai/environments/bun.lock +24 -0
- package/node_modules/@vellumai/environments/package.json +18 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
- package/node_modules/@vellumai/environments/src/index.ts +11 -0
- package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
- package/node_modules/@vellumai/environments/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/bun.lock +29 -0
- package/node_modules/@vellumai/local-mode/package.json +21 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +93 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +59 -0
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +67 -0
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
- package/node_modules/@vellumai/local-mode/src/hatch.ts +74 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +26 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +131 -0
- package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
- package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
- package/package.json +12 -1
- package/src/__tests__/backup.test.ts +38 -0
- package/src/__tests__/env-drift.test.ts +32 -44
- package/src/__tests__/flags.test.ts +248 -0
- package/src/__tests__/multi-local.test.ts +1 -1
- package/src/__tests__/orphan-detection.test.ts +8 -6
- package/src/__tests__/recover.test.ts +307 -0
- package/src/__tests__/segments-to-plain-text.test.ts +37 -0
- package/src/__tests__/wake.test.ts +215 -0
- package/src/commands/backup.ts +2 -0
- package/src/commands/client.ts +471 -30
- package/src/commands/env.ts +1 -1
- package/src/commands/flags.ts +269 -0
- package/src/commands/gateway/token.ts +73 -0
- package/src/commands/gateway.ts +29 -0
- package/src/commands/logs.ts +6 -18
- package/src/commands/ps.ts +41 -41
- package/src/commands/recover.ts +47 -9
- package/src/commands/restore.ts +8 -1
- package/src/commands/retire.ts +3 -23
- package/src/commands/rollback.ts +2 -14
- package/src/commands/ssh.ts +5 -24
- package/src/commands/teleport.ts +34 -26
- package/src/commands/upgrade.ts +8 -16
- package/src/commands/wake.ts +68 -45
- package/src/components/DefaultMainScreen.tsx +16 -1
- package/src/index.ts +6 -0
- package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
- package/src/lib/__tests__/step-runner.test.ts +49 -1
- package/src/lib/assistant-config.ts +16 -3
- package/src/lib/config-utils.ts +24 -3
- package/src/lib/docker.ts +57 -7
- package/src/lib/environments/__tests__/paths.test.ts +2 -1
- package/src/lib/environments/__tests__/seeds.test.ts +2 -1
- package/src/lib/environments/paths.ts +1 -1
- package/src/lib/environments/resolve.ts +2 -5
- package/src/lib/guardian-token.ts +12 -5
- package/src/lib/hatch-local.ts +75 -33
- package/src/lib/http-client.ts +1 -3
- package/src/lib/lifecycle-reporter.ts +31 -0
- package/src/lib/local.ts +173 -292
- package/src/lib/orphan-detection.ts +9 -5
- package/src/lib/pgrep.ts +5 -1
- package/src/lib/platform-client.ts +97 -49
- package/src/lib/process.ts +109 -39
- package/src/lib/retire-local.ts +28 -14
- package/src/lib/segments-to-plain-text.ts +35 -0
- package/src/lib/step-runner.ts +67 -7
- package/src/lib/sync-cloud-assistants.ts +17 -0
- /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { CliInvocation } from "./util";
|
|
6
|
+
|
|
7
|
+
const GUARDIAN_TOKEN_REFRESH_TIMEOUT_MS = 15_000;
|
|
8
|
+
|
|
9
|
+
interface GuardianTokenData {
|
|
10
|
+
accessToken: string;
|
|
11
|
+
accessTokenExpiresAt: string | number;
|
|
12
|
+
refreshToken: string;
|
|
13
|
+
refreshTokenExpiresAt: string | number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isAccessTokenExpired(data: GuardianTokenData): boolean {
|
|
17
|
+
const expiresAt = new Date(data.accessTokenExpiresAt).getTime();
|
|
18
|
+
if (!Number.isFinite(expiresAt)) return true;
|
|
19
|
+
return Date.now() >= expiresAt - 60_000;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isRefreshTokenExpired(data: GuardianTokenData): boolean {
|
|
23
|
+
const expiresAt = new Date(data.refreshTokenExpiresAt).getTime();
|
|
24
|
+
if (!Number.isFinite(expiresAt)) return true;
|
|
25
|
+
return Date.now() >= expiresAt;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type TokenResult =
|
|
29
|
+
| { ok: true; accessToken: string }
|
|
30
|
+
| { ok: false; status: number; error: string };
|
|
31
|
+
|
|
32
|
+
export function getGuardianAccessToken(
|
|
33
|
+
assistantId: string,
|
|
34
|
+
configDir: string,
|
|
35
|
+
invocation: CliInvocation,
|
|
36
|
+
isLoopback: boolean,
|
|
37
|
+
env?: Record<string, string>,
|
|
38
|
+
): Promise<TokenResult> {
|
|
39
|
+
if (!isLoopback) {
|
|
40
|
+
return Promise.resolve({ ok: false, status: 403, error: "Forbidden" });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const tokenPath = path.join(configDir, "assistants", assistantId, "guardian-token.json");
|
|
44
|
+
|
|
45
|
+
let raw: string;
|
|
46
|
+
try {
|
|
47
|
+
raw = fs.readFileSync(tokenPath, "utf-8");
|
|
48
|
+
} catch {
|
|
49
|
+
return Promise.resolve({ ok: false, status: 404, error: "Guardian token not found" });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let data: GuardianTokenData;
|
|
53
|
+
try {
|
|
54
|
+
data = JSON.parse(raw) as GuardianTokenData;
|
|
55
|
+
} catch {
|
|
56
|
+
return Promise.resolve({ ok: false, status: 500, error: "Malformed guardian token file" });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!isAccessTokenExpired(data)) {
|
|
60
|
+
return Promise.resolve({ ok: true, accessToken: data.accessToken });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (isRefreshTokenExpired(data)) {
|
|
64
|
+
return Promise.resolve({
|
|
65
|
+
ok: false,
|
|
66
|
+
status: 401,
|
|
67
|
+
error: "Guardian token expired — re-run `vellum hatch` or `vellum wake`",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return refreshToken(assistantId, invocation, env);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function refreshToken(
|
|
75
|
+
assistantId: string,
|
|
76
|
+
invocation: CliInvocation,
|
|
77
|
+
env?: Record<string, string>,
|
|
78
|
+
): Promise<TokenResult> {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const child = spawn(
|
|
81
|
+
invocation.command,
|
|
82
|
+
[...invocation.baseArgs, "gateway", "token", "refresh", assistantId],
|
|
83
|
+
{ stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } },
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
let stdout = "";
|
|
87
|
+
let done = false;
|
|
88
|
+
|
|
89
|
+
const finish = (result: TokenResult) => {
|
|
90
|
+
if (done) return;
|
|
91
|
+
done = true;
|
|
92
|
+
clearTimeout(timeout);
|
|
93
|
+
resolve(result);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const timeout = setTimeout(() => {
|
|
97
|
+
child.kill("SIGTERM");
|
|
98
|
+
finish({ ok: false, status: 500, error: "Guardian token refresh timed out" });
|
|
99
|
+
}, GUARDIAN_TOKEN_REFRESH_TIMEOUT_MS);
|
|
100
|
+
|
|
101
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
102
|
+
stdout += chunk.toString();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
child.on("close", (code) => {
|
|
106
|
+
if (code === 0) {
|
|
107
|
+
const accessToken = stdout.trim();
|
|
108
|
+
if (accessToken) {
|
|
109
|
+
finish({ ok: true, accessToken });
|
|
110
|
+
} else {
|
|
111
|
+
finish({ ok: false, status: 500, error: "CLI returned empty token" });
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
finish({ ok: false, status: 401, error: "Failed to refresh guardian token" });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
child.on("error", (err) => {
|
|
119
|
+
finish({ ok: false, status: 500, error: `Failed to spawn CLI: ${err.message}` });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import type { CliInvocation } from "./util";
|
|
4
|
+
|
|
5
|
+
const HATCH_TIMEOUT_MS = 120_000;
|
|
6
|
+
|
|
7
|
+
export type HatchResult =
|
|
8
|
+
| { ok: true; assistantId: string }
|
|
9
|
+
| { ok: false; status: number; error: string };
|
|
10
|
+
|
|
11
|
+
export function runHatch(
|
|
12
|
+
invocation: CliInvocation,
|
|
13
|
+
species: string,
|
|
14
|
+
): Promise<HatchResult> {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const child = spawn(
|
|
17
|
+
invocation.command,
|
|
18
|
+
[...invocation.baseArgs, "hatch", species],
|
|
19
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
let stdout = "";
|
|
23
|
+
let stderr = "";
|
|
24
|
+
let done = false;
|
|
25
|
+
|
|
26
|
+
const finish = (result: HatchResult) => {
|
|
27
|
+
if (done) return;
|
|
28
|
+
done = true;
|
|
29
|
+
clearTimeout(timeout);
|
|
30
|
+
resolve(result);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const timeout = setTimeout(() => {
|
|
34
|
+
child.kill("SIGTERM");
|
|
35
|
+
finish({ ok: false, status: 500, error: "Hatch timed out after 120 seconds" });
|
|
36
|
+
}, HATCH_TIMEOUT_MS);
|
|
37
|
+
|
|
38
|
+
child.stdout.on("data", (data: Buffer) => {
|
|
39
|
+
stdout += data.toString();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
child.stderr.on("data", (data: Buffer) => {
|
|
43
|
+
stderr += data.toString();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
child.on("close", (code) => {
|
|
47
|
+
if (code !== 0) {
|
|
48
|
+
const error =
|
|
49
|
+
stderr.trim() ||
|
|
50
|
+
stdout.trim() ||
|
|
51
|
+
`Hatch failed: the CLI exited with code ${code ?? "unknown"} and produced no output.`;
|
|
52
|
+
finish({ ok: false, status: 500, error });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const assistantId = stdout
|
|
56
|
+
.match(/Hatching local assistant:\s+(.+)/)?.[1]
|
|
57
|
+
?.trim();
|
|
58
|
+
if (!assistantId) {
|
|
59
|
+
finish({
|
|
60
|
+
ok: false,
|
|
61
|
+
status: 500,
|
|
62
|
+
error:
|
|
63
|
+
"Hatch reported success but no assistant id was found in the CLI output.",
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
finish({ ok: true, assistantId });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
child.on("error", (err) => {
|
|
71
|
+
finish({ ok: false, status: 500, error: `Failed to spawn CLI: ${err.message}` });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vellumai/local-mode — shared host library for serving the local-assistant
|
|
3
|
+
* surface (lockfile reads, guardian-token issuance, gateway proxying, and the
|
|
4
|
+
* hatch/retire lifecycle ops) over a loopback HTTP boundary. Consumed by the
|
|
5
|
+
* CLI `client` server and the web app's dev-server middleware so the local
|
|
6
|
+
* endpoint behaviour is defined exactly once instead of one host reaching into
|
|
7
|
+
* another's source tree. Depends only on `@vellumai/environments`.
|
|
8
|
+
*/
|
|
9
|
+
export {
|
|
10
|
+
stripSensitiveFields,
|
|
11
|
+
isLoopbackAddr,
|
|
12
|
+
resolveDevCliInvocation,
|
|
13
|
+
} from "./util";
|
|
14
|
+
export type { CliInvocation } from "./util";
|
|
15
|
+
export { resolveLocalConfigFromEnv, resolveLockfilePaths, resolveConfigDir } from "./config";
|
|
16
|
+
export type { LocalEndpointConfig } from "./config";
|
|
17
|
+
export { getLockfileData, upsertLockfileAssistant, replacePlatformAssistants } from "./lockfile";
|
|
18
|
+
export type { LockfileResult, WriteResult } from "./lockfile";
|
|
19
|
+
export { runHatch } from "./hatch";
|
|
20
|
+
export type { HatchResult } from "./hatch";
|
|
21
|
+
export { runRetire } from "./retire";
|
|
22
|
+
export type { RetireResult } from "./retire";
|
|
23
|
+
export { getGuardianAccessToken } from "./guardian-token";
|
|
24
|
+
export type { TokenResult } from "./guardian-token";
|
|
25
|
+
export { parseGatewayUrl, readAllowedGatewayPorts } from "./gateway-proxy";
|
|
26
|
+
export type { GatewayTarget, GatewayParseResult } from "./gateway-proxy";
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { stripSensitiveFields } from "./util";
|
|
5
|
+
|
|
6
|
+
export type LockfileResult =
|
|
7
|
+
| { ok: true; data: Record<string, unknown> }
|
|
8
|
+
| { ok: false; status: number; error?: string };
|
|
9
|
+
|
|
10
|
+
export function getLockfileData(lockfilePaths: string[]): LockfileResult {
|
|
11
|
+
let raw: string | undefined;
|
|
12
|
+
for (const candidate of lockfilePaths) {
|
|
13
|
+
try {
|
|
14
|
+
raw = fs.readFileSync(candidate, "utf-8");
|
|
15
|
+
break;
|
|
16
|
+
} catch (err: unknown) {
|
|
17
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
18
|
+
return { ok: false, status: 500 };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!raw) {
|
|
24
|
+
return { ok: true, data: { assistants: [], activeAssistant: null } };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const data = JSON.parse(raw) as Record<string, unknown>;
|
|
29
|
+
stripSensitiveFields(data);
|
|
30
|
+
return { ok: true, data };
|
|
31
|
+
} catch {
|
|
32
|
+
return { ok: false, status: 500 };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type WriteResult =
|
|
37
|
+
| { ok: true; lockfile: Record<string, unknown> }
|
|
38
|
+
| { ok: false; status: number; error: string };
|
|
39
|
+
|
|
40
|
+
export function upsertLockfileAssistant(
|
|
41
|
+
lockfilePaths: string[],
|
|
42
|
+
assistant: Record<string, unknown>,
|
|
43
|
+
activeAssistant: string | undefined,
|
|
44
|
+
): WriteResult {
|
|
45
|
+
if (!assistant || typeof assistant.assistantId !== "string") {
|
|
46
|
+
return { ok: false, status: 400, error: "Missing assistant.assistantId" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let lockfile: Record<string, unknown> = { assistants: [], activeAssistant: null };
|
|
50
|
+
for (const candidate of lockfilePaths) {
|
|
51
|
+
try {
|
|
52
|
+
lockfile = JSON.parse(fs.readFileSync(candidate, "utf-8")) as Record<string, unknown>;
|
|
53
|
+
break;
|
|
54
|
+
} catch {
|
|
55
|
+
// continue
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const assistants = Array.isArray(lockfile.assistants) ? lockfile.assistants : [];
|
|
60
|
+
const existingIdx = assistants.findIndex(
|
|
61
|
+
(a: Record<string, unknown>) => a?.assistantId === assistant.assistantId,
|
|
62
|
+
);
|
|
63
|
+
if (existingIdx >= 0) {
|
|
64
|
+
assistants[existingIdx] = { ...assistants[existingIdx], ...assistant };
|
|
65
|
+
} else {
|
|
66
|
+
assistants.push(assistant);
|
|
67
|
+
}
|
|
68
|
+
lockfile.assistants = assistants;
|
|
69
|
+
if (activeAssistant !== undefined) {
|
|
70
|
+
lockfile.activeAssistant = activeAssistant;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const writePath = lockfilePaths[0]!;
|
|
74
|
+
try {
|
|
75
|
+
const dir = path.dirname(writePath);
|
|
76
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
77
|
+
const tmp = `${writePath}.tmp.${process.pid}`;
|
|
78
|
+
fs.writeFileSync(tmp, JSON.stringify(lockfile, null, 2));
|
|
79
|
+
fs.renameSync(tmp, writePath);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return { ok: false, status: 500, error: `Failed to write lockfile: ${err}` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const stripped = JSON.parse(JSON.stringify(lockfile)) as Record<string, unknown>;
|
|
85
|
+
stripSensitiveFields(stripped);
|
|
86
|
+
return { ok: true, lockfile: stripped };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function replacePlatformAssistants(
|
|
90
|
+
lockfilePaths: string[],
|
|
91
|
+
platformAssistants: Array<Record<string, unknown>>,
|
|
92
|
+
): WriteResult {
|
|
93
|
+
let lockfile: Record<string, unknown> = { assistants: [], activeAssistant: null };
|
|
94
|
+
for (const candidate of lockfilePaths) {
|
|
95
|
+
try {
|
|
96
|
+
lockfile = JSON.parse(fs.readFileSync(candidate, "utf-8")) as Record<string, unknown>;
|
|
97
|
+
break;
|
|
98
|
+
} catch {
|
|
99
|
+
// continue
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const existing = Array.isArray(lockfile.assistants) ? lockfile.assistants : [];
|
|
104
|
+
const local = existing.filter(
|
|
105
|
+
(a: Record<string, unknown>) => a?.cloud !== "vellum",
|
|
106
|
+
);
|
|
107
|
+
lockfile.assistants = [...local, ...platformAssistants];
|
|
108
|
+
|
|
109
|
+
const active = lockfile.activeAssistant as string | null;
|
|
110
|
+
if (active) {
|
|
111
|
+
const stillExists = (lockfile.assistants as Array<Record<string, unknown>>).some(
|
|
112
|
+
(a) => a.assistantId === active,
|
|
113
|
+
);
|
|
114
|
+
if (!stillExists) lockfile.activeAssistant = null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const writePath = lockfilePaths[0]!;
|
|
118
|
+
try {
|
|
119
|
+
const dir = path.dirname(writePath);
|
|
120
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
121
|
+
const tmp = `${writePath}.tmp.${process.pid}`;
|
|
122
|
+
fs.writeFileSync(tmp, JSON.stringify(lockfile, null, 2));
|
|
123
|
+
fs.renameSync(tmp, writePath);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
return { ok: false, status: 500, error: `Failed to write lockfile: ${err}` };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const stripped = JSON.parse(JSON.stringify(lockfile)) as Record<string, unknown>;
|
|
129
|
+
stripSensitiveFields(stripped);
|
|
130
|
+
return { ok: true, lockfile: stripped };
|
|
131
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import type { CliInvocation } from "./util";
|
|
4
|
+
|
|
5
|
+
const RETIRE_TIMEOUT_MS = 60_000;
|
|
6
|
+
|
|
7
|
+
export type RetireResult =
|
|
8
|
+
| { ok: true }
|
|
9
|
+
| { ok: false; status: number; error: string };
|
|
10
|
+
|
|
11
|
+
export function runRetire(
|
|
12
|
+
invocation: CliInvocation,
|
|
13
|
+
assistantId: string,
|
|
14
|
+
): Promise<RetireResult> {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const child = spawn(
|
|
17
|
+
invocation.command,
|
|
18
|
+
[...invocation.baseArgs, "retire", assistantId, "--yes"],
|
|
19
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
let stdout = "";
|
|
23
|
+
let stderr = "";
|
|
24
|
+
let done = false;
|
|
25
|
+
|
|
26
|
+
const finish = (result: RetireResult) => {
|
|
27
|
+
if (done) return;
|
|
28
|
+
done = true;
|
|
29
|
+
clearTimeout(timeout);
|
|
30
|
+
resolve(result);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const timeout = setTimeout(() => {
|
|
34
|
+
child.kill("SIGTERM");
|
|
35
|
+
finish({ ok: false, status: 500, error: "Retire timed out after 60 seconds" });
|
|
36
|
+
}, RETIRE_TIMEOUT_MS);
|
|
37
|
+
|
|
38
|
+
child.stdout.on("data", (data: Buffer) => {
|
|
39
|
+
stdout += data.toString();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
child.stderr.on("data", (data: Buffer) => {
|
|
43
|
+
stderr += data.toString();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
child.on("close", (code) => {
|
|
47
|
+
if (code === 0) {
|
|
48
|
+
finish({ ok: true });
|
|
49
|
+
} else {
|
|
50
|
+
finish({ ok: false, status: 500, error: stderr || stdout });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
child.on("error", (err) => {
|
|
55
|
+
finish({ ok: false, status: 500, error: `Failed to spawn CLI: ${err.message}` });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const CLI_PACKAGE_NAME = "@vellumai/cli";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How to invoke the Vellum CLI as a child process: a base command plus the
|
|
9
|
+
* leading arguments that precede the subcommand. Each host resolves its own
|
|
10
|
+
* invocation — the dev hosts run the CLI from source via `bun run <entry>`,
|
|
11
|
+
* a packaged host would point at its bundled runtime — and the shared
|
|
12
|
+
* lifecycle ops (`runHatch`, `runRetire`, guardian-token refresh) append
|
|
13
|
+
* their subcommand args to `baseArgs`.
|
|
14
|
+
*/
|
|
15
|
+
export interface CliInvocation {
|
|
16
|
+
command: string;
|
|
17
|
+
baseArgs: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const SENSITIVE_FIELDS = [
|
|
21
|
+
"signingKey",
|
|
22
|
+
"bearerToken",
|
|
23
|
+
"guardianBootstrapSecret",
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
export function stripSensitiveFields(data: Record<string, unknown>): void {
|
|
27
|
+
const assistants = data.assistants;
|
|
28
|
+
if (!Array.isArray(assistants)) return;
|
|
29
|
+
for (const assistant of assistants) {
|
|
30
|
+
if (assistant && typeof assistant === "object") {
|
|
31
|
+
const entry = assistant as Record<string, unknown>;
|
|
32
|
+
for (const field of SENSITIVE_FIELDS) {
|
|
33
|
+
delete entry[field];
|
|
34
|
+
}
|
|
35
|
+
const resources = entry.resources;
|
|
36
|
+
if (resources && typeof resources === "object") {
|
|
37
|
+
for (const field of SENSITIVE_FIELDS) {
|
|
38
|
+
delete (resources as Record<string, unknown>)[field];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isLoopbackAddr(addr: string): boolean {
|
|
46
|
+
const v4Mapped = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
|
|
47
|
+
const normalized = v4Mapped ? v4Mapped[1]! : addr;
|
|
48
|
+
if (normalized.includes(".")) {
|
|
49
|
+
return normalized.startsWith("127.");
|
|
50
|
+
}
|
|
51
|
+
return normalized === "::1";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let _resolvedCliPath: string | undefined;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the CLI entry point.
|
|
58
|
+
*
|
|
59
|
+
* 1. Source tree — `<baseDir>/cli/src/index.ts` (dev mode in monorepo).
|
|
60
|
+
* 2. Installed package — `require.resolve("@vellumai/cli/package.json")`.
|
|
61
|
+
*/
|
|
62
|
+
function resolveCliPath(baseDir: string, importMetaUrl?: string): string {
|
|
63
|
+
if (_resolvedCliPath) return _resolvedCliPath;
|
|
64
|
+
|
|
65
|
+
const sourceTreePath = path.join(baseDir, "cli", "src", "index.ts");
|
|
66
|
+
if (fs.existsSync(sourceTreePath)) {
|
|
67
|
+
_resolvedCliPath = sourceTreePath;
|
|
68
|
+
return _resolvedCliPath;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const _require = createRequire(importMetaUrl ?? `file://${baseDir}/`);
|
|
72
|
+
try {
|
|
73
|
+
const pkgPath = _require.resolve(`${CLI_PACKAGE_NAME}/package.json`);
|
|
74
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { bin?: Record<string, string> };
|
|
75
|
+
const binEntry = pkg.bin?.["vellum"];
|
|
76
|
+
if (binEntry) {
|
|
77
|
+
const entryPoint = path.resolve(path.dirname(pkgPath), binEntry);
|
|
78
|
+
if (fs.existsSync(entryPoint)) {
|
|
79
|
+
_resolvedCliPath = entryPoint;
|
|
80
|
+
return _resolvedCliPath;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// Not found in node_modules
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Vellum CLI not found. Looked for source tree at ${sourceTreePath} and npm package ${CLI_PACKAGE_NAME}.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build the CLI invocation used by the dev hosts (CLI client server and the
|
|
94
|
+
* web dev-server middleware), which run the CLI from source via Bun:
|
|
95
|
+
* `bun run <cli-entry> <subcommand> …`.
|
|
96
|
+
*/
|
|
97
|
+
export function resolveDevCliInvocation(
|
|
98
|
+
baseDir: string,
|
|
99
|
+
importMetaUrl?: string,
|
|
100
|
+
): CliInvocation {
|
|
101
|
+
return { command: "bun", baseArgs: ["run", resolveCliPath(baseDir, importMetaUrl)] };
|
|
102
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"types": ["bun-types"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"],
|
|
15
|
+
"exclude": ["node_modules"]
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vellumai/cli",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.7",
|
|
4
4
|
"description": "CLI tools for vellum-assistant",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
"./package.json": "./package.json",
|
|
9
9
|
"./src/components/DefaultMainScreen": "./src/components/DefaultMainScreen.tsx",
|
|
10
10
|
"./src/lib/constants": "./src/lib/constants.ts",
|
|
11
|
+
"./src/lib/hatch-local": "./src/lib/hatch-local.ts",
|
|
12
|
+
"./src/lib/retire-local": "./src/lib/retire-local.ts",
|
|
13
|
+
"./src/lib/guardian-token": "./src/lib/guardian-token.ts",
|
|
14
|
+
"./src/lib/lifecycle-reporter": "./src/lib/lifecycle-reporter.ts",
|
|
11
15
|
"./src/commands/*": "./src/commands/*.ts"
|
|
12
16
|
},
|
|
13
17
|
"bin": {
|
|
@@ -18,18 +22,25 @@
|
|
|
18
22
|
"format:check": "prettier --check .",
|
|
19
23
|
"lint": "eslint",
|
|
20
24
|
"lint:unused": "knip --include files,dependencies,unlisted",
|
|
25
|
+
"prepack": "node ../scripts/prepack-bundled-deps.mjs",
|
|
21
26
|
"test": "bun test",
|
|
22
27
|
"typecheck": "bunx tsc --noEmit"
|
|
23
28
|
},
|
|
24
29
|
"author": "Vellum AI",
|
|
25
30
|
"license": "MIT",
|
|
26
31
|
"dependencies": {
|
|
32
|
+
"@vellumai/environments": "file:../packages/environments",
|
|
33
|
+
"@vellumai/local-mode": "file:../packages/local-mode",
|
|
27
34
|
"chalk": "5.6.2",
|
|
28
35
|
"ink": "6.8.0",
|
|
29
36
|
"nanoid": "5.1.7",
|
|
30
37
|
"react": "19.2.4",
|
|
31
38
|
"react-devtools-core": "6.1.5"
|
|
32
39
|
},
|
|
40
|
+
"bundledDependencies": [
|
|
41
|
+
"@vellumai/environments",
|
|
42
|
+
"@vellumai/local-mode"
|
|
43
|
+
],
|
|
33
44
|
"devDependencies": {
|
|
34
45
|
"@types/bun": "1.3.11",
|
|
35
46
|
"@types/react": "19.2.14",
|
|
@@ -162,6 +162,16 @@ beforeEach(() => {
|
|
|
162
162
|
});
|
|
163
163
|
getBackupsDirMock.mockReset();
|
|
164
164
|
getBackupsDirMock.mockReturnValue("/tmp/backups-default");
|
|
165
|
+
loadGuardianTokenSpy.mockReset();
|
|
166
|
+
loadGuardianTokenSpy.mockReturnValue({
|
|
167
|
+
accessToken: "local-token",
|
|
168
|
+
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
169
|
+
} as unknown as ReturnType<typeof guardianToken.loadGuardianToken>);
|
|
170
|
+
leaseGuardianTokenSpy.mockReset();
|
|
171
|
+
leaseGuardianTokenSpy.mockResolvedValue({
|
|
172
|
+
accessToken: "leased-token",
|
|
173
|
+
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
174
|
+
} as unknown as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>);
|
|
165
175
|
mkdirSyncMock.mockReset();
|
|
166
176
|
mkdirSyncMock.mockImplementation((() => undefined) as never);
|
|
167
177
|
writeFileSyncMock.mockReset();
|
|
@@ -207,6 +217,34 @@ function mockGcsDownload(body: Uint8Array, ok = true, status = 200) {
|
|
|
207
217
|
}) as unknown as typeof globalThis.fetch;
|
|
208
218
|
}
|
|
209
219
|
|
|
220
|
+
describe("vellum backup <local>: guardian bootstrap secret", () => {
|
|
221
|
+
test("passes the lockfile bootstrap secret when leasing a fresh guardian token", async () => {
|
|
222
|
+
const localEntry = {
|
|
223
|
+
assistantId: "local-assistant",
|
|
224
|
+
runtimeUrl: "http://127.0.0.1:7830",
|
|
225
|
+
cloud: "local",
|
|
226
|
+
guardianBootstrapSecret: "bootstrap-secret-value",
|
|
227
|
+
} satisfies assistantConfig.AssistantEntry;
|
|
228
|
+
findAssistantByNameMock.mockReturnValue(localEntry);
|
|
229
|
+
loadGuardianTokenSpy.mockReturnValue(null);
|
|
230
|
+
setArgv("my-local", "--output", "/tmp/local-backup.vbundle");
|
|
231
|
+
|
|
232
|
+
globalThis.fetch = mock(async () => {
|
|
233
|
+
return new Response(new Uint8Array([1, 2, 3]), {
|
|
234
|
+
status: 200,
|
|
235
|
+
});
|
|
236
|
+
}) as unknown as typeof globalThis.fetch;
|
|
237
|
+
|
|
238
|
+
await backup();
|
|
239
|
+
|
|
240
|
+
expect(leaseGuardianTokenSpy).toHaveBeenCalledWith(
|
|
241
|
+
"http://127.0.0.1:7830",
|
|
242
|
+
"local-assistant",
|
|
243
|
+
"bootstrap-secret-value",
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
210
248
|
describe("vellum backup <platform-managed>: GCS happy path", () => {
|
|
211
249
|
test("requests upload URL → kicks off runtime export → polls → downloads from GCS → writes file", async () => {
|
|
212
250
|
findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
|