@vellumai/cli 0.8.1 → 0.8.3
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/README.md +24 -32
- package/package.json +1 -1
- package/src/__tests__/backup.test.ts +13 -3
- package/src/__tests__/config-utils.test.ts +31 -1
- package/src/__tests__/hatch-provider-secrets.test.ts +284 -0
- package/src/__tests__/input-history.test.ts +102 -0
- package/src/__tests__/preload.ts +5 -1
- package/src/__tests__/provider-secrets.test.ts +290 -0
- package/src/__tests__/setup.test.ts +360 -0
- package/src/__tests__/teleport.test.ts +191 -163
- package/src/commands/client.ts +57 -1
- package/src/commands/hatch.ts +53 -20
- package/src/commands/setup.ts +134 -95
- package/src/commands/teleport.ts +20 -2
- package/src/components/DefaultMainScreen.tsx +72 -119
- package/src/lib/__tests__/docker.test.ts +106 -0
- package/src/lib/assistant-config.ts +6 -2
- package/src/lib/config-utils.ts +18 -0
- package/src/lib/docker.ts +180 -19
- package/src/lib/environments/paths.ts +21 -0
- package/src/lib/hatch-local.ts +42 -3
- package/src/lib/hatch-next-steps.ts +12 -0
- package/src/lib/input-history.ts +5 -8
- package/src/lib/provider-secrets.ts +564 -0
- package/src/lib/sync-cloud-assistants.ts +23 -9
- package/src/lib/doctor-client.ts +0 -153
package/README.md
CHANGED
|
@@ -54,27 +54,25 @@ vellum hatch [species] [options]
|
|
|
54
54
|
|
|
55
55
|
#### Options
|
|
56
56
|
|
|
57
|
-
| Option | Description
|
|
58
|
-
| ------------------- |
|
|
59
|
-
| `-d` | Detached mode. Start the instance in the background without watching startup progress.
|
|
60
|
-
| `--name <name>` | Use a specific instance name instead of an auto-generated one.
|
|
61
|
-
| `--remote <target>` | Where to provision the instance. One of: `local`, `gcp`, `aws`, `custom`. Defaults to `local`. |
|
|
57
|
+
| Option | Description |
|
|
58
|
+
| ------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
59
|
+
| `-d` | Detached mode. Start the instance in the background without watching startup progress. |
|
|
60
|
+
| `--name <name>` | Use a specific instance name instead of an auto-generated one. |
|
|
61
|
+
| `--remote <target>` | Where to provision the instance. One of: `local`, `docker`, `vellum`, `gcp`, `aws`, `custom`. Defaults to `local`. |
|
|
62
62
|
|
|
63
63
|
#### Remote Targets
|
|
64
64
|
|
|
65
65
|
- **`local`** -- Starts the local assistant and local gateway. Gateway source resolution order is: repo source tree, then installed `@vellumai/vellum-gateway` package.
|
|
66
|
-
- **`
|
|
67
|
-
- **`
|
|
68
|
-
- **`
|
|
66
|
+
- **`docker`** -- Starts the assistant, gateway, and credential service in Docker containers.
|
|
67
|
+
- **`vellum`** -- Hatches an assistant on the Vellum platform.
|
|
68
|
+
- **`gcp`** and **`aws`** -- Recognized but not supported as provisioning targets yet. The CLI exits before creating cloud resources. To self-host on AWS/GCP, SSH into the VM and run `vellum hatch` or `vellum hatch --remote docker` there.
|
|
69
|
+
- **`custom`** -- Recognized but not yet implemented.
|
|
69
70
|
|
|
70
71
|
#### Environment Variables
|
|
71
72
|
|
|
72
|
-
| Variable
|
|
73
|
-
|
|
|
74
|
-
| `ANTHROPIC_API_KEY`
|
|
75
|
-
| `GCP_PROJECT` | `gcp` | GCP project ID. Falls back to the active `gcloud` project. |
|
|
76
|
-
| `GCP_DEFAULT_ZONE` | `gcp` | GCP zone for the compute instance. |
|
|
77
|
-
| `VELLUM_CUSTOM_HOST` | `custom` | SSH host in `user@hostname` format. |
|
|
73
|
+
| Variable | Required For | Description |
|
|
74
|
+
| ------------------- | ------------ | -------------------------------------------------------------------------- |
|
|
75
|
+
| `ANTHROPIC_API_KEY` | Optional | Used during setup when no Anthropic API key is already stored or prompted. |
|
|
78
76
|
|
|
79
77
|
#### Examples
|
|
80
78
|
|
|
@@ -82,20 +80,14 @@ vellum hatch [species] [options]
|
|
|
82
80
|
# Hatch a local assistant (default)
|
|
83
81
|
vellum hatch
|
|
84
82
|
|
|
85
|
-
# Hatch a
|
|
86
|
-
vellum hatch
|
|
87
|
-
|
|
88
|
-
# Hatch an openclaw assistant on GCP in detached mode
|
|
89
|
-
vellum hatch openclaw --remote gcp -d
|
|
83
|
+
# Hatch a Docker assistant
|
|
84
|
+
vellum hatch --remote docker
|
|
90
85
|
|
|
91
86
|
# Hatch with a specific instance name
|
|
92
|
-
vellum hatch --name my-assistant --remote
|
|
93
|
-
|
|
94
|
-
# Hatch on a custom SSH host
|
|
95
|
-
VELLUM_CUSTOM_HOST=user@10.0.0.1 vellum hatch --remote custom
|
|
87
|
+
vellum hatch --name my-assistant --remote docker
|
|
96
88
|
```
|
|
97
89
|
|
|
98
|
-
|
|
90
|
+
AWS and GCP hatch targets are recognized so users receive an explicit unsupported-target error instead of an unknown-option error. They currently exit without creating cloud resources; self-hosting on an AWS/GCP VM still works by running `vellum hatch` from inside that machine.
|
|
99
91
|
|
|
100
92
|
### `terminal`
|
|
101
93
|
|
|
@@ -111,18 +103,18 @@ Only available for managed assistants (those running in a Vellum Cloud container
|
|
|
111
103
|
|
|
112
104
|
#### Subcommands
|
|
113
105
|
|
|
114
|
-
| Subcommand | Description
|
|
115
|
-
| ------------------ |
|
|
116
|
-
| _(none)_ | Open an interactive shell session inside the container.
|
|
117
|
-
| `attach <session>` | Attach to an existing `tmux` session by name inside the container.
|
|
118
|
-
| `list` | List the `tmux` sessions currently running inside the container.
|
|
106
|
+
| Subcommand | Description |
|
|
107
|
+
| ------------------ | ------------------------------------------------------------------ |
|
|
108
|
+
| _(none)_ | Open an interactive shell session inside the container. |
|
|
109
|
+
| `attach <session>` | Attach to an existing `tmux` session by name inside the container. |
|
|
110
|
+
| `list` | List the `tmux` sessions currently running inside the container. |
|
|
119
111
|
|
|
120
112
|
#### Options
|
|
121
113
|
|
|
122
|
-
| Option | Description
|
|
123
|
-
| -------------------- |
|
|
114
|
+
| Option | Description |
|
|
115
|
+
| -------------------- | --------------------------------------------------------------------------------------------------- |
|
|
124
116
|
| `[name]` | Positional. Name of the assistant to target. Defaults to the active assistant set via `vellum use`. |
|
|
125
|
-
| `--assistant <name>` | Explicit form of the assistant name. Equivalent to the positional argument.
|
|
117
|
+
| `--assistant <name>` | Explicit form of the assistant name. Equivalent to the positional argument. |
|
|
126
118
|
|
|
127
119
|
If no assistant is named and no active assistant is set, the CLI uses the only managed assistant in the lockfile -- or errors out if there's more than one. Use `vellum ps` to see your assistants and `vellum use <name>` to set the active one.
|
|
128
120
|
|
package/package.json
CHANGED
|
@@ -81,12 +81,20 @@ const localRuntimePollJobStatusMock = spyOn(
|
|
|
81
81
|
|
|
82
82
|
// Mode 1 (runtime-direct local backup) uses guardian tokens. Don't exercise
|
|
83
83
|
// it here, but the spies need to exist so the module under test can import
|
|
84
|
-
// them without surprises.
|
|
85
|
-
|
|
84
|
+
// them without surprises. Saved to variables so afterAll can restore them —
|
|
85
|
+
// otherwise the spied loadGuardianToken leaks into guardian-token.test.ts and
|
|
86
|
+
// setup.test.ts when they run later in the same `bun test` invocation.
|
|
87
|
+
const loadGuardianTokenSpy = spyOn(
|
|
88
|
+
guardianToken,
|
|
89
|
+
"loadGuardianToken",
|
|
90
|
+
).mockReturnValue({
|
|
86
91
|
accessToken: "local-token",
|
|
87
92
|
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
88
93
|
} as unknown as ReturnType<typeof guardianToken.loadGuardianToken>);
|
|
89
|
-
spyOn(
|
|
94
|
+
const leaseGuardianTokenSpy = spyOn(
|
|
95
|
+
guardianToken,
|
|
96
|
+
"leaseGuardianToken",
|
|
97
|
+
).mockResolvedValue({
|
|
90
98
|
accessToken: "leased-token",
|
|
91
99
|
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
92
100
|
} as unknown as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>);
|
|
@@ -177,6 +185,8 @@ afterAll(() => {
|
|
|
177
185
|
getBackupsDirMock.mockRestore();
|
|
178
186
|
mkdirSyncMock.mockRestore();
|
|
179
187
|
writeFileSyncMock.mockRestore();
|
|
188
|
+
loadGuardianTokenSpy.mockRestore();
|
|
189
|
+
leaseGuardianTokenSpy.mockRestore();
|
|
180
190
|
rmSync(testDir, { recursive: true, force: true });
|
|
181
191
|
});
|
|
182
192
|
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { readFileSync, rmSync } from "fs";
|
|
2
2
|
import { describe, expect, test } from "bun:test";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildHatchConfigValues,
|
|
6
|
+
buildNestedConfig,
|
|
7
|
+
writeInitialConfig,
|
|
8
|
+
} from "../lib/config-utils.js";
|
|
5
9
|
|
|
6
10
|
function readInitialConfig(
|
|
7
11
|
configValues: Record<string, string>,
|
|
@@ -32,6 +36,32 @@ describe("config-utils", () => {
|
|
|
32
36
|
});
|
|
33
37
|
});
|
|
34
38
|
|
|
39
|
+
test("buildHatchConfigValues adds the default hatch provider when no config exists", () => {
|
|
40
|
+
expect(buildHatchConfigValues({}, "anthropic")).toEqual({
|
|
41
|
+
"llm.default.provider": "anthropic",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("buildHatchConfigValues preserves explicit provider config", () => {
|
|
46
|
+
expect(
|
|
47
|
+
buildHatchConfigValues(
|
|
48
|
+
{
|
|
49
|
+
"llm.default.provider": "openai",
|
|
50
|
+
"llm.default.model": "gpt-5.4",
|
|
51
|
+
},
|
|
52
|
+
"anthropic",
|
|
53
|
+
),
|
|
54
|
+
).toEqual({
|
|
55
|
+
"llm.default.provider": "openai",
|
|
56
|
+
"llm.default.model": "gpt-5.4",
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("buildHatchConfigValues skips internal hatches without provider setup", () => {
|
|
61
|
+
expect(buildHatchConfigValues({}, undefined)).toEqual({});
|
|
62
|
+
expect(buildHatchConfigValues({}, null)).toEqual({});
|
|
63
|
+
});
|
|
64
|
+
|
|
35
65
|
test("writeInitialConfig does not add a mainAgent callSite for Anthropic defaults", () => {
|
|
36
66
|
expect(
|
|
37
67
|
readInitialConfig({
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
configureHatchProviderApiKey,
|
|
5
|
+
resolveHatchProvider,
|
|
6
|
+
type ProviderSecretFetch,
|
|
7
|
+
} from "../lib/provider-secrets.js";
|
|
8
|
+
|
|
9
|
+
interface RecordedFetchCall {
|
|
10
|
+
url: string;
|
|
11
|
+
init?: RequestInit;
|
|
12
|
+
body: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
16
|
+
return new Response(JSON.stringify(body), {
|
|
17
|
+
status,
|
|
18
|
+
headers: { "Content-Type": "application/json" },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeFetch(responses: Response[]): {
|
|
23
|
+
calls: RecordedFetchCall[];
|
|
24
|
+
fetchImpl: ProviderSecretFetch;
|
|
25
|
+
} {
|
|
26
|
+
const calls: RecordedFetchCall[] = [];
|
|
27
|
+
const fetchImpl: ProviderSecretFetch = async (input, init) => {
|
|
28
|
+
calls.push({
|
|
29
|
+
url: String(input),
|
|
30
|
+
init,
|
|
31
|
+
body: typeof init?.body === "string" ? JSON.parse(init.body) : init?.body,
|
|
32
|
+
});
|
|
33
|
+
const response = responses.shift();
|
|
34
|
+
if (!response) {
|
|
35
|
+
throw new Error("Unexpected fetch call.");
|
|
36
|
+
}
|
|
37
|
+
return response;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return { calls, fetchImpl };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("hatch provider secrets", () => {
|
|
44
|
+
test("defaults hatch provider setup to Anthropic", () => {
|
|
45
|
+
expect(resolveHatchProvider({})).toBe("anthropic");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("uses llm.default.provider override for hatch provider setup", () => {
|
|
49
|
+
expect(resolveHatchProvider({ "llm.default.provider": "openai" })).toBe(
|
|
50
|
+
"openai",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("uses active profile provider for hatch provider setup", () => {
|
|
55
|
+
expect(
|
|
56
|
+
resolveHatchProvider({
|
|
57
|
+
"llm.activeProfile": "work",
|
|
58
|
+
"llm.profiles.work.provider": "openai",
|
|
59
|
+
}),
|
|
60
|
+
).toBe("openai");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("infers active profile provider from model for hatch provider setup", () => {
|
|
64
|
+
expect(
|
|
65
|
+
resolveHatchProvider({
|
|
66
|
+
"llm.activeProfile": "work",
|
|
67
|
+
"llm.profiles.work.model": "gpt-5.4",
|
|
68
|
+
}),
|
|
69
|
+
).toBe("openai");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("active profile provider wins over main agent call-site provider", () => {
|
|
73
|
+
expect(
|
|
74
|
+
resolveHatchProvider({
|
|
75
|
+
"llm.activeProfile": "work",
|
|
76
|
+
"llm.profiles.work.provider": "openai",
|
|
77
|
+
"llm.callSites.mainAgent.provider": "gemini",
|
|
78
|
+
}),
|
|
79
|
+
).toBe("openai");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("uses default provider before static main agent call-site provider", () => {
|
|
83
|
+
expect(
|
|
84
|
+
resolveHatchProvider({
|
|
85
|
+
"llm.default.provider": "anthropic",
|
|
86
|
+
"llm.callSites.mainAgent.provider": "gemini",
|
|
87
|
+
}),
|
|
88
|
+
).toBe("anthropic");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("uses default provider before static main agent call-site profile", () => {
|
|
92
|
+
expect(
|
|
93
|
+
resolveHatchProvider({
|
|
94
|
+
"llm.default.provider": "anthropic",
|
|
95
|
+
"llm.callSites.mainAgent.profile": "work",
|
|
96
|
+
"llm.profiles.work.provider": "openai",
|
|
97
|
+
}),
|
|
98
|
+
).toBe("anthropic");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("uses main agent call-site provider when no hatch default exists", () => {
|
|
102
|
+
expect(
|
|
103
|
+
resolveHatchProvider({
|
|
104
|
+
"llm.callSites.mainAgent.provider": "gemini",
|
|
105
|
+
}),
|
|
106
|
+
).toBe("gemini");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("uses main agent call-site profile when no hatch default exists", () => {
|
|
110
|
+
expect(
|
|
111
|
+
resolveHatchProvider({
|
|
112
|
+
"llm.callSites.mainAgent.profile": "work",
|
|
113
|
+
"llm.profiles.work.provider": "openai",
|
|
114
|
+
}),
|
|
115
|
+
).toBe("openai");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("infers default provider from model before falling back to Anthropic", () => {
|
|
119
|
+
expect(
|
|
120
|
+
resolveHatchProvider({ "llm.default.model": "gemini-2.5-flash" }),
|
|
121
|
+
).toBe("gemini");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("skips hatch provider setup for ollama", () => {
|
|
125
|
+
expect(
|
|
126
|
+
resolveHatchProvider({ "llm.default.provider": "ollama" }),
|
|
127
|
+
).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("skips hatch provider setup for active Ollama profile", () => {
|
|
131
|
+
expect(
|
|
132
|
+
resolveHatchProvider({
|
|
133
|
+
"llm.activeProfile": "local",
|
|
134
|
+
"llm.profiles.local.model": "llama3.2",
|
|
135
|
+
}),
|
|
136
|
+
).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("rejects unsupported hatch providers before hatch starts", () => {
|
|
140
|
+
expect(() =>
|
|
141
|
+
resolveHatchProvider({ "llm.default.provider": "custom" }),
|
|
142
|
+
).toThrow("supported API-key setup flow");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("configures default Anthropic credentials from the environment", async () => {
|
|
146
|
+
const { calls, fetchImpl } = makeFetch([
|
|
147
|
+
jsonResponse({ found: false }),
|
|
148
|
+
jsonResponse({ success: true }),
|
|
149
|
+
]);
|
|
150
|
+
const logs: string[] = [];
|
|
151
|
+
|
|
152
|
+
await configureHatchProviderApiKey({
|
|
153
|
+
gatewayUrl: "http://127.0.0.1:7830",
|
|
154
|
+
provider: resolveHatchProvider({}),
|
|
155
|
+
bearerToken: "guardian-token",
|
|
156
|
+
env: { ANTHROPIC_API_KEY: "test-anthropic-key" },
|
|
157
|
+
fetchImpl,
|
|
158
|
+
log: (message) => logs.push(message),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(calls).toHaveLength(2);
|
|
162
|
+
expect(calls[0].body).toEqual({
|
|
163
|
+
type: "api_key",
|
|
164
|
+
name: "anthropic",
|
|
165
|
+
reveal: false,
|
|
166
|
+
});
|
|
167
|
+
expect(calls[0].init?.headers).toMatchObject({
|
|
168
|
+
Authorization: "Bearer guardian-token",
|
|
169
|
+
});
|
|
170
|
+
expect(calls[1].body).toEqual({
|
|
171
|
+
type: "api_key",
|
|
172
|
+
name: "anthropic",
|
|
173
|
+
value: "test-anthropic-key",
|
|
174
|
+
});
|
|
175
|
+
expect(logs.join("\n")).toContain(
|
|
176
|
+
"Configured Anthropic credentials from ANTHROPIC_API_KEY.",
|
|
177
|
+
);
|
|
178
|
+
expect(logs.join("\n")).not.toContain("test-anthropic-key");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("uses OpenAI override and skips prompt when credentials already exist", async () => {
|
|
182
|
+
const { calls, fetchImpl } = makeFetch([jsonResponse({ found: true })]);
|
|
183
|
+
const logs: string[] = [];
|
|
184
|
+
let prompted = false;
|
|
185
|
+
|
|
186
|
+
await configureHatchProviderApiKey({
|
|
187
|
+
gatewayUrl: "http://127.0.0.1:7830",
|
|
188
|
+
provider: resolveHatchProvider({ "llm.default.provider": "openai" }),
|
|
189
|
+
bearerToken: "guardian-token",
|
|
190
|
+
env: {},
|
|
191
|
+
fetchImpl,
|
|
192
|
+
prompt: async () => {
|
|
193
|
+
prompted = true;
|
|
194
|
+
return "unused";
|
|
195
|
+
},
|
|
196
|
+
log: (message) => logs.push(message),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(prompted).toBe(false);
|
|
200
|
+
expect(calls).toHaveLength(1);
|
|
201
|
+
expect(calls[0].body).toEqual({
|
|
202
|
+
type: "api_key",
|
|
203
|
+
name: "openai",
|
|
204
|
+
reveal: false,
|
|
205
|
+
});
|
|
206
|
+
expect(logs.join("\n")).toContain(
|
|
207
|
+
"Provider credentials already configured for OpenAI.",
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("uses active profile provider when selecting environment credential", async () => {
|
|
212
|
+
const { calls, fetchImpl } = makeFetch([
|
|
213
|
+
jsonResponse({ found: false }),
|
|
214
|
+
jsonResponse({ success: true }),
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
await configureHatchProviderApiKey({
|
|
218
|
+
gatewayUrl: "http://127.0.0.1:7830",
|
|
219
|
+
provider: resolveHatchProvider({
|
|
220
|
+
"llm.activeProfile": "work",
|
|
221
|
+
"llm.profiles.work.provider": "openai",
|
|
222
|
+
}),
|
|
223
|
+
bearerToken: "guardian-token",
|
|
224
|
+
env: { OPENAI_API_KEY: "test-openai-key" },
|
|
225
|
+
fetchImpl,
|
|
226
|
+
log: () => {},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(calls[0].body).toEqual({
|
|
230
|
+
type: "api_key",
|
|
231
|
+
name: "openai",
|
|
232
|
+
reveal: false,
|
|
233
|
+
});
|
|
234
|
+
expect(calls[1].body).toEqual({
|
|
235
|
+
type: "api_key",
|
|
236
|
+
name: "openai",
|
|
237
|
+
value: "test-openai-key",
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("keeps hatch recoverable when provider credentials are missing in a non-interactive shell", async () => {
|
|
242
|
+
const { fetchImpl } = makeFetch([jsonResponse({ found: false })]);
|
|
243
|
+
const logs: string[] = [];
|
|
244
|
+
|
|
245
|
+
await configureHatchProviderApiKey({
|
|
246
|
+
gatewayUrl: "http://127.0.0.1:7830",
|
|
247
|
+
provider: "anthropic",
|
|
248
|
+
env: {},
|
|
249
|
+
fetchImpl,
|
|
250
|
+
stdinIsTTY: false,
|
|
251
|
+
log: (message) => logs.push(message),
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const output = logs.join("\n");
|
|
255
|
+
expect(output).toContain("Provider credential setup skipped");
|
|
256
|
+
expect(output).toContain("Missing ANTHROPIC_API_KEY");
|
|
257
|
+
expect(output).toContain("vellum setup --provider anthropic");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("surfaces gateway validation failures without throwing or logging the key", async () => {
|
|
261
|
+
const { fetchImpl } = makeFetch([
|
|
262
|
+
jsonResponse({ found: false }),
|
|
263
|
+
jsonResponse(
|
|
264
|
+
{ error: { message: "API key is invalid or expired." } },
|
|
265
|
+
400,
|
|
266
|
+
),
|
|
267
|
+
]);
|
|
268
|
+
const logs: string[] = [];
|
|
269
|
+
|
|
270
|
+
await configureHatchProviderApiKey({
|
|
271
|
+
gatewayUrl: "http://127.0.0.1:7830",
|
|
272
|
+
provider: "anthropic",
|
|
273
|
+
env: { ANTHROPIC_API_KEY: "test-anthropic-key" },
|
|
274
|
+
fetchImpl,
|
|
275
|
+
log: (message) => logs.push(message),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const output = logs.join("\n");
|
|
279
|
+
expect(output).toContain("Provider credential setup failed");
|
|
280
|
+
expect(output).toContain("API key is invalid or expired.");
|
|
281
|
+
expect(output).toContain("vellum setup --provider anthropic");
|
|
282
|
+
expect(output).not.toContain("test-anthropic-key");
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { homedir, tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { getInputHistoryPath } from "../lib/environments/paths.js";
|
|
12
|
+
import { appendHistory, loadHistory } from "../lib/input-history.js";
|
|
13
|
+
|
|
14
|
+
describe("input-history XDG paths", () => {
|
|
15
|
+
let tempDir: string;
|
|
16
|
+
let savedState: string | undefined;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
savedState = process.env.XDG_STATE_HOME;
|
|
20
|
+
tempDir = mkdtempSync(join(tmpdir(), "cli-input-history-test-"));
|
|
21
|
+
process.env.XDG_STATE_HOME = join(tempDir, ".local", "state");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
if (savedState === undefined) {
|
|
26
|
+
delete process.env.XDG_STATE_HOME;
|
|
27
|
+
} else {
|
|
28
|
+
process.env.XDG_STATE_HOME = savedState;
|
|
29
|
+
}
|
|
30
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("appendHistory writes to $XDG_STATE_HOME/vellum/input-history", () => {
|
|
34
|
+
appendHistory("hello world");
|
|
35
|
+
|
|
36
|
+
const canonical = getInputHistoryPath();
|
|
37
|
+
expect(canonical).toBe(
|
|
38
|
+
join(tempDir, ".local", "state", "vellum", "input-history"),
|
|
39
|
+
);
|
|
40
|
+
expect(existsSync(canonical)).toBe(true);
|
|
41
|
+
expect(readFileSync(canonical, "utf-8")).toBe("hello world\n");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("appendHistory does NOT touch ~/.vellum/", () => {
|
|
45
|
+
// Crucially: the CLI must not create or write to ~/.vellum/ per the
|
|
46
|
+
// "No `.vellum/` directory access" boundary in cli/AGENTS.md. We snapshot
|
|
47
|
+
// the legacy path's existence before the call (some test machines already
|
|
48
|
+
// have a ~/.vellum/ for unrelated daemon state) and assert the file at
|
|
49
|
+
// that path is unchanged afterwards.
|
|
50
|
+
const legacyPath = join(homedir(), ".vellum", "input-history");
|
|
51
|
+
const existedBefore = existsSync(legacyPath);
|
|
52
|
+
const contentBefore: string = existedBefore
|
|
53
|
+
? readFileSync(legacyPath, "utf-8")
|
|
54
|
+
: "";
|
|
55
|
+
|
|
56
|
+
appendHistory("hello");
|
|
57
|
+
|
|
58
|
+
expect(existsSync(legacyPath)).toBe(existedBefore);
|
|
59
|
+
if (existedBefore) {
|
|
60
|
+
expect(readFileSync(legacyPath, "utf-8")).toBe(contentBefore);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("XDG_STATE_HOME default is ~/.local/state when unset", () => {
|
|
65
|
+
delete process.env.XDG_STATE_HOME;
|
|
66
|
+
|
|
67
|
+
// os.homedir() is cached at process start by Bun and ignores
|
|
68
|
+
// process.env.HOME mutations, so compute the expected path from the same
|
|
69
|
+
// source the production helper uses.
|
|
70
|
+
expect(getInputHistoryPath()).toBe(
|
|
71
|
+
join(homedir(), ".local", "state", "vellum", "input-history"),
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("appendHistory skips empty and slash-command entries", () => {
|
|
76
|
+
appendHistory("");
|
|
77
|
+
appendHistory(" ");
|
|
78
|
+
appendHistory("/help");
|
|
79
|
+
appendHistory("real entry");
|
|
80
|
+
|
|
81
|
+
expect(loadHistory()).toEqual(["real entry"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("appendHistory deduplicates by moving to most recent", () => {
|
|
85
|
+
appendHistory("a");
|
|
86
|
+
appendHistory("b");
|
|
87
|
+
appendHistory("a");
|
|
88
|
+
|
|
89
|
+
expect(loadHistory()).toEqual(["b", "a"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("appendHistory caps history at MAX_ENTRIES (1000)", () => {
|
|
93
|
+
for (let i = 0; i < 1100; i++) {
|
|
94
|
+
appendHistory(`entry-${i}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const history = loadHistory();
|
|
98
|
+
expect(history.length).toBe(1000);
|
|
99
|
+
expect(history[0]).toBe("entry-100");
|
|
100
|
+
expect(history[999]).toBe("entry-1099");
|
|
101
|
+
});
|
|
102
|
+
});
|
package/src/__tests__/preload.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
-
import { afterAll } from "bun:test";
|
|
13
|
+
import { afterAll, mock } from "bun:test";
|
|
14
14
|
|
|
15
15
|
const testDir = realpathSync(
|
|
16
16
|
mkdtempSync(join(tmpdir(), "vellum-cli-test-workspace-")),
|
|
@@ -24,4 +24,8 @@ afterAll(() => {
|
|
|
24
24
|
} catch {
|
|
25
25
|
/* best-effort cleanup */
|
|
26
26
|
}
|
|
27
|
+
|
|
28
|
+
// Reset all module mocks so mock.module() calls in one test file
|
|
29
|
+
// don't leak into the next file in the same bun test run.
|
|
30
|
+
mock.restore();
|
|
27
31
|
});
|