@vellumai/cli 0.8.3 → 0.8.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 +29 -7
- package/package.json +1 -1
- package/src/__tests__/api-key-check.test.ts +78 -0
- package/src/__tests__/assistant-config.test.ts +108 -0
- package/src/__tests__/assistant-target-args.test.ts +30 -0
- package/src/__tests__/host-image-loader.test.ts +206 -0
- package/src/__tests__/ps-platform-status.test.ts +100 -22
- package/src/__tests__/retire.test.ts +241 -0
- package/src/__tests__/use.test.ts +144 -0
- package/src/commands/client.ts +27 -24
- package/src/commands/ps.ts +107 -105
- package/src/commands/retire.ts +144 -34
- package/src/commands/roadmap.ts +449 -0
- package/src/commands/use.ts +24 -10
- package/src/components/DefaultMainScreen.tsx +27 -115
- package/src/index.ts +3 -0
- package/src/lib/__tests__/port-allocator.test.ts +117 -0
- package/src/lib/__tests__/step-runner.test.ts +85 -0
- package/src/lib/api-key-check.ts +40 -0
- package/src/lib/assistant-config.ts +84 -5
- package/src/lib/assistant-target-args.ts +21 -0
- package/src/lib/docker.ts +67 -16
- package/src/lib/hatch-local.ts +11 -0
- package/src/lib/host-image-loader.ts +138 -0
- package/src/lib/platform-releases.ts +12 -5
- package/src/lib/port-allocator.ts +93 -0
- package/src/lib/statefulset.ts +0 -10
- package/src/lib/step-runner.ts +40 -7
- package/src/shared/provider-env-vars.ts +1 -0
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawn } from "child_process";
|
|
2
1
|
import { basename } from "path";
|
|
3
2
|
import {
|
|
4
3
|
useCallback,
|
|
@@ -10,8 +9,6 @@ import {
|
|
|
10
9
|
} from "react";
|
|
11
10
|
import { Box, render as inkRender, Text, useInput, useStdout } from "ink";
|
|
12
11
|
|
|
13
|
-
import { removeAssistantEntry } from "../lib/assistant-config";
|
|
14
|
-
|
|
15
12
|
import { SPECIES_CONFIG, type Species } from "../lib/constants";
|
|
16
13
|
import { checkHealth } from "../lib/health-check";
|
|
17
14
|
import { appendHistory, loadHistory } from "../lib/input-history";
|
|
@@ -43,9 +40,27 @@ export const SLASH_COMMANDS = [
|
|
|
43
40
|
"/help",
|
|
44
41
|
"/q",
|
|
45
42
|
"/quit",
|
|
46
|
-
"/retire",
|
|
47
43
|
];
|
|
48
44
|
|
|
45
|
+
const HELP_COMMANDS = [
|
|
46
|
+
{
|
|
47
|
+
command: "/btw <question>",
|
|
48
|
+
description: "Ask a side question while the assistant is working",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
command: "/quit, /exit, /q",
|
|
52
|
+
description: "Disconnect and exit",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
command: "/clear",
|
|
56
|
+
description: "Clear the screen",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
command: "/help, ?",
|
|
60
|
+
description: "Show this help",
|
|
61
|
+
},
|
|
62
|
+
] as const;
|
|
63
|
+
|
|
49
64
|
const SEND_TIMEOUT_MS = 5000;
|
|
50
65
|
|
|
51
66
|
// ── Layout constants ──────────────────────────────────────
|
|
@@ -100,12 +115,11 @@ const MIN_FEED_ROWS = 3;
|
|
|
100
115
|
// Feed item height estimation
|
|
101
116
|
const TOOL_CALL_CHROME_LINES = 2; // header (┌) + footer (└)
|
|
102
117
|
const MESSAGE_SPACING = 1;
|
|
103
|
-
const HELP_DISPLAY_HEIGHT =
|
|
118
|
+
const HELP_DISPLAY_HEIGHT = HELP_COMMANDS.length + 1;
|
|
104
119
|
|
|
105
120
|
interface ListMessagesResponse {
|
|
106
121
|
messages: RuntimeMessage[];
|
|
107
122
|
nextCursor?: string;
|
|
108
|
-
interfaces?: string[];
|
|
109
123
|
}
|
|
110
124
|
|
|
111
125
|
interface SendMessageResponse {
|
|
@@ -742,26 +756,12 @@ function HelpDisplay(): ReactElement {
|
|
|
742
756
|
return (
|
|
743
757
|
<Box flexDirection="column">
|
|
744
758
|
<Text bold>Commands:</Text>
|
|
745
|
-
|
|
746
|
-
{
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
<Text dimColor>Retire the remote instance and exit</Text>
|
|
752
|
-
</Text>
|
|
753
|
-
<Text>
|
|
754
|
-
{" /quit, /exit, /q "}
|
|
755
|
-
<Text dimColor>Disconnect and exit</Text>
|
|
756
|
-
</Text>
|
|
757
|
-
<Text>
|
|
758
|
-
{" /clear "}
|
|
759
|
-
<Text dimColor>Clear the screen</Text>
|
|
760
|
-
</Text>
|
|
761
|
-
<Text>
|
|
762
|
-
{" /help, ? "}
|
|
763
|
-
<Text dimColor>Show this help</Text>
|
|
764
|
-
</Text>
|
|
759
|
+
{HELP_COMMANDS.map((entry) => (
|
|
760
|
+
<Text key={entry.command}>
|
|
761
|
+
{` ${entry.command.padEnd(17)} `}
|
|
762
|
+
<Text dimColor>{entry.description}</Text>
|
|
763
|
+
</Text>
|
|
764
|
+
))}
|
|
765
765
|
</Box>
|
|
766
766
|
);
|
|
767
767
|
}
|
|
@@ -1391,8 +1391,6 @@ interface ChatAppProps {
|
|
|
1391
1391
|
/** Pre-built auth headers (e.g. { Authorization: "Bearer ..." } for local,
|
|
1392
1392
|
* { "X-Session-Token": "...", "Vellum-Organization-Id": "..." } for platform). */
|
|
1393
1393
|
auth?: Record<string, string>;
|
|
1394
|
-
project?: string;
|
|
1395
|
-
zone?: string;
|
|
1396
1394
|
onExit: () => void;
|
|
1397
1395
|
handleRef: (handle: ChatAppHandle) => void;
|
|
1398
1396
|
}
|
|
@@ -1403,8 +1401,6 @@ function ChatApp({
|
|
|
1403
1401
|
assistantName,
|
|
1404
1402
|
species,
|
|
1405
1403
|
auth,
|
|
1406
|
-
project,
|
|
1407
|
-
zone,
|
|
1408
1404
|
onExit,
|
|
1409
1405
|
handleRef,
|
|
1410
1406
|
}: ChatAppProps): ReactElement {
|
|
@@ -1954,86 +1950,6 @@ function ChatApp({
|
|
|
1954
1950
|
return;
|
|
1955
1951
|
}
|
|
1956
1952
|
|
|
1957
|
-
if (trimmed === "/retire") {
|
|
1958
|
-
if (!project || !zone) {
|
|
1959
|
-
h.showError(
|
|
1960
|
-
"No instance info available. Connect to a hatched instance first.",
|
|
1961
|
-
);
|
|
1962
|
-
return;
|
|
1963
|
-
}
|
|
1964
|
-
|
|
1965
|
-
const confirmIndex = await h.showSelection(`Retire ${assistantId}?`, [
|
|
1966
|
-
"Yes, retire",
|
|
1967
|
-
"Cancel",
|
|
1968
|
-
]);
|
|
1969
|
-
if (confirmIndex !== 0) {
|
|
1970
|
-
h.addStatus("Cancelled.");
|
|
1971
|
-
return;
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
h.showSpinner(`Retiring instance ${assistantId}...`);
|
|
1975
|
-
|
|
1976
|
-
try {
|
|
1977
|
-
const labelChild = spawn(
|
|
1978
|
-
"gcloud",
|
|
1979
|
-
[
|
|
1980
|
-
"compute",
|
|
1981
|
-
"instances",
|
|
1982
|
-
"add-labels",
|
|
1983
|
-
assistantId,
|
|
1984
|
-
`--project=${project}`,
|
|
1985
|
-
`--zone=${zone}`,
|
|
1986
|
-
"--labels=retired-by=vel",
|
|
1987
|
-
],
|
|
1988
|
-
{ stdio: "pipe" },
|
|
1989
|
-
);
|
|
1990
|
-
await new Promise<void>((resolve) => {
|
|
1991
|
-
labelChild.on("close", () => resolve());
|
|
1992
|
-
labelChild.on("error", () => resolve());
|
|
1993
|
-
});
|
|
1994
|
-
} catch {
|
|
1995
|
-
// Best-effort labeling before deletion
|
|
1996
|
-
}
|
|
1997
|
-
|
|
1998
|
-
const child = spawn(
|
|
1999
|
-
"gcloud",
|
|
2000
|
-
[
|
|
2001
|
-
"compute",
|
|
2002
|
-
"instances",
|
|
2003
|
-
"delete",
|
|
2004
|
-
assistantId,
|
|
2005
|
-
`--project=${project}`,
|
|
2006
|
-
`--zone=${zone}`,
|
|
2007
|
-
"--quiet",
|
|
2008
|
-
],
|
|
2009
|
-
{ stdio: "pipe" },
|
|
2010
|
-
);
|
|
2011
|
-
|
|
2012
|
-
child.on("close", (code) => {
|
|
2013
|
-
handleRef_.current?.hideSpinner();
|
|
2014
|
-
if (code === 0) {
|
|
2015
|
-
removeAssistantEntry(assistantId);
|
|
2016
|
-
handleRef_.current?.addStatus(
|
|
2017
|
-
`Removed ${assistantId} from lockfile.json`,
|
|
2018
|
-
);
|
|
2019
|
-
} else {
|
|
2020
|
-
handleRef_.current?.showError(
|
|
2021
|
-
`Failed to delete instance (exit code ${code})`,
|
|
2022
|
-
);
|
|
2023
|
-
}
|
|
2024
|
-
cleanup();
|
|
2025
|
-
process.exit(code === 0 ? 0 : 1);
|
|
2026
|
-
});
|
|
2027
|
-
|
|
2028
|
-
child.on("error", (err) => {
|
|
2029
|
-
handleRef_.current?.hideSpinner();
|
|
2030
|
-
handleRef_.current?.showError(
|
|
2031
|
-
`Failed to retire instance: ${err.message}`,
|
|
2032
|
-
);
|
|
2033
|
-
});
|
|
2034
|
-
return;
|
|
2035
|
-
}
|
|
2036
|
-
|
|
2037
1953
|
// If a connection attempt is already in progress, don't silently drop input
|
|
2038
1954
|
if (connectingRef.current) {
|
|
2039
1955
|
h.addStatus(
|
|
@@ -2218,7 +2134,7 @@ function ChatApp({
|
|
|
2218
2134
|
// racing with SSE events that may arrive during the sendMessage await.
|
|
2219
2135
|
h.showSpinner("Working...");
|
|
2220
2136
|
},
|
|
2221
|
-
[runtimeUrl, assistantId, auth,
|
|
2137
|
+
[runtimeUrl, assistantId, auth, cleanup, ensureConnected],
|
|
2222
2138
|
);
|
|
2223
2139
|
|
|
2224
2140
|
const handleSubmit = useCallback(
|
|
@@ -2530,8 +2446,6 @@ export function renderChatApp(
|
|
|
2530
2446
|
onExit: () => void,
|
|
2531
2447
|
options?: {
|
|
2532
2448
|
auth?: Record<string, string>;
|
|
2533
|
-
project?: string;
|
|
2534
|
-
zone?: string;
|
|
2535
2449
|
assistantName?: string;
|
|
2536
2450
|
},
|
|
2537
2451
|
): ChatAppInstance {
|
|
@@ -2544,8 +2458,6 @@ export function renderChatApp(
|
|
|
2544
2458
|
assistantName={options?.assistantName}
|
|
2545
2459
|
species={species}
|
|
2546
2460
|
auth={options?.auth}
|
|
2547
|
-
project={options?.project}
|
|
2548
|
-
zone={options?.zone}
|
|
2549
2461
|
onExit={onExit}
|
|
2550
2462
|
handleRef={(h) => {
|
|
2551
2463
|
chatHandle = h;
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { message } from "./commands/message";
|
|
|
14
14
|
import { ps } from "./commands/ps";
|
|
15
15
|
import { recover } from "./commands/recover";
|
|
16
16
|
import { restore } from "./commands/restore";
|
|
17
|
+
import { roadmap } from "./commands/roadmap";
|
|
17
18
|
import { retire } from "./commands/retire";
|
|
18
19
|
import { rollback } from "./commands/rollback";
|
|
19
20
|
import { setup } from "./commands/setup";
|
|
@@ -45,6 +46,7 @@ const commands = {
|
|
|
45
46
|
recover,
|
|
46
47
|
restore,
|
|
47
48
|
retire,
|
|
49
|
+
roadmap,
|
|
48
50
|
rollback,
|
|
49
51
|
setup,
|
|
50
52
|
sleep,
|
|
@@ -83,6 +85,7 @@ function printHelp(): void {
|
|
|
83
85
|
" restore Restore data (and optionally version) from a .vbundle backup",
|
|
84
86
|
);
|
|
85
87
|
console.log(" retire Delete an assistant instance");
|
|
88
|
+
console.log(" roadmap Manage roadmap items");
|
|
86
89
|
console.log(" rollback Roll back an assistant to a previous version");
|
|
87
90
|
console.log(" setup Configure API keys interactively");
|
|
88
91
|
console.log(" sleep Stop the assistant process");
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createServer, type Server } from "net";
|
|
3
|
+
|
|
4
|
+
import { findOpenPort } from "../port-allocator.js";
|
|
5
|
+
|
|
6
|
+
const HOST = "127.0.0.1";
|
|
7
|
+
|
|
8
|
+
async function bindBlocker(port: number, host: string = HOST): Promise<Server> {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const server = createServer();
|
|
11
|
+
server.once("error", reject);
|
|
12
|
+
server.once("listening", () => resolve(server));
|
|
13
|
+
server.listen(port, host);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function closeServer(server: Server): Promise<void> {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function getEphemeralPort(): Promise<number> {
|
|
24
|
+
const server = await new Promise<Server>((resolve, reject) => {
|
|
25
|
+
const s = createServer();
|
|
26
|
+
s.once("error", reject);
|
|
27
|
+
s.once("listening", () => resolve(s));
|
|
28
|
+
s.listen(0, HOST);
|
|
29
|
+
});
|
|
30
|
+
const addr = server.address();
|
|
31
|
+
if (!addr || typeof addr === "string" || addr.port == null) {
|
|
32
|
+
throw new Error("Could not obtain ephemeral port");
|
|
33
|
+
}
|
|
34
|
+
const port = addr.port;
|
|
35
|
+
await closeServer(server);
|
|
36
|
+
return port;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("findOpenPort", () => {
|
|
40
|
+
test("returns the preferred port when it is free", async () => {
|
|
41
|
+
const port = await getEphemeralPort();
|
|
42
|
+
const result = await findOpenPort(port, { host: HOST });
|
|
43
|
+
expect(result).toBe(port);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("walks past an in-use port and returns the next free one", async () => {
|
|
47
|
+
const blocked = await getEphemeralPort();
|
|
48
|
+
const blocker = await bindBlocker(blocked);
|
|
49
|
+
try {
|
|
50
|
+
const result = await findOpenPort(blocked, { host: HOST });
|
|
51
|
+
expect(result).toBeGreaterThan(blocked);
|
|
52
|
+
expect(result).toBeLessThanOrEqual(blocked + 50);
|
|
53
|
+
} finally {
|
|
54
|
+
await closeServer(blocker);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("walks past two consecutive in-use ports", async () => {
|
|
59
|
+
const first = await getEphemeralPort();
|
|
60
|
+
const blockerA = await bindBlocker(first);
|
|
61
|
+
let blockerB: Server | null = null;
|
|
62
|
+
try {
|
|
63
|
+
// Best-effort grab of the next consecutive port; if the kernel
|
|
64
|
+
// handed it to someone else just before we got here, that's still a
|
|
65
|
+
// valid "two consecutive blockers" scenario for the walk.
|
|
66
|
+
try {
|
|
67
|
+
blockerB = await bindBlocker(first + 1);
|
|
68
|
+
} catch {
|
|
69
|
+
blockerB = null;
|
|
70
|
+
}
|
|
71
|
+
const result = await findOpenPort(first, { host: HOST });
|
|
72
|
+
expect(result).toBeGreaterThan(first + (blockerB ? 1 : 0));
|
|
73
|
+
} finally {
|
|
74
|
+
await closeServer(blockerA);
|
|
75
|
+
if (blockerB) await closeServer(blockerB);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("throws when the entire requested window is in use", async () => {
|
|
80
|
+
const blocked = await getEphemeralPort();
|
|
81
|
+
const blocker = await bindBlocker(blocked);
|
|
82
|
+
try {
|
|
83
|
+
await expect(
|
|
84
|
+
findOpenPort(blocked, { host: HOST, maxAttempts: 1 }),
|
|
85
|
+
).rejects.toThrow(/no open port/i);
|
|
86
|
+
} finally {
|
|
87
|
+
await closeServer(blocker);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("rejects non-integer or out-of-range preferred port", async () => {
|
|
92
|
+
await expect(findOpenPort(0, { host: HOST })).rejects.toThrow(
|
|
93
|
+
/not a valid TCP port/i,
|
|
94
|
+
);
|
|
95
|
+
await expect(findOpenPort(65536, { host: HOST })).rejects.toThrow(
|
|
96
|
+
/not a valid TCP port/i,
|
|
97
|
+
);
|
|
98
|
+
await expect(findOpenPort(1.5, { host: HOST })).rejects.toThrow(
|
|
99
|
+
/not a valid TCP port/i,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("rejects non-positive maxAttempts", async () => {
|
|
104
|
+
await expect(
|
|
105
|
+
findOpenPort(20100, { host: HOST, maxAttempts: 0 }),
|
|
106
|
+
).rejects.toThrow(/maxAttempts/i);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("does not leak the probe port — port is rebindable after resolution", async () => {
|
|
110
|
+
const port = await getEphemeralPort();
|
|
111
|
+
const found = await findOpenPort(port, { host: HOST });
|
|
112
|
+
expect(found).toBe(port);
|
|
113
|
+
// If the probe leaked a listener on `port`, this would throw EADDRINUSE.
|
|
114
|
+
const reuse = await bindBlocker(found);
|
|
115
|
+
await closeServer(reuse);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { buildExecErrorMessage, exec, execOutput } from "../step-runner";
|
|
4
|
+
|
|
5
|
+
describe("buildExecErrorMessage", () => {
|
|
6
|
+
it("omits the argv from the header so secrets in args can't leak", () => {
|
|
7
|
+
// Realistic shape — docker hatch invocations pass `-e <NAME>=<val>`
|
|
8
|
+
// flags inline. If we ever regress and put argv in the header, this
|
|
9
|
+
// assertion catches it immediately.
|
|
10
|
+
const msg = buildExecErrorMessage("docker", 125, "stderr text", "");
|
|
11
|
+
expect(msg).not.toContain("ANTHROPIC_API_KEY");
|
|
12
|
+
expect(msg).not.toContain("OPENAI_API_KEY");
|
|
13
|
+
expect(msg.startsWith("docker exited with code 125")).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("appends stderr below the header when present", () => {
|
|
17
|
+
const msg = buildExecErrorMessage("docker", 125, " bind failed\n", "");
|
|
18
|
+
expect(msg).toBe("docker exited with code 125\nbind failed");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("appends stdout when stderr is empty", () => {
|
|
22
|
+
const msg = buildExecErrorMessage("docker", 1, "", "stdout-only\n");
|
|
23
|
+
expect(msg).toBe("docker exited with code 1\nstdout-only");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("appends both streams joined by newline when both present", () => {
|
|
27
|
+
const msg = buildExecErrorMessage("docker", 1, "stderr-line", "stdout-line");
|
|
28
|
+
expect(msg).toBe("docker exited with code 1\nstderr-line\nstdout-line");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("collapses an empty output to just the header", () => {
|
|
32
|
+
const msg = buildExecErrorMessage("docker", 1, " ", "\n");
|
|
33
|
+
expect(msg).toBe("docker exited with code 1");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("handles a null exit code (signal-terminated child)", () => {
|
|
37
|
+
const msg = buildExecErrorMessage("docker", null, "killed", "");
|
|
38
|
+
expect(msg).toBe("docker exited with an unknown code\nkilled");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("exec — secret leak regression", () => {
|
|
43
|
+
it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
|
|
44
|
+
// The classic hatch failure shape: docker invoked with several
|
|
45
|
+
// -e flags, exiting non-zero. Without the fix, args.join(" ")
|
|
46
|
+
// would put `-e ANTHROPIC_API_KEY=sk-ant-…` into err.message.
|
|
47
|
+
const fakeSecret = "sk-ant-this-should-never-appear-in-logs";
|
|
48
|
+
try {
|
|
49
|
+
await exec("sh", [
|
|
50
|
+
"-c",
|
|
51
|
+
`echo "bind for 0.0.0.0:20100 failed: port is already allocated" 1>&2 && exit 125`,
|
|
52
|
+
"-e",
|
|
53
|
+
`ANTHROPIC_API_KEY=${fakeSecret}`,
|
|
54
|
+
]);
|
|
55
|
+
throw new Error("exec should have rejected");
|
|
56
|
+
} catch (err) {
|
|
57
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
58
|
+
expect(message).not.toContain(fakeSecret);
|
|
59
|
+
expect(message).not.toContain("ANTHROPIC_API_KEY");
|
|
60
|
+
expect(message).toContain("sh exited with code 125");
|
|
61
|
+
expect(message).toContain("port is already allocated");
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("execOutput — secret leak regression", () => {
|
|
67
|
+
it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
|
|
68
|
+
const fakeSecret = "sk-openai-leak-canary";
|
|
69
|
+
try {
|
|
70
|
+
await execOutput("sh", [
|
|
71
|
+
"-c",
|
|
72
|
+
`echo "no such container" 1>&2 && exit 1`,
|
|
73
|
+
"-e",
|
|
74
|
+
`OPENAI_API_KEY=${fakeSecret}`,
|
|
75
|
+
]);
|
|
76
|
+
throw new Error("execOutput should have rejected");
|
|
77
|
+
} catch (err) {
|
|
78
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
79
|
+
expect(message).not.toContain(fakeSecret);
|
|
80
|
+
expect(message).not.toContain("OPENAI_API_KEY");
|
|
81
|
+
expect(message).toContain("sh exited with code 1");
|
|
82
|
+
expect(message).toContain("no such container");
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { LLM_PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
|
|
2
|
+
|
|
3
|
+
export interface ApiKeyCheckResult {
|
|
4
|
+
hasKey: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Returns true when a key value is a real credential rather than a placeholder.
|
|
9
|
+
*
|
|
10
|
+
* .env.example ships values like `sk-ant-...`, `sk-...`, and `...` to show
|
|
11
|
+
* where credentials go. Any value containing `...` or that is empty is treated
|
|
12
|
+
* as a placeholder that the user has not replaced yet.
|
|
13
|
+
*/
|
|
14
|
+
function isPlaceholder(value: string | undefined): boolean {
|
|
15
|
+
if (!value || value.trim() === "") return true;
|
|
16
|
+
if (value.includes("...")) return true;
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check whether at least one LLM provider API key is configured in the
|
|
22
|
+
* current process environment.
|
|
23
|
+
*
|
|
24
|
+
* The CLI's job is to spawn the daemon and pass configuration via environment
|
|
25
|
+
* variables — it does not read from the .vellum/ directory (see AGENTS.md).
|
|
26
|
+
* Checking process.env is sufficient: the daemon forwards whatever is set
|
|
27
|
+
* in the environment, so exporting a key before running `vellum hatch` is
|
|
28
|
+
* the correct way to supply it.
|
|
29
|
+
*
|
|
30
|
+
* Uses the canonical LLM provider env-var catalog so the list stays in sync
|
|
31
|
+
* automatically as new providers are added.
|
|
32
|
+
*/
|
|
33
|
+
export function checkProviderApiKey(): ApiKeyCheckResult {
|
|
34
|
+
for (const envVar of Object.values(LLM_PROVIDER_ENV_VAR_NAMES)) {
|
|
35
|
+
if (!isPlaceholder(process.env[envVar])) {
|
|
36
|
+
return { hasKey: true };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { hasKey: false };
|
|
40
|
+
}
|
|
@@ -109,6 +109,11 @@ export interface AssistantEntry {
|
|
|
109
109
|
[key: string]: unknown;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
export type AssistantLookupResult =
|
|
113
|
+
| { status: "found"; entry: AssistantEntry }
|
|
114
|
+
| { status: "not_found" }
|
|
115
|
+
| { status: "ambiguous"; matches: AssistantEntry[] };
|
|
116
|
+
|
|
112
117
|
interface LockfileData {
|
|
113
118
|
assistants?: Record<string, unknown>[];
|
|
114
119
|
activeAssistant?: string;
|
|
@@ -309,8 +314,70 @@ export function loadLatestAssistant(): AssistantEntry | null {
|
|
|
309
314
|
}
|
|
310
315
|
|
|
311
316
|
export function findAssistantByName(name: string): AssistantEntry | null {
|
|
317
|
+
return readAssistants().find((entry) => entry.assistantId === name) ?? null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function getAssistantDisplayName(entry: AssistantEntry): string {
|
|
321
|
+
const primary = entry.name?.trim();
|
|
322
|
+
if (primary) return primary;
|
|
323
|
+
|
|
324
|
+
const legacy = entry.assistantName?.trim();
|
|
325
|
+
if (legacy) return legacy;
|
|
326
|
+
|
|
327
|
+
return entry.assistantId;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function formatAssistantReference(entry: AssistantEntry): string {
|
|
331
|
+
const displayName = getAssistantDisplayName(entry);
|
|
332
|
+
return displayName === entry.assistantId
|
|
333
|
+
? entry.assistantId
|
|
334
|
+
: `${displayName} (${entry.assistantId})`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function getAssistantDisplayNameCandidates(entry: AssistantEntry): string[] {
|
|
338
|
+
return Array.from(
|
|
339
|
+
new Set(
|
|
340
|
+
[entry.name?.trim(), entry.assistantName?.trim()].filter(
|
|
341
|
+
(value): value is string => typeof value === "string" && value !== "",
|
|
342
|
+
),
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function lookupAssistantByIdentifier(
|
|
348
|
+
identifier: string,
|
|
349
|
+
): AssistantLookupResult {
|
|
312
350
|
const entries = readAssistants();
|
|
313
|
-
|
|
351
|
+
const exactId = entries.find((entry) => entry.assistantId === identifier);
|
|
352
|
+
if (exactId) {
|
|
353
|
+
return { status: "found", entry: exactId };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const displayMatches = entries.filter((entry) =>
|
|
357
|
+
getAssistantDisplayNameCandidates(entry).includes(identifier),
|
|
358
|
+
);
|
|
359
|
+
if (displayMatches.length === 1) {
|
|
360
|
+
return { status: "found", entry: displayMatches[0] };
|
|
361
|
+
}
|
|
362
|
+
if (displayMatches.length > 1) {
|
|
363
|
+
return { status: "ambiguous", matches: displayMatches };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return { status: "not_found" };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function formatAssistantLookupError(
|
|
370
|
+
identifier: string,
|
|
371
|
+
result: AssistantLookupResult = lookupAssistantByIdentifier(identifier),
|
|
372
|
+
): string {
|
|
373
|
+
if (result.status === "ambiguous") {
|
|
374
|
+
const matches = result.matches
|
|
375
|
+
.map((entry) => formatAssistantReference(entry))
|
|
376
|
+
.join(", ");
|
|
377
|
+
return `Multiple assistants match '${identifier}': ${matches}. Use the assistant ID to disambiguate.`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return `No assistant found with name or ID '${identifier}'.`;
|
|
314
381
|
}
|
|
315
382
|
|
|
316
383
|
export function removeAssistantEntry(assistantId: string): void {
|
|
@@ -446,13 +513,25 @@ export function resolveAssistant(nameArg?: string): AssistantEntry | null {
|
|
|
446
513
|
* 3. Sole lockfile entry (any cloud)
|
|
447
514
|
*/
|
|
448
515
|
export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
|
|
449
|
-
const entry = resolveAssistant(nameArg);
|
|
450
|
-
if (entry) return entry;
|
|
451
|
-
|
|
452
516
|
if (nameArg) {
|
|
453
|
-
|
|
517
|
+
const result = lookupAssistantByIdentifier(nameArg);
|
|
518
|
+
if (result.status === "found") return result.entry;
|
|
519
|
+
console.error(formatAssistantLookupError(nameArg, result));
|
|
454
520
|
} else {
|
|
521
|
+
const active = getActiveAssistant();
|
|
522
|
+
if (active) {
|
|
523
|
+
const result = lookupAssistantByIdentifier(active);
|
|
524
|
+
if (result.status === "found") return result.entry;
|
|
525
|
+
if (result.status === "ambiguous") {
|
|
526
|
+
console.error(formatAssistantLookupError(active, result));
|
|
527
|
+
process.exit(1);
|
|
528
|
+
}
|
|
529
|
+
// Active assistant no longer exists in lockfile — fall through.
|
|
530
|
+
}
|
|
531
|
+
|
|
455
532
|
const all = readAssistants();
|
|
533
|
+
if (all.length === 1) return all[0];
|
|
534
|
+
|
|
456
535
|
if (all.length === 0) {
|
|
457
536
|
console.error("No assistant found. Run 'vellum hatch' first.");
|
|
458
537
|
} else {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function parseAssistantTargetArg(
|
|
2
|
+
args: string[],
|
|
3
|
+
flagsWithValues: readonly string[] = [],
|
|
4
|
+
): string | undefined {
|
|
5
|
+
const flagsWithValuesSet = new Set(flagsWithValues);
|
|
6
|
+
const parts: string[] = [];
|
|
7
|
+
|
|
8
|
+
for (let i = 0; i < args.length; i++) {
|
|
9
|
+
const arg = args[i];
|
|
10
|
+
if (flagsWithValuesSet.has(arg)) {
|
|
11
|
+
i++;
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (arg.startsWith("-")) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
parts.push(arg);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
21
|
+
}
|