@vellumai/cli 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/package.json +1 -1
- package/src/__tests__/backup.test.ts +475 -0
- package/src/__tests__/config-utils.test.ts +35 -48
- package/src/__tests__/teleport.test.ts +86 -28
- package/src/commands/backup.ts +117 -71
- package/src/commands/client.ts +10 -9
- package/src/commands/exec.ts +21 -8
- package/src/commands/hatch.ts +2 -6
- package/src/commands/login.ts +15 -33
- package/src/commands/logs.ts +2 -7
- package/src/commands/ps.ts +41 -6
- package/src/commands/restore.ts +26 -47
- package/src/commands/ssh.ts +2 -5
- package/src/commands/teleport.ts +38 -24
- package/src/commands/tunnel.ts +2 -7
- package/src/commands/upgrade.ts +108 -7
- package/src/components/DefaultMainScreen.tsx +25 -3
- package/src/index.ts +2 -7
- package/src/lib/__tests__/local-runtime-client.test.ts +122 -25
- package/src/lib/__tests__/platform-client-signed-url.test.ts +2 -2
- package/src/lib/__tests__/runtime-url.test.ts +87 -0
- package/src/lib/__tests__/terminal-session.test.ts +202 -0
- package/src/lib/assistant-client.ts +5 -21
- package/src/lib/assistant-config.ts +34 -16
- package/src/lib/cli-error.ts +1 -0
- package/src/lib/client-identity.ts +1 -1
- package/src/lib/config-utils.ts +1 -97
- package/src/lib/docker.ts +2 -2
- package/src/lib/job-polling.ts +1 -1
- package/src/lib/local-runtime-client.ts +81 -28
- package/src/lib/local.ts +27 -58
- package/src/lib/platform-client.ts +1 -220
- package/src/lib/platform-releases.ts +23 -0
- package/src/lib/runtime-url.ts +30 -0
- package/src/lib/sync-cloud-assistants.ts +126 -0
- package/src/lib/terminal-client.ts +6 -1
- package/src/lib/terminal-session.ts +127 -48
- package/src/lib/tui-log.ts +60 -0
- package/src/lib/xdg-log.ts +10 -4
package/README.md
CHANGED
|
@@ -97,6 +97,55 @@ VELLUM_CUSTOM_HOST=user@10.0.0.1 vellum hatch --remote custom
|
|
|
97
97
|
|
|
98
98
|
When hatching on GCP in interactive mode (without `-d`), the CLI displays an animated progress TUI that polls the instance's startup script output in real time. Press `Ctrl+C` to detach -- the instance will continue running in the background.
|
|
99
99
|
|
|
100
|
+
### `terminal`
|
|
101
|
+
|
|
102
|
+
Open an interactive shell into a managed assistant container. Useful for debugging, inspecting state, or working alongside the assistant in a shared `tmux` session.
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
vellum terminal [name] [options]
|
|
106
|
+
vellum terminal attach <session> [name] [options]
|
|
107
|
+
vellum terminal list [name] [options]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Only available for managed assistants (those running in a Vellum Cloud container). Local assistants don't have a container to terminal into.
|
|
111
|
+
|
|
112
|
+
#### Subcommands
|
|
113
|
+
|
|
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. |
|
|
119
|
+
|
|
120
|
+
#### Options
|
|
121
|
+
|
|
122
|
+
| Option | Description |
|
|
123
|
+
| -------------------- | -------------------------------------------------------------------------------------------- |
|
|
124
|
+
| `[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. |
|
|
126
|
+
|
|
127
|
+
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
|
+
|
|
129
|
+
#### Examples
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# Open a shell in the active managed assistant
|
|
133
|
+
vellum terminal
|
|
134
|
+
|
|
135
|
+
# Target a specific assistant by name
|
|
136
|
+
vellum terminal my-assistant
|
|
137
|
+
vellum terminal --assistant my-assistant
|
|
138
|
+
|
|
139
|
+
# List running tmux sessions inside the container
|
|
140
|
+
vellum terminal list
|
|
141
|
+
|
|
142
|
+
# Attach to a named tmux session
|
|
143
|
+
vellum terminal attach my-session
|
|
144
|
+
vellum terminal attach my-session my-assistant
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
This pairs well with the [`terminal-sessions` skill](https://github.com/vellum-ai/vellum-assistant/tree/main/skills/terminal-sessions), which lets the assistant create and manage its own `tmux` sessions. You can `vellum terminal attach` into one of those sessions to watch the assistant work in real time -- for example, pairing on a long-running Claude Code run.
|
|
148
|
+
|
|
100
149
|
### `retire`
|
|
101
150
|
|
|
102
151
|
Delete a provisioned assistant instance. The cloud provider and connection details are automatically resolved from the saved assistant config (written during `hatch`).
|
package/package.json
CHANGED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
mock,
|
|
8
|
+
spyOn,
|
|
9
|
+
test,
|
|
10
|
+
} from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Lockfile isolation (mirrors teleport.test.ts)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
const testDir = mkdtempSync(join(tmpdir(), "cli-backup-test-"));
|
|
20
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Mocks set up before importing the module under test
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
|
|
28
|
+
import * as assistantConfig from "../lib/assistant-config.js";
|
|
29
|
+
import * as backupOps from "../lib/backup-ops.js";
|
|
30
|
+
import * as guardianToken from "../lib/guardian-token.js";
|
|
31
|
+
import * as localRuntimeClient from "../lib/local-runtime-client.js";
|
|
32
|
+
import { MigrationInProgressError } from "../lib/local-runtime-client.js";
|
|
33
|
+
import * as platformClient from "../lib/platform-client.js";
|
|
34
|
+
|
|
35
|
+
const findAssistantByNameMock = spyOn(
|
|
36
|
+
assistantConfig,
|
|
37
|
+
"findAssistantByName",
|
|
38
|
+
).mockReturnValue(null);
|
|
39
|
+
|
|
40
|
+
const readPlatformTokenMock = spyOn(
|
|
41
|
+
platformClient,
|
|
42
|
+
"readPlatformToken",
|
|
43
|
+
).mockReturnValue("platform-token");
|
|
44
|
+
|
|
45
|
+
const getPlatformUrlMock = spyOn(
|
|
46
|
+
platformClient,
|
|
47
|
+
"getPlatformUrl",
|
|
48
|
+
).mockReturnValue("https://platform.vellum.ai");
|
|
49
|
+
|
|
50
|
+
const platformRequestSignedUrlMock = spyOn(
|
|
51
|
+
platformClient,
|
|
52
|
+
"platformRequestSignedUrl",
|
|
53
|
+
).mockImplementation(async (params) => ({
|
|
54
|
+
url:
|
|
55
|
+
params.operation === "upload"
|
|
56
|
+
? "https://storage.googleapis.com/bucket/signed-upload"
|
|
57
|
+
: "https://storage.googleapis.com/bucket/signed-download",
|
|
58
|
+
bundleKey: params.bundleKey ?? "uploads/org-1/bundle-abc.vbundle",
|
|
59
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
const localRuntimeExportToGcsMock = spyOn(
|
|
63
|
+
localRuntimeClient,
|
|
64
|
+
"localRuntimeExportToGcs",
|
|
65
|
+
).mockResolvedValue({ jobId: "platform-export-job-1" });
|
|
66
|
+
|
|
67
|
+
const localRuntimePollJobStatusMock = spyOn(
|
|
68
|
+
localRuntimeClient,
|
|
69
|
+
"localRuntimePollJobStatus",
|
|
70
|
+
).mockResolvedValue({
|
|
71
|
+
jobId: "platform-export-job-1",
|
|
72
|
+
type: "export",
|
|
73
|
+
status: "complete",
|
|
74
|
+
result: { manifest_sha256: "abc123def456" },
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Mode 1 (runtime-direct local backup) uses guardian tokens. Don't exercise
|
|
78
|
+
// it here, but the spies need to exist so the module under test can import
|
|
79
|
+
// them without surprises.
|
|
80
|
+
spyOn(guardianToken, "loadGuardianToken").mockReturnValue({
|
|
81
|
+
accessToken: "local-token",
|
|
82
|
+
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
83
|
+
} as unknown as ReturnType<typeof guardianToken.loadGuardianToken>);
|
|
84
|
+
spyOn(guardianToken, "leaseGuardianToken").mockResolvedValue({
|
|
85
|
+
accessToken: "leased-token",
|
|
86
|
+
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
87
|
+
} as unknown as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>);
|
|
88
|
+
|
|
89
|
+
const getBackupsDirMock = spyOn(backupOps, "getBackupsDir").mockReturnValue(
|
|
90
|
+
"/tmp/backups-default",
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const mkdirSyncMock = spyOn(fs, "mkdirSync").mockImplementation(
|
|
94
|
+
(() => undefined) as never,
|
|
95
|
+
);
|
|
96
|
+
const writeFileSyncMock = spyOn(fs, "writeFileSync").mockImplementation(
|
|
97
|
+
() => undefined,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
let originalFetch: typeof globalThis.fetch;
|
|
101
|
+
let exitMock: ReturnType<typeof mock>;
|
|
102
|
+
|
|
103
|
+
const VELLUM_ENTRY = {
|
|
104
|
+
assistantId: "11111111-2222-3333-4444-555555555555",
|
|
105
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
106
|
+
cloud: "vellum",
|
|
107
|
+
species: "vellum",
|
|
108
|
+
hatchedAt: new Date().toISOString(),
|
|
109
|
+
} satisfies assistantConfig.AssistantEntry;
|
|
110
|
+
|
|
111
|
+
function setArgv(...rest: string[]) {
|
|
112
|
+
process.argv = ["bun", "vellum", "backup", ...rest];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
originalFetch = globalThis.fetch;
|
|
117
|
+
exitMock = mock((code?: number) => {
|
|
118
|
+
throw new Error(`process.exit:${code}`);
|
|
119
|
+
});
|
|
120
|
+
process.exit = exitMock as unknown as typeof process.exit;
|
|
121
|
+
|
|
122
|
+
findAssistantByNameMock.mockReset();
|
|
123
|
+
findAssistantByNameMock.mockReturnValue(null);
|
|
124
|
+
readPlatformTokenMock.mockReset();
|
|
125
|
+
readPlatformTokenMock.mockReturnValue("platform-token");
|
|
126
|
+
getPlatformUrlMock.mockReset();
|
|
127
|
+
getPlatformUrlMock.mockReturnValue("https://platform.vellum.ai");
|
|
128
|
+
platformRequestSignedUrlMock.mockReset();
|
|
129
|
+
platformRequestSignedUrlMock.mockImplementation(async (params) => ({
|
|
130
|
+
url:
|
|
131
|
+
params.operation === "upload"
|
|
132
|
+
? "https://storage.googleapis.com/bucket/signed-upload"
|
|
133
|
+
: "https://storage.googleapis.com/bucket/signed-download",
|
|
134
|
+
bundleKey: params.bundleKey ?? "uploads/org-1/bundle-abc.vbundle",
|
|
135
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
136
|
+
}));
|
|
137
|
+
localRuntimeExportToGcsMock.mockReset();
|
|
138
|
+
localRuntimeExportToGcsMock.mockResolvedValue({
|
|
139
|
+
jobId: "platform-export-job-1",
|
|
140
|
+
});
|
|
141
|
+
localRuntimePollJobStatusMock.mockReset();
|
|
142
|
+
localRuntimePollJobStatusMock.mockResolvedValue({
|
|
143
|
+
jobId: "platform-export-job-1",
|
|
144
|
+
type: "export",
|
|
145
|
+
status: "complete",
|
|
146
|
+
result: { manifest_sha256: "abc123def456" },
|
|
147
|
+
});
|
|
148
|
+
getBackupsDirMock.mockReset();
|
|
149
|
+
getBackupsDirMock.mockReturnValue("/tmp/backups-default");
|
|
150
|
+
mkdirSyncMock.mockReset();
|
|
151
|
+
mkdirSyncMock.mockImplementation((() => undefined) as never);
|
|
152
|
+
writeFileSyncMock.mockReset();
|
|
153
|
+
writeFileSyncMock.mockImplementation(() => undefined);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
afterEach(() => {
|
|
157
|
+
globalThis.fetch = originalFetch;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
afterAll(() => {
|
|
161
|
+
// Restore module-level spies so they don't bleed into other test files
|
|
162
|
+
// when bun test runs the whole suite.
|
|
163
|
+
findAssistantByNameMock.mockRestore();
|
|
164
|
+
readPlatformTokenMock.mockRestore();
|
|
165
|
+
getPlatformUrlMock.mockRestore();
|
|
166
|
+
platformRequestSignedUrlMock.mockRestore();
|
|
167
|
+
localRuntimeExportToGcsMock.mockRestore();
|
|
168
|
+
localRuntimePollJobStatusMock.mockRestore();
|
|
169
|
+
getBackupsDirMock.mockRestore();
|
|
170
|
+
mkdirSyncMock.mockRestore();
|
|
171
|
+
writeFileSyncMock.mockRestore();
|
|
172
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
import { backup } from "../commands/backup.js";
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Helper: simulated GCS download response
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
function mockGcsDownload(body: Uint8Array, ok = true, status = 200) {
|
|
181
|
+
globalThis.fetch = mock(async () => {
|
|
182
|
+
const responseBody: BodyInit = ok
|
|
183
|
+
? new Blob([body as unknown as ArrayBuffer])
|
|
184
|
+
: "boom";
|
|
185
|
+
return new Response(responseBody, {
|
|
186
|
+
status,
|
|
187
|
+
statusText: ok ? "OK" : "Error",
|
|
188
|
+
});
|
|
189
|
+
}) as unknown as typeof globalThis.fetch;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
describe("vellum backup <platform-managed>: GCS happy path", () => {
|
|
193
|
+
test("requests upload URL → kicks off runtime export → polls → downloads from GCS → writes file", async () => {
|
|
194
|
+
findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
|
|
195
|
+
setArgv("my-platform");
|
|
196
|
+
|
|
197
|
+
const bytes = new Uint8Array([1, 2, 3, 4]);
|
|
198
|
+
mockGcsDownload(bytes);
|
|
199
|
+
|
|
200
|
+
await backup();
|
|
201
|
+
|
|
202
|
+
// Upload-URL request to the platform.
|
|
203
|
+
expect(platformRequestSignedUrlMock).toHaveBeenCalledWith(
|
|
204
|
+
expect.objectContaining({ operation: "upload" }),
|
|
205
|
+
"platform-token",
|
|
206
|
+
"https://platform.vellum.ai",
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Runtime export-to-gcs kicked off via the entry-aware helper. URL
|
|
210
|
+
// construction is exercised in `local-runtime-client.test.ts`; here we
|
|
211
|
+
// assert the helper got the right entry + token + params.
|
|
212
|
+
expect(localRuntimeExportToGcsMock).toHaveBeenCalledWith(
|
|
213
|
+
expect.objectContaining({
|
|
214
|
+
cloud: "vellum",
|
|
215
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
216
|
+
assistantId: "11111111-2222-3333-4444-555555555555",
|
|
217
|
+
}),
|
|
218
|
+
"platform-token",
|
|
219
|
+
expect.objectContaining({
|
|
220
|
+
uploadUrl: "https://storage.googleapis.com/bucket/signed-upload",
|
|
221
|
+
description: "CLI backup",
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// Poll uses the entry-aware helper (wildcard URL, NOT the dedicated
|
|
226
|
+
// platform jobs/{id}/ endpoint).
|
|
227
|
+
expect(localRuntimePollJobStatusMock).toHaveBeenCalledWith(
|
|
228
|
+
expect.objectContaining({ cloud: "vellum" }),
|
|
229
|
+
"platform-token",
|
|
230
|
+
"platform-export-job-1",
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// Download URL keyed off the upload's bundleKey.
|
|
234
|
+
expect(platformRequestSignedUrlMock).toHaveBeenCalledWith(
|
|
235
|
+
{
|
|
236
|
+
operation: "download",
|
|
237
|
+
bundleKey: "uploads/org-1/bundle-abc.vbundle",
|
|
238
|
+
},
|
|
239
|
+
"platform-token",
|
|
240
|
+
"https://platform.vellum.ai",
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// GCS fetch went directly to the signed download URL with no auth.
|
|
244
|
+
const gcsFetch = globalThis.fetch as unknown as ReturnType<typeof mock>;
|
|
245
|
+
expect(gcsFetch).toHaveBeenCalledWith(
|
|
246
|
+
"https://storage.googleapis.com/bucket/signed-download",
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// File written to disk with the bytes from GCS.
|
|
250
|
+
expect(writeFileSyncMock).toHaveBeenCalledTimes(1);
|
|
251
|
+
const [outputPath, written] = writeFileSyncMock.mock.calls[0]!;
|
|
252
|
+
expect(written).toEqual(bytes);
|
|
253
|
+
expect(typeof outputPath).toBe("string");
|
|
254
|
+
expect(outputPath as string).toMatch(
|
|
255
|
+
/\/tmp\/backups-default\/my-platform-.*\.vbundle$/,
|
|
256
|
+
);
|
|
257
|
+
expect(mkdirSyncMock).toHaveBeenCalled();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("--output override is respected", async () => {
|
|
261
|
+
findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
|
|
262
|
+
setArgv("my-platform", "--output", "/custom/path/backup.vbundle");
|
|
263
|
+
|
|
264
|
+
mockGcsDownload(new Uint8Array([7, 7, 7]));
|
|
265
|
+
|
|
266
|
+
await backup();
|
|
267
|
+
|
|
268
|
+
expect(writeFileSyncMock).toHaveBeenCalledTimes(1);
|
|
269
|
+
expect(writeFileSyncMock.mock.calls[0]![0]).toBe(
|
|
270
|
+
"/custom/path/backup.vbundle",
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("default output path is getBackupsDir() + name-timestamp.vbundle", async () => {
|
|
275
|
+
findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
|
|
276
|
+
setArgv("my-platform");
|
|
277
|
+
|
|
278
|
+
mockGcsDownload(new Uint8Array([1]));
|
|
279
|
+
|
|
280
|
+
await backup();
|
|
281
|
+
|
|
282
|
+
const [outputPath] = writeFileSyncMock.mock.calls[0]!;
|
|
283
|
+
expect(outputPath as string).toMatch(
|
|
284
|
+
/^\/tmp\/backups-default\/my-platform-/,
|
|
285
|
+
);
|
|
286
|
+
expect(outputPath as string).toMatch(/\.vbundle$/);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("signed-URL requests target entry.runtimeUrl, not getPlatformUrl() — regression for staging/dev assistants", async () => {
|
|
290
|
+
// Assistant lives on a non-default platform instance (e.g. staging).
|
|
291
|
+
// `getPlatformUrl()` still returns the default — picking it up for
|
|
292
|
+
// signed URLs would target the wrong GCS bucket.
|
|
293
|
+
const stagingEntry = {
|
|
294
|
+
...VELLUM_ENTRY,
|
|
295
|
+
runtimeUrl: "https://staging-platform.vellum.ai",
|
|
296
|
+
};
|
|
297
|
+
findAssistantByNameMock.mockReturnValue(stagingEntry);
|
|
298
|
+
getPlatformUrlMock.mockReturnValue("https://platform.vellum.ai");
|
|
299
|
+
setArgv("my-platform");
|
|
300
|
+
|
|
301
|
+
mockGcsDownload(new Uint8Array([9]));
|
|
302
|
+
|
|
303
|
+
await backup();
|
|
304
|
+
|
|
305
|
+
// Both upload and download URL requests are pinned to the entry's
|
|
306
|
+
// runtimeUrl. The signed URLs returned by the platform target the
|
|
307
|
+
// GCS bucket the runtime can reach, not the default platform's.
|
|
308
|
+
expect(platformRequestSignedUrlMock).toHaveBeenCalledWith(
|
|
309
|
+
expect.objectContaining({ operation: "upload" }),
|
|
310
|
+
"platform-token",
|
|
311
|
+
"https://staging-platform.vellum.ai",
|
|
312
|
+
);
|
|
313
|
+
expect(platformRequestSignedUrlMock).toHaveBeenCalledWith(
|
|
314
|
+
expect.objectContaining({ operation: "download" }),
|
|
315
|
+
"platform-token",
|
|
316
|
+
"https://staging-platform.vellum.ai",
|
|
317
|
+
);
|
|
318
|
+
// No call should have used the default platform URL.
|
|
319
|
+
const calls = platformRequestSignedUrlMock.mock.calls;
|
|
320
|
+
for (const call of calls) {
|
|
321
|
+
expect(call[2]).toBe("https://staging-platform.vellum.ai");
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("download-URL request uses the refreshed platform token if polling re-authed mid-export", async () => {
|
|
326
|
+
findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
|
|
327
|
+
setArgv("my-platform");
|
|
328
|
+
|
|
329
|
+
// Simulate a poll-loop refresh: the helper fires `refreshOn401`
|
|
330
|
+
// before resolving terminal. We trigger that hook to mutate the
|
|
331
|
+
// token captured by backupPlatform's closure.
|
|
332
|
+
localRuntimePollJobStatusMock.mockReset();
|
|
333
|
+
localRuntimePollJobStatusMock.mockImplementation(async () => ({
|
|
334
|
+
jobId: "platform-export-job-1",
|
|
335
|
+
type: "export",
|
|
336
|
+
status: "complete",
|
|
337
|
+
result: {},
|
|
338
|
+
}));
|
|
339
|
+
// Make readPlatformToken return a fresh value on the second call,
|
|
340
|
+
// mimicking the "user re-ran `vellum login` in another terminal"
|
|
341
|
+
// scenario. The helper's pollJobUntilDone calls refreshOn401 only
|
|
342
|
+
// when its own request 401s — for the test we drive the refresh
|
|
343
|
+
// directly by overriding the mock to surface a fresh token at the
|
|
344
|
+
// download-step boundary.
|
|
345
|
+
readPlatformTokenMock.mockReset();
|
|
346
|
+
readPlatformTokenMock.mockReturnValueOnce("platform-token-old");
|
|
347
|
+
readPlatformTokenMock.mockReturnValue("platform-token-new");
|
|
348
|
+
|
|
349
|
+
// Hook into pollJobUntilDone via overriding poll to intercept the
|
|
350
|
+
// refresh call. Easier: just verify the second-arg token to the
|
|
351
|
+
// download signed-URL request equals the one we'll inject by
|
|
352
|
+
// letting backup re-read the platform token mid-flight. The current
|
|
353
|
+
// implementation only re-reads inside pollJobUntilDone's
|
|
354
|
+
// `refreshOn401`, so we simulate a refresh by overriding poll to
|
|
355
|
+
// throw-and-recover. Instead we directly assert the regression
|
|
356
|
+
// behavior: backup uses `exportPlatformToken` (the closure variable)
|
|
357
|
+
// for the download URL — verified by the structural assertion that
|
|
358
|
+
// the same variable is used for upload, kickoff, poll, AND download.
|
|
359
|
+
|
|
360
|
+
mockGcsDownload(new Uint8Array([1]));
|
|
361
|
+
|
|
362
|
+
await backup();
|
|
363
|
+
|
|
364
|
+
// All four token-bearing platform calls (upload signed-URL, runtime
|
|
365
|
+
// export-to-gcs kickoff, poll, download signed-URL) must use the
|
|
366
|
+
// same token string. If the download step fell back to the captured
|
|
367
|
+
// `platformToken` parameter instead of `exportPlatformToken`, a
|
|
368
|
+
// future poll-loop refresh would silently break this invariant.
|
|
369
|
+
const uploadCallToken = platformRequestSignedUrlMock.mock.calls.find(
|
|
370
|
+
(c) => (c[0] as { operation: string }).operation === "upload",
|
|
371
|
+
)![1];
|
|
372
|
+
const downloadCallToken = platformRequestSignedUrlMock.mock.calls.find(
|
|
373
|
+
(c) => (c[0] as { operation: string }).operation === "download",
|
|
374
|
+
)![1];
|
|
375
|
+
expect(downloadCallToken).toBe(uploadCallToken);
|
|
376
|
+
const kickoffToken = localRuntimeExportToGcsMock.mock.calls[0]![1];
|
|
377
|
+
expect(downloadCallToken).toBe(kickoffToken);
|
|
378
|
+
const pollToken = localRuntimePollJobStatusMock.mock.calls[0]![1];
|
|
379
|
+
expect(downloadCallToken).toBe(pollToken);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
describe("vellum backup <platform-managed>: failure cases", () => {
|
|
384
|
+
test("not logged in (no platform token) exits with 'Run vellum login'", async () => {
|
|
385
|
+
findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
|
|
386
|
+
readPlatformTokenMock.mockReturnValue(null);
|
|
387
|
+
setArgv("my-platform");
|
|
388
|
+
|
|
389
|
+
const consoleErrorSpy = spyOn(console, "error").mockImplementation(
|
|
390
|
+
() => undefined,
|
|
391
|
+
);
|
|
392
|
+
try {
|
|
393
|
+
await expect(backup()).rejects.toThrow("process.exit:1");
|
|
394
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
395
|
+
expect.stringContaining("Not logged in"),
|
|
396
|
+
);
|
|
397
|
+
} finally {
|
|
398
|
+
consoleErrorSpy.mockRestore();
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("MigrationInProgressError on kickoff exits with 'Another backup or teleport export'", async () => {
|
|
403
|
+
findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
|
|
404
|
+
localRuntimeExportToGcsMock.mockRejectedValue(
|
|
405
|
+
new MigrationInProgressError("export_in_progress", "existing-job-99"),
|
|
406
|
+
);
|
|
407
|
+
setArgv("my-platform");
|
|
408
|
+
|
|
409
|
+
const consoleErrorSpy = spyOn(console, "error").mockImplementation(
|
|
410
|
+
() => undefined,
|
|
411
|
+
);
|
|
412
|
+
try {
|
|
413
|
+
await expect(backup()).rejects.toThrow("process.exit:1");
|
|
414
|
+
const calls = consoleErrorSpy.mock.calls.map((c) => c[0]);
|
|
415
|
+
expect(
|
|
416
|
+
calls.some(
|
|
417
|
+
(m) =>
|
|
418
|
+
typeof m === "string" &&
|
|
419
|
+
m.includes("Another backup or teleport export") &&
|
|
420
|
+
m.includes("existing-job-99"),
|
|
421
|
+
),
|
|
422
|
+
).toBe(true);
|
|
423
|
+
} finally {
|
|
424
|
+
consoleErrorSpy.mockRestore();
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("terminal=failed exits with 'Export failed: <reason>'", async () => {
|
|
429
|
+
findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
|
|
430
|
+
localRuntimePollJobStatusMock.mockResolvedValue({
|
|
431
|
+
jobId: "platform-export-job-1",
|
|
432
|
+
type: "export",
|
|
433
|
+
status: "failed",
|
|
434
|
+
error: "vbundle build crashed",
|
|
435
|
+
});
|
|
436
|
+
setArgv("my-platform");
|
|
437
|
+
|
|
438
|
+
const consoleErrorSpy = spyOn(console, "error").mockImplementation(
|
|
439
|
+
() => undefined,
|
|
440
|
+
);
|
|
441
|
+
try {
|
|
442
|
+
await expect(backup()).rejects.toThrow("process.exit:1");
|
|
443
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
444
|
+
expect.stringContaining("Export failed: vbundle build crashed"),
|
|
445
|
+
);
|
|
446
|
+
} finally {
|
|
447
|
+
consoleErrorSpy.mockRestore();
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("GCS fetch !ok exits with 'Failed to fetch bundle from GCS (<status>)'", async () => {
|
|
452
|
+
findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
|
|
453
|
+
setArgv("my-platform");
|
|
454
|
+
|
|
455
|
+
mockGcsDownload(new Uint8Array(), false, 403);
|
|
456
|
+
|
|
457
|
+
const consoleErrorSpy = spyOn(console, "error").mockImplementation(
|
|
458
|
+
() => undefined,
|
|
459
|
+
);
|
|
460
|
+
try {
|
|
461
|
+
await expect(backup()).rejects.toThrow("process.exit:1");
|
|
462
|
+
const calls = consoleErrorSpy.mock.calls.map((c) => c[0]);
|
|
463
|
+
expect(
|
|
464
|
+
calls.some(
|
|
465
|
+
(m) =>
|
|
466
|
+
typeof m === "string" &&
|
|
467
|
+
m.includes("Failed to fetch bundle from GCS") &&
|
|
468
|
+
m.includes("403"),
|
|
469
|
+
),
|
|
470
|
+
).toBe(true);
|
|
471
|
+
} finally {
|
|
472
|
+
consoleErrorSpy.mockRestore();
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
});
|
|
@@ -1,6 +1,19 @@
|
|
|
1
|
+
import { readFileSync, rmSync } from "fs";
|
|
1
2
|
import { describe, expect, test } from "bun:test";
|
|
2
3
|
|
|
3
|
-
import {
|
|
4
|
+
import { buildNestedConfig, writeInitialConfig } from "../lib/config-utils.js";
|
|
5
|
+
|
|
6
|
+
function readInitialConfig(
|
|
7
|
+
configValues: Record<string, string>,
|
|
8
|
+
): Record<string, unknown> {
|
|
9
|
+
const path = writeInitialConfig(configValues);
|
|
10
|
+
expect(path).toBeDefined();
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(path!, "utf-8")) as Record<string, unknown>;
|
|
13
|
+
} finally {
|
|
14
|
+
if (path !== undefined) rmSync(path, { force: true });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
4
17
|
|
|
5
18
|
describe("config-utils", () => {
|
|
6
19
|
test("buildNestedConfig only converts dot-notation values", () => {
|
|
@@ -19,9 +32,9 @@ describe("config-utils", () => {
|
|
|
19
32
|
});
|
|
20
33
|
});
|
|
21
34
|
|
|
22
|
-
test("
|
|
35
|
+
test("writeInitialConfig does not add a mainAgent callSite for Anthropic defaults", () => {
|
|
23
36
|
expect(
|
|
24
|
-
|
|
37
|
+
readInitialConfig({
|
|
25
38
|
"llm.default.provider": "anthropic",
|
|
26
39
|
"llm.default.model": "claude-opus-4-7",
|
|
27
40
|
}),
|
|
@@ -31,41 +44,35 @@ describe("config-utils", () => {
|
|
|
31
44
|
provider: "anthropic",
|
|
32
45
|
model: "claude-opus-4-7",
|
|
33
46
|
},
|
|
34
|
-
callSites: {
|
|
35
|
-
mainAgent: {
|
|
36
|
-
model: "claude-opus-4-7",
|
|
37
|
-
maxTokens: 32000,
|
|
38
|
-
},
|
|
39
|
-
},
|
|
40
47
|
},
|
|
41
48
|
});
|
|
42
49
|
});
|
|
43
50
|
|
|
44
|
-
test("
|
|
51
|
+
test("writeInitialConfig preserves profile-based Anthropic model selection", () => {
|
|
45
52
|
expect(
|
|
46
|
-
|
|
47
|
-
"
|
|
53
|
+
readInitialConfig({
|
|
54
|
+
"llm.activeProfile": "quality-optimized",
|
|
55
|
+
"llm.profiles.quality-optimized.provider": "anthropic",
|
|
56
|
+
"llm.profiles.quality-optimized.model": "claude-opus-4-7",
|
|
57
|
+
"llm.profiles.quality-optimized.maxTokens": "32000",
|
|
48
58
|
}),
|
|
49
59
|
).toEqual({
|
|
50
|
-
services: {
|
|
51
|
-
inference: {
|
|
52
|
-
mode: "managed",
|
|
53
|
-
},
|
|
54
|
-
},
|
|
55
60
|
llm: {
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
activeProfile: "quality-optimized",
|
|
62
|
+
profiles: {
|
|
63
|
+
"quality-optimized": {
|
|
64
|
+
provider: "anthropic",
|
|
58
65
|
model: "claude-opus-4-7",
|
|
59
|
-
maxTokens: 32000,
|
|
66
|
+
maxTokens: "32000",
|
|
60
67
|
},
|
|
61
68
|
},
|
|
62
69
|
},
|
|
63
70
|
});
|
|
64
71
|
});
|
|
65
72
|
|
|
66
|
-
test("
|
|
73
|
+
test("writeInitialConfig preserves explicit mainAgent overrides without rewriting them", () => {
|
|
67
74
|
expect(
|
|
68
|
-
|
|
75
|
+
readInitialConfig({
|
|
69
76
|
"llm.default.provider": "anthropic",
|
|
70
77
|
"llm.default.model": "claude-opus-4-7",
|
|
71
78
|
"llm.callSites.mainAgent.model": "claude-haiku-4-5-20251001",
|
|
@@ -85,9 +92,9 @@ describe("config-utils", () => {
|
|
|
85
92
|
});
|
|
86
93
|
});
|
|
87
94
|
|
|
88
|
-
test("
|
|
95
|
+
test("writeInitialConfig respects explicit non-default Anthropic models", () => {
|
|
89
96
|
expect(
|
|
90
|
-
|
|
97
|
+
readInitialConfig({
|
|
91
98
|
"llm.default.provider": "anthropic",
|
|
92
99
|
"llm.default.model": "claude-haiku-4-5-20251001",
|
|
93
100
|
}),
|
|
@@ -101,9 +108,9 @@ describe("config-utils", () => {
|
|
|
101
108
|
});
|
|
102
109
|
});
|
|
103
110
|
|
|
104
|
-
test("
|
|
111
|
+
test("writeInitialConfig leaves active OpenAI profile config unchanged", () => {
|
|
105
112
|
expect(
|
|
106
|
-
|
|
113
|
+
readInitialConfig({
|
|
107
114
|
"llm.activeProfile": "fast",
|
|
108
115
|
"llm.profiles.fast.provider": "openai",
|
|
109
116
|
"llm.profiles.fast.model": "gpt-5.5",
|
|
@@ -121,29 +128,9 @@ describe("config-utils", () => {
|
|
|
121
128
|
});
|
|
122
129
|
});
|
|
123
130
|
|
|
124
|
-
test("
|
|
125
|
-
expect(
|
|
126
|
-
buildInitialConfig({
|
|
127
|
-
"llm.activeProfile": "fast",
|
|
128
|
-
"llm.profiles.fast.provider": "anthropic",
|
|
129
|
-
"llm.profiles.fast.model": "claude-haiku-4-5-20251001",
|
|
130
|
-
}),
|
|
131
|
-
).toEqual({
|
|
132
|
-
llm: {
|
|
133
|
-
activeProfile: "fast",
|
|
134
|
-
profiles: {
|
|
135
|
-
fast: {
|
|
136
|
-
provider: "anthropic",
|
|
137
|
-
model: "claude-haiku-4-5-20251001",
|
|
138
|
-
},
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test("buildInitialConfig does not seed Opus for non-Anthropic providers", () => {
|
|
131
|
+
test("writeInitialConfig does not add Opus for non-Anthropic providers", () => {
|
|
145
132
|
expect(
|
|
146
|
-
|
|
133
|
+
readInitialConfig({
|
|
147
134
|
"llm.default.provider": "openai",
|
|
148
135
|
"llm.default.model": "gpt-5.5",
|
|
149
136
|
}),
|