@vellumai/cli 0.8.6 → 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/bun.lock +8 -0
- package/knip.json +5 -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__/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__/segments-to-plain-text.test.ts +37 -0
- package/src/commands/client.ts +413 -2
- package/src/commands/env.ts +1 -1
- package/src/commands/flags.ts +89 -17
- package/src/components/DefaultMainScreen.tsx +16 -1
- package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
- package/src/lib/assistant-config.ts +3 -3
- 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 +73 -33
- package/src/lib/lifecycle-reporter.ts +31 -0
- package/src/lib/retire-local.ts +28 -14
- package/src/lib/segments-to-plain-text.ts +35 -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",
|
|
@@ -2,64 +2,52 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
|
-
import { SEEDS } from "
|
|
5
|
+
import { SEEDS } from "@vellumai/environments";
|
|
6
6
|
|
|
7
|
-
// Drift guard
|
|
8
|
-
// environment names:
|
|
7
|
+
// Drift guard between the two language-level sources of truth for the set of
|
|
8
|
+
// known environment names:
|
|
9
9
|
//
|
|
10
|
-
// 1.
|
|
11
|
-
// 2.
|
|
10
|
+
// 1. packages/environments/src/seeds.ts — SEEDS record (TS source of truth)
|
|
11
|
+
// 2. clients/shared/App/VellumEnvironment.swift — Swift `VellumEnvironment` enum
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// FOLLOW-UP: split the env name list into a shared `packages/environments`
|
|
18
|
-
// package (mirroring `packages/service-contracts`, `credential-storage`) so
|
|
19
|
-
// both sites can `import { KNOWN_ENVIRONMENTS }` from one place and this
|
|
20
|
-
// drift guard becomes a compile-time check. Planned alongside CLI-driven
|
|
21
|
-
// context support — see the "Environments" design doc.
|
|
13
|
+
// The Swift client can't import the TypeScript package, so the two lists are
|
|
14
|
+
// maintained independently and must be kept in lockstep by hand. This test
|
|
15
|
+
// parses the enum cases out of the Swift source and asserts they agree with
|
|
16
|
+
// SEEDS. Adding an environment means updating both sites.
|
|
22
17
|
|
|
23
18
|
const REPO_ROOT = join(import.meta.dir, "..", "..", "..");
|
|
24
|
-
const
|
|
19
|
+
const SWIFT_ENVIRONMENT = join(
|
|
25
20
|
REPO_ROOT,
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
21
|
+
"clients",
|
|
22
|
+
"shared",
|
|
23
|
+
"App",
|
|
24
|
+
"VellumEnvironment.swift",
|
|
30
25
|
);
|
|
31
26
|
|
|
32
27
|
/**
|
|
33
|
-
* Extract the
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* the
|
|
28
|
+
* Extract the case names declared in the `VellumEnvironment` enum. Matches
|
|
29
|
+
* standalone `case <name>` declaration lines (one identifier, nothing else),
|
|
30
|
+
* which is the enum's own declaration syntax. Switch-statement arms like
|
|
31
|
+
* `case .local:` carry a leading dot and a trailing colon, so they're
|
|
32
|
+
* excluded — the match is anchored to a bare identifier at end of line.
|
|
38
33
|
*/
|
|
39
|
-
function
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const match = source.match(pattern);
|
|
45
|
-
if (!match) {
|
|
46
|
-
throw new Error(
|
|
47
|
-
`Could not find Set literal for ${setName}. Update the drift-guard regex in env-drift.test.ts.`,
|
|
48
|
-
);
|
|
34
|
+
function extractSwiftEnumCases(source: string): string[] {
|
|
35
|
+
const names: string[] = [];
|
|
36
|
+
for (const line of source.split("\n")) {
|
|
37
|
+
const match = line.match(/^\s*case\s+([a-zA-Z][a-zA-Z0-9]*)\s*$/);
|
|
38
|
+
if (match) names.push(match[1]!);
|
|
49
39
|
}
|
|
50
|
-
|
|
51
|
-
const literals = body.match(/"([^"]+)"/g) ?? [];
|
|
52
|
-
return literals.map((lit) => lit.slice(1, -1));
|
|
40
|
+
return names;
|
|
53
41
|
}
|
|
54
42
|
|
|
55
|
-
describe("
|
|
43
|
+
describe("environment name drift guard (TS ↔ Swift)", () => {
|
|
56
44
|
const seedNames = new Set(Object.keys(SEEDS));
|
|
57
45
|
|
|
58
|
-
test("
|
|
59
|
-
const source = readFileSync(
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
);
|
|
63
|
-
expect([...
|
|
46
|
+
test("clients/shared/App/VellumEnvironment.swift matches SEEDS", () => {
|
|
47
|
+
const source = readFileSync(SWIFT_ENVIRONMENT, "utf8");
|
|
48
|
+
const swiftNames = new Set(extractSwiftEnumCases(source));
|
|
49
|
+
|
|
50
|
+
expect(swiftNames.size).toBeGreaterThan(0);
|
|
51
|
+
expect([...swiftNames].sort()).toEqual([...seedNames].sort());
|
|
64
52
|
});
|
|
65
53
|
});
|