@vellumai/cli 0.5.13 → 0.5.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +6 -0
- package/package.json +1 -1
- package/src/__tests__/teleport.test.ts +829 -0
- package/src/commands/hatch.ts +8 -3
- package/src/commands/ps.ts +1 -1
- package/src/commands/recover.ts +13 -4
- package/src/commands/rollback.ts +0 -9
- package/src/commands/teleport.ts +746 -0
- package/src/commands/upgrade.ts +0 -11
- package/src/commands/wake.ts +17 -4
- package/src/index.ts +3 -0
- package/src/lib/assistant-config.ts +3 -2
- package/src/lib/docker.ts +2 -146
- package/src/lib/local.ts +32 -2
- package/src/lib/upgrade-lifecycle.ts +0 -11
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
mock,
|
|
7
|
+
spyOn,
|
|
8
|
+
test,
|
|
9
|
+
} from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Temp directory for lockfile isolation (same pattern as assistant-config.test.ts)
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const testDir = mkdtempSync(join(tmpdir(), "cli-teleport-test-"));
|
|
19
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Mocks — must be set up before importing the module under test
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
// Import the real assistant-config module — do NOT mock it with mock.module()
|
|
26
|
+
// because Bun's mock.module() replaces the module globally and leaks into
|
|
27
|
+
// other test files (e.g. multi-local.test.ts) running in the same process.
|
|
28
|
+
// Instead, we use spyOn to mock findAssistantByName on the imported module object.
|
|
29
|
+
import * as assistantConfig from "../lib/assistant-config.js";
|
|
30
|
+
|
|
31
|
+
const findAssistantByNameMock = spyOn(
|
|
32
|
+
assistantConfig,
|
|
33
|
+
"findAssistantByName",
|
|
34
|
+
).mockReturnValue(null);
|
|
35
|
+
|
|
36
|
+
const loadGuardianTokenMock = mock((_id: string) => ({
|
|
37
|
+
accessToken: "local-token",
|
|
38
|
+
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
39
|
+
}));
|
|
40
|
+
const leaseGuardianTokenMock = mock(async () => ({
|
|
41
|
+
accessToken: "leased-token",
|
|
42
|
+
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
mock.module("../lib/guardian-token.js", () => ({
|
|
46
|
+
loadGuardianToken: loadGuardianTokenMock,
|
|
47
|
+
leaseGuardianToken: leaseGuardianTokenMock,
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
const readPlatformTokenMock = mock((): string | null => "platform-token");
|
|
51
|
+
const fetchOrganizationIdMock = mock(async () => "org-123");
|
|
52
|
+
const platformInitiateExportMock = mock(async () => ({
|
|
53
|
+
jobId: "job-1",
|
|
54
|
+
status: "pending",
|
|
55
|
+
}));
|
|
56
|
+
const platformPollExportStatusMock = mock(async () => ({
|
|
57
|
+
status: "complete" as string,
|
|
58
|
+
downloadUrl: "https://cdn.example.com/bundle.tar.gz",
|
|
59
|
+
}));
|
|
60
|
+
const platformDownloadExportMock = mock(async () => {
|
|
61
|
+
const data = new Uint8Array([10, 20, 30]);
|
|
62
|
+
return new Response(data, { status: 200 });
|
|
63
|
+
});
|
|
64
|
+
const platformImportPreflightMock = mock(async () => ({
|
|
65
|
+
statusCode: 200,
|
|
66
|
+
body: {
|
|
67
|
+
can_import: true,
|
|
68
|
+
summary: {
|
|
69
|
+
files_to_create: 2,
|
|
70
|
+
files_to_overwrite: 1,
|
|
71
|
+
files_unchanged: 0,
|
|
72
|
+
total_files: 3,
|
|
73
|
+
},
|
|
74
|
+
} as Record<string, unknown>,
|
|
75
|
+
}));
|
|
76
|
+
const platformImportBundleMock = mock(async () => ({
|
|
77
|
+
statusCode: 200,
|
|
78
|
+
body: {
|
|
79
|
+
success: true,
|
|
80
|
+
summary: {
|
|
81
|
+
total_files: 3,
|
|
82
|
+
files_created: 2,
|
|
83
|
+
files_overwritten: 1,
|
|
84
|
+
files_skipped: 0,
|
|
85
|
+
backups_created: 1,
|
|
86
|
+
},
|
|
87
|
+
} as Record<string, unknown>,
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
mock.module("../lib/platform-client.js", () => ({
|
|
91
|
+
readPlatformToken: readPlatformTokenMock,
|
|
92
|
+
fetchOrganizationId: fetchOrganizationIdMock,
|
|
93
|
+
platformInitiateExport: platformInitiateExportMock,
|
|
94
|
+
platformPollExportStatus: platformPollExportStatusMock,
|
|
95
|
+
platformDownloadExport: platformDownloadExportMock,
|
|
96
|
+
platformImportPreflight: platformImportPreflightMock,
|
|
97
|
+
platformImportBundle: platformImportBundleMock,
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
import { teleport } from "../commands/teleport.js";
|
|
101
|
+
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Helpers
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
afterAll(() => {
|
|
108
|
+
findAssistantByNameMock.mockRestore();
|
|
109
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
110
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
let originalArgv: string[];
|
|
114
|
+
let exitMock: ReturnType<typeof mock>;
|
|
115
|
+
let originalExit: typeof process.exit;
|
|
116
|
+
let consoleLogSpy: ReturnType<typeof spyOn>;
|
|
117
|
+
let consoleErrorSpy: ReturnType<typeof spyOn>;
|
|
118
|
+
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
originalArgv = [...process.argv];
|
|
121
|
+
|
|
122
|
+
// Reset all mocks
|
|
123
|
+
findAssistantByNameMock.mockReset();
|
|
124
|
+
findAssistantByNameMock.mockReturnValue(null);
|
|
125
|
+
|
|
126
|
+
loadGuardianTokenMock.mockReset();
|
|
127
|
+
loadGuardianTokenMock.mockReturnValue({
|
|
128
|
+
accessToken: "local-token",
|
|
129
|
+
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
130
|
+
});
|
|
131
|
+
leaseGuardianTokenMock.mockReset();
|
|
132
|
+
|
|
133
|
+
readPlatformTokenMock.mockReset();
|
|
134
|
+
readPlatformTokenMock.mockReturnValue("platform-token");
|
|
135
|
+
fetchOrganizationIdMock.mockReset();
|
|
136
|
+
fetchOrganizationIdMock.mockResolvedValue("org-123");
|
|
137
|
+
platformInitiateExportMock.mockReset();
|
|
138
|
+
platformInitiateExportMock.mockResolvedValue({
|
|
139
|
+
jobId: "job-1",
|
|
140
|
+
status: "pending",
|
|
141
|
+
});
|
|
142
|
+
platformPollExportStatusMock.mockReset();
|
|
143
|
+
platformPollExportStatusMock.mockResolvedValue({
|
|
144
|
+
status: "complete",
|
|
145
|
+
downloadUrl: "https://cdn.example.com/bundle.tar.gz",
|
|
146
|
+
});
|
|
147
|
+
platformDownloadExportMock.mockReset();
|
|
148
|
+
platformDownloadExportMock.mockResolvedValue(
|
|
149
|
+
new Response(new Uint8Array([10, 20, 30]), { status: 200 }),
|
|
150
|
+
);
|
|
151
|
+
platformImportPreflightMock.mockReset();
|
|
152
|
+
platformImportPreflightMock.mockResolvedValue({
|
|
153
|
+
statusCode: 200,
|
|
154
|
+
body: {
|
|
155
|
+
can_import: true,
|
|
156
|
+
summary: {
|
|
157
|
+
files_to_create: 2,
|
|
158
|
+
files_to_overwrite: 1,
|
|
159
|
+
files_unchanged: 0,
|
|
160
|
+
total_files: 3,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
platformImportBundleMock.mockReset();
|
|
165
|
+
platformImportBundleMock.mockResolvedValue({
|
|
166
|
+
statusCode: 200,
|
|
167
|
+
body: {
|
|
168
|
+
success: true,
|
|
169
|
+
summary: {
|
|
170
|
+
total_files: 3,
|
|
171
|
+
files_created: 2,
|
|
172
|
+
files_overwritten: 1,
|
|
173
|
+
files_skipped: 0,
|
|
174
|
+
backups_created: 1,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Mock process.exit to throw so we can catch it
|
|
180
|
+
exitMock = mock((code?: number) => {
|
|
181
|
+
throw new Error(`process.exit:${code}`);
|
|
182
|
+
});
|
|
183
|
+
originalExit = process.exit;
|
|
184
|
+
process.exit = exitMock as unknown as typeof process.exit;
|
|
185
|
+
|
|
186
|
+
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
187
|
+
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
import { afterEach } from "bun:test";
|
|
191
|
+
|
|
192
|
+
afterEach(() => {
|
|
193
|
+
process.argv = originalArgv;
|
|
194
|
+
process.exit = originalExit;
|
|
195
|
+
consoleLogSpy.mockRestore();
|
|
196
|
+
consoleErrorSpy.mockRestore();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
function setArgv(...args: string[]): void {
|
|
200
|
+
// teleport reads process.argv.slice(3)
|
|
201
|
+
process.argv = ["bun", "vellum", "teleport", ...args];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function makeEntry(
|
|
205
|
+
id: string,
|
|
206
|
+
overrides?: Partial<AssistantEntry>,
|
|
207
|
+
): AssistantEntry {
|
|
208
|
+
return {
|
|
209
|
+
assistantId: id,
|
|
210
|
+
runtimeUrl: "http://localhost:7821",
|
|
211
|
+
cloud: "local",
|
|
212
|
+
...overrides,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Extract the URL string from a fetch mock call argument. */
|
|
217
|
+
function extractUrl(arg: unknown): string {
|
|
218
|
+
if (typeof arg === "string") return arg;
|
|
219
|
+
if (arg && typeof arg === "object" && "url" in arg) {
|
|
220
|
+
return (arg as { url: string }).url;
|
|
221
|
+
}
|
|
222
|
+
return String(arg);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Filter fetch mock calls by URL substring. */
|
|
226
|
+
function filterFetchCalls(
|
|
227
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
228
|
+
fetchMock: { mock: { calls: any[][] } },
|
|
229
|
+
substring: string,
|
|
230
|
+
): unknown[][] {
|
|
231
|
+
return fetchMock.mock.calls.filter((call: unknown[]) =>
|
|
232
|
+
extractUrl(call[0]).includes(substring),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Arg parsing tests
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
describe("teleport arg parsing", () => {
|
|
241
|
+
test("--help prints usage and exits 0", async () => {
|
|
242
|
+
setArgv("--help");
|
|
243
|
+
await expect(teleport()).rejects.toThrow("process.exit:0");
|
|
244
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
245
|
+
expect.stringContaining("Usage:"),
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("-h prints usage and exits 0", async () => {
|
|
250
|
+
setArgv("-h");
|
|
251
|
+
await expect(teleport()).rejects.toThrow("process.exit:0");
|
|
252
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
253
|
+
expect.stringContaining("Usage:"),
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("missing --from and --to prints help and exits 1", async () => {
|
|
258
|
+
setArgv();
|
|
259
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
260
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
261
|
+
expect.stringContaining("Usage:"),
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("missing --to prints help and exits 1", async () => {
|
|
266
|
+
setArgv("--from", "source");
|
|
267
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
268
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
269
|
+
expect.stringContaining("Usage:"),
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("missing --from prints help and exits 1", async () => {
|
|
274
|
+
setArgv("--to", "target");
|
|
275
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
276
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
277
|
+
expect.stringContaining("Usage:"),
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("--from and --to are correctly parsed", async () => {
|
|
282
|
+
setArgv("--from", "source", "--to", "target");
|
|
283
|
+
|
|
284
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
285
|
+
if (name === "source") return makeEntry("source");
|
|
286
|
+
if (name === "target") return makeEntry("target");
|
|
287
|
+
return null;
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// This will attempt a fetch to the local export endpoint, which will fail.
|
|
291
|
+
// We just want to confirm parsing worked (i.e. it gets past the arg check).
|
|
292
|
+
// Mock global fetch to avoid network calls.
|
|
293
|
+
const originalFetch = globalThis.fetch;
|
|
294
|
+
const fetchMock = mock(async (url: string | URL | Request) => {
|
|
295
|
+
const urlStr = typeof url === "string" ? url : url.toString();
|
|
296
|
+
if (urlStr.includes("/export")) {
|
|
297
|
+
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
298
|
+
}
|
|
299
|
+
// import endpoint — return valid JSON
|
|
300
|
+
return new Response(
|
|
301
|
+
JSON.stringify({
|
|
302
|
+
success: true,
|
|
303
|
+
summary: {
|
|
304
|
+
total_files: 1,
|
|
305
|
+
files_created: 1,
|
|
306
|
+
files_overwritten: 0,
|
|
307
|
+
files_skipped: 0,
|
|
308
|
+
backups_created: 0,
|
|
309
|
+
},
|
|
310
|
+
}),
|
|
311
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
await teleport();
|
|
318
|
+
// Should call findAssistantByName for both source and target
|
|
319
|
+
expect(findAssistantByNameMock).toHaveBeenCalledWith("source");
|
|
320
|
+
expect(findAssistantByNameMock).toHaveBeenCalledWith("target");
|
|
321
|
+
} finally {
|
|
322
|
+
globalThis.fetch = originalFetch;
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("--dry-run flag is detected", async () => {
|
|
327
|
+
setArgv("--from", "source", "--to", "target", "--dry-run");
|
|
328
|
+
|
|
329
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
330
|
+
if (name === "source") return makeEntry("source");
|
|
331
|
+
if (name === "target") return makeEntry("target");
|
|
332
|
+
return null;
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const originalFetch = globalThis.fetch;
|
|
336
|
+
const fetchMock = mock(async (url: string | URL | Request) => {
|
|
337
|
+
const urlStr = typeof url === "string" ? url : url.toString();
|
|
338
|
+
if (urlStr.includes("/export")) {
|
|
339
|
+
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
340
|
+
}
|
|
341
|
+
if (urlStr.includes("/import-preflight")) {
|
|
342
|
+
return new Response(
|
|
343
|
+
JSON.stringify({
|
|
344
|
+
can_import: true,
|
|
345
|
+
summary: {
|
|
346
|
+
files_to_create: 1,
|
|
347
|
+
files_to_overwrite: 0,
|
|
348
|
+
files_unchanged: 0,
|
|
349
|
+
total_files: 1,
|
|
350
|
+
},
|
|
351
|
+
}),
|
|
352
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
return new Response("not found", { status: 404 });
|
|
356
|
+
});
|
|
357
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
await teleport();
|
|
361
|
+
// In dry-run mode for local target, it should call the preflight endpoint
|
|
362
|
+
const preflightCalls = filterFetchCalls(fetchMock, "import-preflight");
|
|
363
|
+
expect(preflightCalls.length).toBe(1);
|
|
364
|
+
} finally {
|
|
365
|
+
globalThis.fetch = originalFetch;
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("unknown assistant name causes exit with error", async () => {
|
|
370
|
+
setArgv("--from", "nonexistent", "--to", "target");
|
|
371
|
+
findAssistantByNameMock.mockReturnValue(null);
|
|
372
|
+
|
|
373
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
374
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
375
|
+
expect.stringContaining("not found in lockfile"),
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// Cloud resolution tests
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
describe("teleport cloud resolution", () => {
|
|
385
|
+
test("entry with cloud: 'vellum' resolves to vellum", async () => {
|
|
386
|
+
setArgv("--from", "src", "--to", "dst");
|
|
387
|
+
|
|
388
|
+
const srcEntry = makeEntry("src", {
|
|
389
|
+
cloud: "vellum",
|
|
390
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
391
|
+
});
|
|
392
|
+
const dstEntry = makeEntry("dst", { cloud: "local" });
|
|
393
|
+
|
|
394
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
395
|
+
if (name === "src") return srcEntry;
|
|
396
|
+
if (name === "dst") return dstEntry;
|
|
397
|
+
return null;
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// Platform export path: readPlatformToken → fetchOrganizationId → initiateExport → poll → download
|
|
401
|
+
// then local import
|
|
402
|
+
const originalFetch = globalThis.fetch;
|
|
403
|
+
const fetchMock = mock(async () => {
|
|
404
|
+
return new Response(
|
|
405
|
+
JSON.stringify({
|
|
406
|
+
success: true,
|
|
407
|
+
summary: {
|
|
408
|
+
total_files: 1,
|
|
409
|
+
files_created: 1,
|
|
410
|
+
files_overwritten: 0,
|
|
411
|
+
files_skipped: 0,
|
|
412
|
+
backups_created: 0,
|
|
413
|
+
},
|
|
414
|
+
}),
|
|
415
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
await teleport();
|
|
422
|
+
// Should have used platform export functions
|
|
423
|
+
expect(platformInitiateExportMock).toHaveBeenCalled();
|
|
424
|
+
expect(platformPollExportStatusMock).toHaveBeenCalled();
|
|
425
|
+
expect(platformDownloadExportMock).toHaveBeenCalled();
|
|
426
|
+
} finally {
|
|
427
|
+
globalThis.fetch = originalFetch;
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("entry with cloud: 'local' resolves to local", async () => {
|
|
432
|
+
setArgv("--from", "src", "--to", "dst");
|
|
433
|
+
|
|
434
|
+
const srcEntry = makeEntry("src", { cloud: "local" });
|
|
435
|
+
const dstEntry = makeEntry("dst", {
|
|
436
|
+
cloud: "vellum",
|
|
437
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
441
|
+
if (name === "src") return srcEntry;
|
|
442
|
+
if (name === "dst") return dstEntry;
|
|
443
|
+
return null;
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const originalFetch = globalThis.fetch;
|
|
447
|
+
const fetchMock = mock(async () => {
|
|
448
|
+
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
449
|
+
});
|
|
450
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
await teleport();
|
|
454
|
+
// Local export uses fetch to /v1/migrations/export
|
|
455
|
+
const exportCalls = filterFetchCalls(fetchMock, "/v1/migrations/export");
|
|
456
|
+
expect(exportCalls.length).toBe(1);
|
|
457
|
+
// Platform import should be called
|
|
458
|
+
expect(platformImportBundleMock).toHaveBeenCalled();
|
|
459
|
+
} finally {
|
|
460
|
+
globalThis.fetch = originalFetch;
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("entry with no cloud but project set resolves to gcp (unsupported)", async () => {
|
|
465
|
+
setArgv("--from", "src", "--to", "dst");
|
|
466
|
+
|
|
467
|
+
// cloud is empty string or undefined — resolveCloud checks entry.project
|
|
468
|
+
const srcEntry = makeEntry("src", {
|
|
469
|
+
cloud: "" as string,
|
|
470
|
+
project: "my-gcp-project",
|
|
471
|
+
});
|
|
472
|
+
const dstEntry = makeEntry("dst", { cloud: "local" });
|
|
473
|
+
|
|
474
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
475
|
+
if (name === "src") return srcEntry;
|
|
476
|
+
if (name === "dst") return dstEntry;
|
|
477
|
+
return null;
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// GCP is unsupported, should print error and exit
|
|
481
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
482
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
483
|
+
expect.stringContaining("only supports local and platform"),
|
|
484
|
+
);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("entry with no cloud and no project resolves to local", async () => {
|
|
488
|
+
setArgv("--from", "src", "--to", "dst");
|
|
489
|
+
|
|
490
|
+
// cloud is falsy, no project — resolveCloud returns "local"
|
|
491
|
+
const srcEntry = makeEntry("src", { cloud: "" as string });
|
|
492
|
+
const dstEntry = makeEntry("dst", { cloud: "local" });
|
|
493
|
+
|
|
494
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
495
|
+
if (name === "src") return srcEntry;
|
|
496
|
+
if (name === "dst") return dstEntry;
|
|
497
|
+
return null;
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const originalFetch = globalThis.fetch;
|
|
501
|
+
const fetchMock = mock(async (url: string | URL | Request) => {
|
|
502
|
+
const urlStr = typeof url === "string" ? url : url.toString();
|
|
503
|
+
if (urlStr.includes("/export")) {
|
|
504
|
+
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
505
|
+
}
|
|
506
|
+
// import endpoint
|
|
507
|
+
return new Response(
|
|
508
|
+
JSON.stringify({
|
|
509
|
+
success: true,
|
|
510
|
+
summary: {
|
|
511
|
+
total_files: 1,
|
|
512
|
+
files_created: 1,
|
|
513
|
+
files_overwritten: 0,
|
|
514
|
+
files_skipped: 0,
|
|
515
|
+
backups_created: 0,
|
|
516
|
+
},
|
|
517
|
+
}),
|
|
518
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
519
|
+
);
|
|
520
|
+
});
|
|
521
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
await teleport();
|
|
525
|
+
// Should use local export (fetch to /v1/migrations/export)
|
|
526
|
+
const exportCalls = filterFetchCalls(fetchMock, "/v1/migrations/export");
|
|
527
|
+
expect(exportCalls.length).toBe(1);
|
|
528
|
+
} finally {
|
|
529
|
+
globalThis.fetch = originalFetch;
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
// Transfer routing tests
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
describe("teleport transfer routing", () => {
|
|
539
|
+
test("local → platform calls local export endpoint and platform import", async () => {
|
|
540
|
+
setArgv("--from", "local-src", "--to", "platform-dst");
|
|
541
|
+
|
|
542
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
543
|
+
if (name === "local-src")
|
|
544
|
+
return makeEntry("local-src", { cloud: "local" });
|
|
545
|
+
if (name === "platform-dst")
|
|
546
|
+
return makeEntry("platform-dst", {
|
|
547
|
+
cloud: "vellum",
|
|
548
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
549
|
+
});
|
|
550
|
+
return null;
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
const originalFetch = globalThis.fetch;
|
|
554
|
+
const fetchMock = mock(async () => {
|
|
555
|
+
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
556
|
+
});
|
|
557
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
558
|
+
|
|
559
|
+
try {
|
|
560
|
+
await teleport();
|
|
561
|
+
|
|
562
|
+
// Local export: should call fetch to /v1/migrations/export
|
|
563
|
+
const exportCalls = filterFetchCalls(fetchMock, "/v1/migrations/export");
|
|
564
|
+
expect(exportCalls.length).toBe(1);
|
|
565
|
+
|
|
566
|
+
// Platform import: should call platformImportBundle
|
|
567
|
+
expect(platformImportBundleMock).toHaveBeenCalled();
|
|
568
|
+
|
|
569
|
+
// Should NOT call platform export functions
|
|
570
|
+
expect(platformInitiateExportMock).not.toHaveBeenCalled();
|
|
571
|
+
} finally {
|
|
572
|
+
globalThis.fetch = originalFetch;
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("platform → local calls platform export functions and local import endpoint", async () => {
|
|
577
|
+
setArgv("--from", "platform-src", "--to", "local-dst");
|
|
578
|
+
|
|
579
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
580
|
+
if (name === "platform-src")
|
|
581
|
+
return makeEntry("platform-src", {
|
|
582
|
+
cloud: "vellum",
|
|
583
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
584
|
+
});
|
|
585
|
+
if (name === "local-dst")
|
|
586
|
+
return makeEntry("local-dst", { cloud: "local" });
|
|
587
|
+
return null;
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const originalFetch = globalThis.fetch;
|
|
591
|
+
const fetchMock = mock(async () => {
|
|
592
|
+
return new Response(
|
|
593
|
+
JSON.stringify({
|
|
594
|
+
success: true,
|
|
595
|
+
summary: {
|
|
596
|
+
total_files: 3,
|
|
597
|
+
files_created: 2,
|
|
598
|
+
files_overwritten: 1,
|
|
599
|
+
files_skipped: 0,
|
|
600
|
+
backups_created: 1,
|
|
601
|
+
},
|
|
602
|
+
}),
|
|
603
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
604
|
+
);
|
|
605
|
+
});
|
|
606
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
await teleport();
|
|
610
|
+
|
|
611
|
+
// Platform export: should call all three platform export functions
|
|
612
|
+
expect(platformInitiateExportMock).toHaveBeenCalled();
|
|
613
|
+
expect(platformPollExportStatusMock).toHaveBeenCalled();
|
|
614
|
+
expect(platformDownloadExportMock).toHaveBeenCalled();
|
|
615
|
+
|
|
616
|
+
// Local import: should call fetch to /v1/migrations/import (but not /import-preflight)
|
|
617
|
+
const importCalls = filterFetchCalls(
|
|
618
|
+
fetchMock,
|
|
619
|
+
"/v1/migrations/import",
|
|
620
|
+
).filter((call) => !extractUrl(call[0]).includes("/import-preflight"));
|
|
621
|
+
expect(importCalls.length).toBe(1);
|
|
622
|
+
|
|
623
|
+
// Should NOT call platformImportBundle
|
|
624
|
+
expect(platformImportBundleMock).not.toHaveBeenCalled();
|
|
625
|
+
} finally {
|
|
626
|
+
globalThis.fetch = originalFetch;
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("--dry-run calls preflight instead of import (local target)", async () => {
|
|
631
|
+
setArgv("--from", "src", "--to", "dst", "--dry-run");
|
|
632
|
+
|
|
633
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
634
|
+
if (name === "src") return makeEntry("src", { cloud: "local" });
|
|
635
|
+
if (name === "dst") return makeEntry("dst", { cloud: "local" });
|
|
636
|
+
return null;
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
const originalFetch = globalThis.fetch;
|
|
640
|
+
const fetchMock = mock(async (url: string | URL | Request) => {
|
|
641
|
+
const urlStr = typeof url === "string" ? url : url.toString();
|
|
642
|
+
if (urlStr.includes("/export")) {
|
|
643
|
+
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
644
|
+
}
|
|
645
|
+
if (urlStr.includes("/import-preflight")) {
|
|
646
|
+
return new Response(
|
|
647
|
+
JSON.stringify({
|
|
648
|
+
can_import: true,
|
|
649
|
+
summary: {
|
|
650
|
+
files_to_create: 1,
|
|
651
|
+
files_to_overwrite: 0,
|
|
652
|
+
files_unchanged: 0,
|
|
653
|
+
total_files: 1,
|
|
654
|
+
},
|
|
655
|
+
}),
|
|
656
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
return new Response("not found", { status: 404 });
|
|
660
|
+
});
|
|
661
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
662
|
+
|
|
663
|
+
try {
|
|
664
|
+
await teleport();
|
|
665
|
+
|
|
666
|
+
// Should call preflight endpoint
|
|
667
|
+
const preflightCalls = filterFetchCalls(fetchMock, "import-preflight");
|
|
668
|
+
expect(preflightCalls.length).toBe(1);
|
|
669
|
+
|
|
670
|
+
// Should NOT call the actual import endpoint
|
|
671
|
+
const importCalls = filterFetchCalls(fetchMock, "/import").filter(
|
|
672
|
+
(call) => !extractUrl(call[0]).includes("preflight"),
|
|
673
|
+
);
|
|
674
|
+
expect(importCalls.length).toBe(0);
|
|
675
|
+
} finally {
|
|
676
|
+
globalThis.fetch = originalFetch;
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test("--dry-run calls platformImportPreflight instead of platformImportBundle (platform target)", async () => {
|
|
681
|
+
setArgv("--from", "src", "--to", "dst", "--dry-run");
|
|
682
|
+
|
|
683
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
684
|
+
if (name === "src") return makeEntry("src", { cloud: "local" });
|
|
685
|
+
if (name === "dst")
|
|
686
|
+
return makeEntry("dst", {
|
|
687
|
+
cloud: "vellum",
|
|
688
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
689
|
+
});
|
|
690
|
+
return null;
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
const originalFetch = globalThis.fetch;
|
|
694
|
+
const fetchMock = mock(async () => {
|
|
695
|
+
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
696
|
+
});
|
|
697
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
698
|
+
|
|
699
|
+
try {
|
|
700
|
+
await teleport();
|
|
701
|
+
|
|
702
|
+
// Should call platformImportPreflight
|
|
703
|
+
expect(platformImportPreflightMock).toHaveBeenCalled();
|
|
704
|
+
|
|
705
|
+
// Should NOT call platformImportBundle
|
|
706
|
+
expect(platformImportBundleMock).not.toHaveBeenCalled();
|
|
707
|
+
} finally {
|
|
708
|
+
globalThis.fetch = originalFetch;
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test("platform → platform calls platform export and platform import", async () => {
|
|
713
|
+
setArgv("--from", "platform-src", "--to", "platform-dst");
|
|
714
|
+
|
|
715
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
716
|
+
if (name === "platform-src")
|
|
717
|
+
return makeEntry("platform-src", {
|
|
718
|
+
cloud: "vellum",
|
|
719
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
720
|
+
});
|
|
721
|
+
if (name === "platform-dst")
|
|
722
|
+
return makeEntry("platform-dst", {
|
|
723
|
+
cloud: "vellum",
|
|
724
|
+
runtimeUrl: "https://platform2.vellum.ai",
|
|
725
|
+
});
|
|
726
|
+
return null;
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
await teleport();
|
|
730
|
+
|
|
731
|
+
// Platform export: should call all three platform export functions
|
|
732
|
+
expect(platformInitiateExportMock).toHaveBeenCalled();
|
|
733
|
+
expect(platformPollExportStatusMock).toHaveBeenCalled();
|
|
734
|
+
expect(platformDownloadExportMock).toHaveBeenCalled();
|
|
735
|
+
|
|
736
|
+
// Platform import: should call platformImportBundle
|
|
737
|
+
expect(platformImportBundleMock).toHaveBeenCalled();
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
// ---------------------------------------------------------------------------
|
|
742
|
+
// Edge case: extra/unrecognized arguments
|
|
743
|
+
// ---------------------------------------------------------------------------
|
|
744
|
+
|
|
745
|
+
describe("teleport extra arguments", () => {
|
|
746
|
+
test("extra unrecognized flags are ignored and command works normally", async () => {
|
|
747
|
+
setArgv(
|
|
748
|
+
"--from",
|
|
749
|
+
"src",
|
|
750
|
+
"--to",
|
|
751
|
+
"dst",
|
|
752
|
+
"--bogus-flag",
|
|
753
|
+
"--another-unknown",
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
757
|
+
if (name === "src") return makeEntry("src", { cloud: "local" });
|
|
758
|
+
if (name === "dst") return makeEntry("dst", { cloud: "local" });
|
|
759
|
+
return null;
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
const originalFetch = globalThis.fetch;
|
|
763
|
+
const fetchMock = mock(async (url: string | URL | Request) => {
|
|
764
|
+
const urlStr = typeof url === "string" ? url : url.toString();
|
|
765
|
+
if (urlStr.includes("/export")) {
|
|
766
|
+
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
767
|
+
}
|
|
768
|
+
return new Response(
|
|
769
|
+
JSON.stringify({
|
|
770
|
+
success: true,
|
|
771
|
+
summary: {
|
|
772
|
+
total_files: 1,
|
|
773
|
+
files_created: 1,
|
|
774
|
+
files_overwritten: 0,
|
|
775
|
+
files_skipped: 0,
|
|
776
|
+
backups_created: 0,
|
|
777
|
+
},
|
|
778
|
+
}),
|
|
779
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
780
|
+
);
|
|
781
|
+
});
|
|
782
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
783
|
+
|
|
784
|
+
try {
|
|
785
|
+
await teleport();
|
|
786
|
+
// Should have proceeded normally despite extra flags
|
|
787
|
+
expect(findAssistantByNameMock).toHaveBeenCalledWith("src");
|
|
788
|
+
expect(findAssistantByNameMock).toHaveBeenCalledWith("dst");
|
|
789
|
+
const exportCalls = filterFetchCalls(fetchMock, "/v1/migrations/export");
|
|
790
|
+
expect(exportCalls.length).toBe(1);
|
|
791
|
+
} finally {
|
|
792
|
+
globalThis.fetch = originalFetch;
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
// ---------------------------------------------------------------------------
|
|
798
|
+
// Edge case: --from or --to without a following value
|
|
799
|
+
// ---------------------------------------------------------------------------
|
|
800
|
+
|
|
801
|
+
describe("teleport malformed flag usage", () => {
|
|
802
|
+
test("--from as the last argument (no value) prints help and exits 1", async () => {
|
|
803
|
+
setArgv("--to", "target", "--from");
|
|
804
|
+
// --from is the last arg so parseArgs won't assign a value to `from`
|
|
805
|
+
// This should result in missing --from and trigger help + exit 1
|
|
806
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
807
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
808
|
+
expect.stringContaining("Usage:"),
|
|
809
|
+
);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test("--to as the last argument (no value) prints help and exits 1", async () => {
|
|
813
|
+
setArgv("--from", "source", "--to");
|
|
814
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
815
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
816
|
+
expect.stringContaining("Usage:"),
|
|
817
|
+
);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
test("--from --to target rejects flag-like value for --from, leaving from undefined", async () => {
|
|
821
|
+
setArgv("--from", "--to", "target");
|
|
822
|
+
// parseArgs sees --from then skips "--to" (starts with --) so from stays undefined.
|
|
823
|
+
// --to then correctly consumes "target". from is undefined → prints help and exits 1.
|
|
824
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
825
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
826
|
+
expect.stringContaining("Usage:"),
|
|
827
|
+
);
|
|
828
|
+
});
|
|
829
|
+
});
|