@vellumai/cli 0.5.14 → 0.5.16
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/package.json +1 -1
- package/src/__tests__/teleport.test.ts +568 -397
- package/src/commands/hatch.ts +3 -387
- package/src/commands/retire.ts +2 -120
- package/src/commands/teleport.ts +595 -187
- package/src/commands/wake.ts +29 -4
- package/src/lib/hatch-local.ts +403 -0
- package/src/lib/local.ts +9 -120
- package/src/lib/retire-local.ts +124 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
afterAll,
|
|
3
|
+
afterEach,
|
|
3
4
|
beforeEach,
|
|
4
5
|
describe,
|
|
5
6
|
expect,
|
|
@@ -33,6 +34,21 @@ const findAssistantByNameMock = spyOn(
|
|
|
33
34
|
"findAssistantByName",
|
|
34
35
|
).mockReturnValue(null);
|
|
35
36
|
|
|
37
|
+
const saveAssistantEntryMock = spyOn(
|
|
38
|
+
assistantConfig,
|
|
39
|
+
"saveAssistantEntry",
|
|
40
|
+
).mockImplementation(() => {});
|
|
41
|
+
|
|
42
|
+
const loadAllAssistantsMock = spyOn(
|
|
43
|
+
assistantConfig,
|
|
44
|
+
"loadAllAssistants",
|
|
45
|
+
).mockReturnValue([]);
|
|
46
|
+
|
|
47
|
+
const removeAssistantEntryMock = spyOn(
|
|
48
|
+
assistantConfig,
|
|
49
|
+
"removeAssistantEntry",
|
|
50
|
+
).mockImplementation(() => {});
|
|
51
|
+
|
|
36
52
|
const loadGuardianTokenMock = mock((_id: string) => ({
|
|
37
53
|
accessToken: "local-token",
|
|
38
54
|
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
@@ -49,6 +65,12 @@ mock.module("../lib/guardian-token.js", () => ({
|
|
|
49
65
|
|
|
50
66
|
const readPlatformTokenMock = mock((): string | null => "platform-token");
|
|
51
67
|
const fetchOrganizationIdMock = mock(async () => "org-123");
|
|
68
|
+
const getPlatformUrlMock = mock(() => "https://platform.vellum.ai");
|
|
69
|
+
const hatchAssistantMock = mock(async () => ({
|
|
70
|
+
id: "platform-new-id",
|
|
71
|
+
name: "platform-new",
|
|
72
|
+
status: "active",
|
|
73
|
+
}));
|
|
52
74
|
const platformInitiateExportMock = mock(async () => ({
|
|
53
75
|
jobId: "job-1",
|
|
54
76
|
status: "pending",
|
|
@@ -90,6 +112,8 @@ const platformImportBundleMock = mock(async () => ({
|
|
|
90
112
|
mock.module("../lib/platform-client.js", () => ({
|
|
91
113
|
readPlatformToken: readPlatformTokenMock,
|
|
92
114
|
fetchOrganizationId: fetchOrganizationIdMock,
|
|
115
|
+
getPlatformUrl: getPlatformUrlMock,
|
|
116
|
+
hatchAssistant: hatchAssistantMock,
|
|
93
117
|
platformInitiateExport: platformInitiateExportMock,
|
|
94
118
|
platformPollExportStatus: platformPollExportStatusMock,
|
|
95
119
|
platformDownloadExport: platformDownloadExportMock,
|
|
@@ -97,7 +121,47 @@ mock.module("../lib/platform-client.js", () => ({
|
|
|
97
121
|
platformImportBundle: platformImportBundleMock,
|
|
98
122
|
}));
|
|
99
123
|
|
|
100
|
-
|
|
124
|
+
const hatchLocalMock = mock(async () => {});
|
|
125
|
+
|
|
126
|
+
mock.module("../lib/hatch-local.js", () => ({
|
|
127
|
+
hatchLocal: hatchLocalMock,
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
const hatchDockerMock = mock(async () => {});
|
|
131
|
+
const retireDockerMock = mock(async () => {});
|
|
132
|
+
|
|
133
|
+
const sleepContainersMock = mock(async () => {});
|
|
134
|
+
const dockerResourceNamesMock = mock((name: string) => ({
|
|
135
|
+
assistantContainer: `${name}-assistant`,
|
|
136
|
+
gatewayContainer: `${name}-gateway`,
|
|
137
|
+
cesContainer: `${name}-ces`,
|
|
138
|
+
network: `${name}-net`,
|
|
139
|
+
}));
|
|
140
|
+
|
|
141
|
+
mock.module("../lib/docker.js", () => ({
|
|
142
|
+
hatchDocker: hatchDockerMock,
|
|
143
|
+
retireDocker: retireDockerMock,
|
|
144
|
+
sleepContainers: sleepContainersMock,
|
|
145
|
+
dockerResourceNames: dockerResourceNamesMock,
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
const stopProcessByPidFileMock = mock(async () => true);
|
|
149
|
+
|
|
150
|
+
mock.module("../lib/process.js", () => ({
|
|
151
|
+
stopProcessByPidFile: stopProcessByPidFileMock,
|
|
152
|
+
}));
|
|
153
|
+
|
|
154
|
+
const retireLocalMock = mock(async () => {});
|
|
155
|
+
|
|
156
|
+
mock.module("../lib/retire-local.js", () => ({
|
|
157
|
+
retireLocal: retireLocalMock,
|
|
158
|
+
}));
|
|
159
|
+
|
|
160
|
+
import {
|
|
161
|
+
teleport,
|
|
162
|
+
parseArgs,
|
|
163
|
+
resolveOrHatchTarget,
|
|
164
|
+
} from "../commands/teleport.js";
|
|
101
165
|
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
102
166
|
|
|
103
167
|
// ---------------------------------------------------------------------------
|
|
@@ -106,6 +170,9 @@ import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
|
106
170
|
|
|
107
171
|
afterAll(() => {
|
|
108
172
|
findAssistantByNameMock.mockRestore();
|
|
173
|
+
saveAssistantEntryMock.mockRestore();
|
|
174
|
+
loadAllAssistantsMock.mockRestore();
|
|
175
|
+
removeAssistantEntryMock.mockRestore();
|
|
109
176
|
rmSync(testDir, { recursive: true, force: true });
|
|
110
177
|
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
111
178
|
});
|
|
@@ -122,6 +189,12 @@ beforeEach(() => {
|
|
|
122
189
|
// Reset all mocks
|
|
123
190
|
findAssistantByNameMock.mockReset();
|
|
124
191
|
findAssistantByNameMock.mockReturnValue(null);
|
|
192
|
+
saveAssistantEntryMock.mockReset();
|
|
193
|
+
saveAssistantEntryMock.mockImplementation(() => {});
|
|
194
|
+
loadAllAssistantsMock.mockReset();
|
|
195
|
+
loadAllAssistantsMock.mockReturnValue([]);
|
|
196
|
+
removeAssistantEntryMock.mockReset();
|
|
197
|
+
removeAssistantEntryMock.mockImplementation(() => {});
|
|
125
198
|
|
|
126
199
|
loadGuardianTokenMock.mockReset();
|
|
127
200
|
loadGuardianTokenMock.mockReturnValue({
|
|
@@ -134,6 +207,14 @@ beforeEach(() => {
|
|
|
134
207
|
readPlatformTokenMock.mockReturnValue("platform-token");
|
|
135
208
|
fetchOrganizationIdMock.mockReset();
|
|
136
209
|
fetchOrganizationIdMock.mockResolvedValue("org-123");
|
|
210
|
+
getPlatformUrlMock.mockReset();
|
|
211
|
+
getPlatformUrlMock.mockReturnValue("https://platform.vellum.ai");
|
|
212
|
+
hatchAssistantMock.mockReset();
|
|
213
|
+
hatchAssistantMock.mockResolvedValue({
|
|
214
|
+
id: "platform-new-id",
|
|
215
|
+
name: "platform-new",
|
|
216
|
+
status: "active",
|
|
217
|
+
});
|
|
137
218
|
platformInitiateExportMock.mockReset();
|
|
138
219
|
platformInitiateExportMock.mockResolvedValue({
|
|
139
220
|
jobId: "job-1",
|
|
@@ -176,6 +257,19 @@ beforeEach(() => {
|
|
|
176
257
|
},
|
|
177
258
|
});
|
|
178
259
|
|
|
260
|
+
hatchLocalMock.mockReset();
|
|
261
|
+
hatchLocalMock.mockResolvedValue(undefined);
|
|
262
|
+
hatchDockerMock.mockReset();
|
|
263
|
+
hatchDockerMock.mockResolvedValue(undefined);
|
|
264
|
+
retireDockerMock.mockReset();
|
|
265
|
+
retireDockerMock.mockResolvedValue(undefined);
|
|
266
|
+
retireLocalMock.mockReset();
|
|
267
|
+
retireLocalMock.mockResolvedValue(undefined);
|
|
268
|
+
sleepContainersMock.mockReset();
|
|
269
|
+
sleepContainersMock.mockResolvedValue(undefined);
|
|
270
|
+
stopProcessByPidFileMock.mockReset();
|
|
271
|
+
stopProcessByPidFileMock.mockResolvedValue(true);
|
|
272
|
+
|
|
179
273
|
// Mock process.exit to throw so we can catch it
|
|
180
274
|
exitMock = mock((code?: number) => {
|
|
181
275
|
throw new Error(`process.exit:${code}`);
|
|
@@ -187,8 +281,6 @@ beforeEach(() => {
|
|
|
187
281
|
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
188
282
|
});
|
|
189
283
|
|
|
190
|
-
import { afterEach } from "bun:test";
|
|
191
|
-
|
|
192
284
|
afterEach(() => {
|
|
193
285
|
process.argv = originalArgv;
|
|
194
286
|
process.exit = originalExit;
|
|
@@ -213,24 +305,44 @@ function makeEntry(
|
|
|
213
305
|
};
|
|
214
306
|
}
|
|
215
307
|
|
|
216
|
-
/**
|
|
217
|
-
function
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
308
|
+
/** Create a mock fetch that handles export and import endpoints. */
|
|
309
|
+
function createFetchMock() {
|
|
310
|
+
return mock(async (url: string | URL | Request) => {
|
|
311
|
+
const urlStr = typeof url === "string" ? url : url.toString();
|
|
312
|
+
if (urlStr.includes("/export")) {
|
|
313
|
+
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
314
|
+
}
|
|
315
|
+
if (urlStr.includes("/import-preflight")) {
|
|
316
|
+
return new Response(
|
|
317
|
+
JSON.stringify({
|
|
318
|
+
can_import: true,
|
|
319
|
+
summary: {
|
|
320
|
+
files_to_create: 1,
|
|
321
|
+
files_to_overwrite: 0,
|
|
322
|
+
files_unchanged: 0,
|
|
323
|
+
total_files: 1,
|
|
324
|
+
},
|
|
325
|
+
}),
|
|
326
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (urlStr.includes("/import")) {
|
|
330
|
+
return new Response(
|
|
331
|
+
JSON.stringify({
|
|
332
|
+
success: true,
|
|
333
|
+
summary: {
|
|
334
|
+
total_files: 1,
|
|
335
|
+
files_created: 1,
|
|
336
|
+
files_overwritten: 0,
|
|
337
|
+
files_skipped: 0,
|
|
338
|
+
backups_created: 0,
|
|
339
|
+
},
|
|
340
|
+
}),
|
|
341
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
return new Response("not found", { status: 404 });
|
|
345
|
+
});
|
|
234
346
|
}
|
|
235
347
|
|
|
236
348
|
// ---------------------------------------------------------------------------
|
|
@@ -254,7 +366,7 @@ describe("teleport arg parsing", () => {
|
|
|
254
366
|
);
|
|
255
367
|
});
|
|
256
368
|
|
|
257
|
-
test("missing --from and
|
|
369
|
+
test("missing --from and env flag prints help and exits 1", async () => {
|
|
258
370
|
setArgv();
|
|
259
371
|
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
260
372
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
@@ -262,7 +374,7 @@ describe("teleport arg parsing", () => {
|
|
|
262
374
|
);
|
|
263
375
|
});
|
|
264
376
|
|
|
265
|
-
test("missing
|
|
377
|
+
test("missing env flag prints help and exits 1", async () => {
|
|
266
378
|
setArgv("--from", "source");
|
|
267
379
|
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
268
380
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
@@ -270,126 +382,122 @@ describe("teleport arg parsing", () => {
|
|
|
270
382
|
);
|
|
271
383
|
});
|
|
272
384
|
|
|
273
|
-
test("
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
expect(
|
|
277
|
-
|
|
385
|
+
test("--local sets targetEnv to 'local' with no name", () => {
|
|
386
|
+
const result = parseArgs(["--from", "source", "--local"]);
|
|
387
|
+
expect(result.from).toBe("source");
|
|
388
|
+
expect(result.targetEnv).toBe("local");
|
|
389
|
+
expect(result.targetName).toBeUndefined();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("--docker my-name sets targetEnv to 'docker' and targetName to 'my-name'", () => {
|
|
393
|
+
const result = parseArgs(["--from", "source", "--docker", "my-name"]);
|
|
394
|
+
expect(result.from).toBe("source");
|
|
395
|
+
expect(result.targetEnv).toBe("docker");
|
|
396
|
+
expect(result.targetName).toBe("my-name");
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("--platform sets targetEnv to 'platform'", () => {
|
|
400
|
+
const result = parseArgs(["--from", "source", "--platform"]);
|
|
401
|
+
expect(result.from).toBe("source");
|
|
402
|
+
expect(result.targetEnv).toBe("platform");
|
|
403
|
+
expect(result.targetName).toBeUndefined();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("multiple env flags error", () => {
|
|
407
|
+
expect(() => parseArgs(["--from", "src", "--local", "--docker"])).toThrow(
|
|
408
|
+
"process.exit:1",
|
|
409
|
+
);
|
|
410
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
411
|
+
expect.stringContaining("Only one environment flag"),
|
|
278
412
|
);
|
|
279
413
|
});
|
|
280
414
|
|
|
281
|
-
test("--
|
|
282
|
-
|
|
415
|
+
test("--keep-source is parsed", () => {
|
|
416
|
+
const result = parseArgs(["--from", "source", "--docker", "--keep-source"]);
|
|
417
|
+
expect(result.keepSource).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("--dry-run is parsed", () => {
|
|
421
|
+
const result = parseArgs(["--from", "source", "--local", "--dry-run"]);
|
|
422
|
+
expect(result.dryRun).toBe(true);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("target name after env flag is consumed but --flags are not", () => {
|
|
426
|
+
const result = parseArgs(["--from", "source", "--docker", "--keep-source"]);
|
|
427
|
+
expect(result.targetEnv).toBe("docker");
|
|
428
|
+
expect(result.targetName).toBeUndefined();
|
|
429
|
+
expect(result.keepSource).toBe(true);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// Same-environment rejection tests
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
describe("same-environment rejection", () => {
|
|
438
|
+
test("source local, target local -> error (after resolving target)", async () => {
|
|
439
|
+
setArgv("--from", "src", "--local", "dst");
|
|
440
|
+
|
|
441
|
+
const srcEntry = makeEntry("src", { cloud: "local" });
|
|
442
|
+
const dstEntry = makeEntry("dst", { cloud: "local" });
|
|
283
443
|
|
|
284
444
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
285
|
-
if (name === "
|
|
286
|
-
if (name === "
|
|
445
|
+
if (name === "src") return srcEntry;
|
|
446
|
+
if (name === "dst") return dstEntry;
|
|
287
447
|
return null;
|
|
288
448
|
});
|
|
289
449
|
|
|
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
450
|
const originalFetch = globalThis.fetch;
|
|
294
|
-
|
|
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;
|
|
451
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
315
452
|
|
|
316
453
|
try {
|
|
317
|
-
await teleport();
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
454
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
455
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
456
|
+
expect.stringContaining("Cannot teleport between two local assistants"),
|
|
457
|
+
);
|
|
321
458
|
} finally {
|
|
322
459
|
globalThis.fetch = originalFetch;
|
|
323
460
|
}
|
|
324
461
|
});
|
|
325
462
|
|
|
326
|
-
test("
|
|
327
|
-
setArgv("--from", "
|
|
463
|
+
test("source docker, target docker -> error (after resolving target)", async () => {
|
|
464
|
+
setArgv("--from", "src", "--docker", "dst");
|
|
465
|
+
|
|
466
|
+
const srcEntry = makeEntry("src", { cloud: "docker" });
|
|
467
|
+
const dstEntry = makeEntry("dst", { cloud: "docker" });
|
|
328
468
|
|
|
329
469
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
330
|
-
if (name === "
|
|
331
|
-
if (name === "
|
|
470
|
+
if (name === "src") return srcEntry;
|
|
471
|
+
if (name === "dst") return dstEntry;
|
|
332
472
|
return null;
|
|
333
473
|
});
|
|
334
474
|
|
|
335
475
|
const originalFetch = globalThis.fetch;
|
|
336
|
-
|
|
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;
|
|
476
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
358
477
|
|
|
359
478
|
try {
|
|
360
|
-
await teleport();
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
479
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
480
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
481
|
+
expect.stringContaining(
|
|
482
|
+
"Cannot teleport between two docker assistants",
|
|
483
|
+
),
|
|
484
|
+
);
|
|
364
485
|
} finally {
|
|
365
486
|
globalThis.fetch = originalFetch;
|
|
366
487
|
}
|
|
367
488
|
});
|
|
368
489
|
|
|
369
|
-
test("
|
|
370
|
-
setArgv("--from", "
|
|
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");
|
|
490
|
+
test("source vellum, target platform -> error (after resolving target)", async () => {
|
|
491
|
+
setArgv("--from", "src", "--platform", "dst");
|
|
387
492
|
|
|
388
493
|
const srcEntry = makeEntry("src", {
|
|
389
494
|
cloud: "vellum",
|
|
390
495
|
runtimeUrl: "https://platform.vellum.ai",
|
|
391
496
|
});
|
|
392
|
-
const dstEntry = makeEntry("dst", {
|
|
497
|
+
const dstEntry = makeEntry("dst", {
|
|
498
|
+
cloud: "vellum",
|
|
499
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
500
|
+
});
|
|
393
501
|
|
|
394
502
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
395
503
|
if (name === "src") return srcEntry;
|
|
@@ -397,433 +505,496 @@ describe("teleport cloud resolution", () => {
|
|
|
397
505
|
return null;
|
|
398
506
|
});
|
|
399
507
|
|
|
400
|
-
// Platform export path: readPlatformToken → fetchOrganizationId → initiateExport → poll → download
|
|
401
|
-
// then local import
|
|
402
508
|
const originalFetch = globalThis.fetch;
|
|
403
|
-
|
|
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;
|
|
509
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
419
510
|
|
|
420
511
|
try {
|
|
421
|
-
await teleport();
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
512
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
513
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
514
|
+
expect.stringContaining(
|
|
515
|
+
"Cannot teleport between two platform assistants",
|
|
516
|
+
),
|
|
517
|
+
);
|
|
426
518
|
} finally {
|
|
427
519
|
globalThis.fetch = originalFetch;
|
|
428
520
|
}
|
|
429
521
|
});
|
|
430
522
|
|
|
431
|
-
test("
|
|
432
|
-
setArgv("--from", "
|
|
523
|
+
test("same-env rejection happens before hatching (no orphaned assistants)", async () => {
|
|
524
|
+
setArgv("--from", "my-local", "--local");
|
|
433
525
|
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
526
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
527
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
528
|
+
if (name === "my-local") return localEntry;
|
|
529
|
+
return null;
|
|
438
530
|
});
|
|
439
531
|
|
|
532
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
533
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
534
|
+
expect.stringContaining("Cannot teleport between two local assistants"),
|
|
535
|
+
);
|
|
536
|
+
// Crucially: no hatch should have been called — the early guard fires first
|
|
537
|
+
expect(hatchLocalMock).not.toHaveBeenCalled();
|
|
538
|
+
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
539
|
+
expect(hatchAssistantMock).not.toHaveBeenCalled();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("same-env rejection before hatching for docker", async () => {
|
|
543
|
+
setArgv("--from", "my-docker", "--docker");
|
|
544
|
+
|
|
545
|
+
const dockerEntry = makeEntry("my-docker", { cloud: "docker" });
|
|
440
546
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
441
|
-
if (name === "
|
|
442
|
-
if (name === "dst") return dstEntry;
|
|
547
|
+
if (name === "my-docker") return dockerEntry;
|
|
443
548
|
return null;
|
|
444
549
|
});
|
|
445
550
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
551
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
552
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
553
|
+
expect.stringContaining("Cannot teleport between two docker assistants"),
|
|
554
|
+
);
|
|
555
|
+
expect(hatchLocalMock).not.toHaveBeenCalled();
|
|
556
|
+
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
557
|
+
expect(hatchAssistantMock).not.toHaveBeenCalled();
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("same-env rejection before hatching for platform (vellum cloud)", async () => {
|
|
561
|
+
setArgv("--from", "my-cloud", "--platform");
|
|
562
|
+
|
|
563
|
+
const platformEntry = makeEntry("my-cloud", {
|
|
564
|
+
cloud: "vellum",
|
|
565
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
566
|
+
});
|
|
567
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
568
|
+
if (name === "my-cloud") return platformEntry;
|
|
569
|
+
return null;
|
|
449
570
|
});
|
|
450
|
-
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
451
571
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
}
|
|
572
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
573
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
574
|
+
expect.stringContaining(
|
|
575
|
+
"Cannot teleport between two platform assistants",
|
|
576
|
+
),
|
|
577
|
+
);
|
|
578
|
+
expect(hatchLocalMock).not.toHaveBeenCalled();
|
|
579
|
+
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
580
|
+
expect(hatchAssistantMock).not.toHaveBeenCalled();
|
|
462
581
|
});
|
|
463
582
|
|
|
464
|
-
test("
|
|
465
|
-
|
|
583
|
+
test("flag says docker but resolved target is local -> rejects cloud mismatch", async () => {
|
|
584
|
+
// User passes --docker but the named target is actually a local assistant
|
|
585
|
+
setArgv("--from", "src", "--docker", "misidentified");
|
|
466
586
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
project: "my-gcp-project",
|
|
471
|
-
});
|
|
472
|
-
const dstEntry = makeEntry("dst", { cloud: "local" });
|
|
587
|
+
const srcEntry = makeEntry("src", { cloud: "vellum" });
|
|
588
|
+
// Target is actually local despite the --docker flag
|
|
589
|
+
const dstEntry = makeEntry("misidentified", { cloud: "local" });
|
|
473
590
|
|
|
474
591
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
475
592
|
if (name === "src") return srcEntry;
|
|
476
|
-
if (name === "
|
|
593
|
+
if (name === "misidentified") return dstEntry;
|
|
477
594
|
return null;
|
|
478
595
|
});
|
|
479
596
|
|
|
480
|
-
// GCP is unsupported, should print error and exit
|
|
481
597
|
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
482
598
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
483
|
-
expect.stringContaining("
|
|
599
|
+
expect.stringContaining("is a local assistant, not docker"),
|
|
484
600
|
);
|
|
485
601
|
});
|
|
602
|
+
});
|
|
486
603
|
|
|
487
|
-
|
|
488
|
-
|
|
604
|
+
// ---------------------------------------------------------------------------
|
|
605
|
+
// resolveOrHatchTarget tests
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
489
607
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
const
|
|
608
|
+
describe("resolveOrHatchTarget", () => {
|
|
609
|
+
test("existing assistant is returned without hatching", async () => {
|
|
610
|
+
const dockerEntry = makeEntry("my-docker", { cloud: "docker" });
|
|
611
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
612
|
+
if (name === "my-docker") return dockerEntry;
|
|
613
|
+
return null;
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
const result = await resolveOrHatchTarget("docker", "my-docker");
|
|
617
|
+
expect(result).toBe(dockerEntry);
|
|
618
|
+
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
619
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
620
|
+
expect.stringContaining("Target: my-docker (docker)"),
|
|
621
|
+
);
|
|
622
|
+
});
|
|
493
623
|
|
|
624
|
+
test("name not found -> hatch docker", async () => {
|
|
625
|
+
const newEntry = makeEntry("new-one", { cloud: "docker" });
|
|
494
626
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
495
|
-
|
|
496
|
-
|
|
627
|
+
// First call: lookup by name -> not found
|
|
628
|
+
// Second call: after hatch -> found
|
|
629
|
+
if (name === "new-one" && hatchDockerMock.mock.calls.length > 0) {
|
|
630
|
+
return newEntry;
|
|
631
|
+
}
|
|
497
632
|
return null;
|
|
498
633
|
});
|
|
499
634
|
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
635
|
+
const result = await resolveOrHatchTarget("docker", "new-one");
|
|
636
|
+
expect(hatchDockerMock).toHaveBeenCalledWith(
|
|
637
|
+
"vellum",
|
|
638
|
+
false,
|
|
639
|
+
"new-one",
|
|
640
|
+
false,
|
|
641
|
+
{},
|
|
642
|
+
);
|
|
643
|
+
expect(result).toBe(newEntry);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("no name -> hatch local with null name, discovers via diff", async () => {
|
|
647
|
+
const existingEntry = makeEntry("existing-local", { cloud: "local" });
|
|
648
|
+
const newEntry = makeEntry("auto-generated", { cloud: "local" });
|
|
649
|
+
|
|
650
|
+
// Before hatch: only the existing entry
|
|
651
|
+
// After hatch: existing + new entry
|
|
652
|
+
loadAllAssistantsMock.mockImplementation(() => {
|
|
653
|
+
if (hatchLocalMock.mock.calls.length > 0) {
|
|
654
|
+
return [existingEntry, newEntry];
|
|
505
655
|
}
|
|
506
|
-
|
|
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
|
-
);
|
|
656
|
+
return [existingEntry];
|
|
520
657
|
});
|
|
521
|
-
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
522
658
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
659
|
+
const result = await resolveOrHatchTarget("local");
|
|
660
|
+
expect(hatchLocalMock).toHaveBeenCalledWith(
|
|
661
|
+
"vellum",
|
|
662
|
+
null,
|
|
663
|
+
false,
|
|
664
|
+
false,
|
|
665
|
+
false,
|
|
666
|
+
{},
|
|
667
|
+
);
|
|
668
|
+
expect(result).toBe(newEntry);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("platform with existing ID -> returns existing without hatching", async () => {
|
|
672
|
+
const platformEntry = makeEntry("uuid-123", {
|
|
673
|
+
cloud: "vellum",
|
|
674
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
675
|
+
});
|
|
676
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
677
|
+
if (name === "uuid-123") return platformEntry;
|
|
678
|
+
return null;
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
const result = await resolveOrHatchTarget("platform", "uuid-123");
|
|
682
|
+
expect(result).toBe(platformEntry);
|
|
683
|
+
expect(hatchAssistantMock).not.toHaveBeenCalled();
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("platform with unknown name -> hatches via hatchAssistant", async () => {
|
|
687
|
+
findAssistantByNameMock.mockReturnValue(null);
|
|
688
|
+
|
|
689
|
+
const result = await resolveOrHatchTarget("platform", "nonexistent");
|
|
690
|
+
expect(hatchAssistantMock).toHaveBeenCalledWith("platform-token");
|
|
691
|
+
expect(saveAssistantEntryMock).toHaveBeenCalledWith(
|
|
692
|
+
expect.objectContaining({
|
|
693
|
+
assistantId: "platform-new-id",
|
|
694
|
+
cloud: "vellum",
|
|
695
|
+
}),
|
|
696
|
+
);
|
|
697
|
+
expect(result.assistantId).toBe("platform-new-id");
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test("existing assistant with wrong cloud -> rejects", async () => {
|
|
701
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
702
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
703
|
+
if (name === "my-local") return localEntry;
|
|
704
|
+
return null;
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
await expect(resolveOrHatchTarget("docker", "my-local")).rejects.toThrow(
|
|
708
|
+
"process.exit:1",
|
|
709
|
+
);
|
|
710
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
711
|
+
expect.stringContaining("is a local assistant, not docker"),
|
|
712
|
+
);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
test("name with path traversal -> rejects before hatching", async () => {
|
|
716
|
+
findAssistantByNameMock.mockReturnValue(null);
|
|
717
|
+
|
|
718
|
+
await expect(
|
|
719
|
+
resolveOrHatchTarget("docker", "../../../etc/passwd"),
|
|
720
|
+
).rejects.toThrow("process.exit:1");
|
|
721
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
722
|
+
expect.stringContaining("invalid characters"),
|
|
723
|
+
);
|
|
724
|
+
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
531
725
|
});
|
|
532
726
|
});
|
|
533
727
|
|
|
534
728
|
// ---------------------------------------------------------------------------
|
|
535
|
-
//
|
|
729
|
+
// Auto-retire tests
|
|
536
730
|
// ---------------------------------------------------------------------------
|
|
537
731
|
|
|
538
|
-
describe("
|
|
539
|
-
test("local
|
|
540
|
-
setArgv("--from", "local
|
|
732
|
+
describe("auto-retire", () => {
|
|
733
|
+
test("local -> docker: stops source before hatch, retires after import", async () => {
|
|
734
|
+
setArgv("--from", "my-local", "--docker");
|
|
735
|
+
|
|
736
|
+
const localEntry = makeEntry("my-local", {
|
|
737
|
+
cloud: "local",
|
|
738
|
+
resources: {
|
|
739
|
+
instanceDir: "/home/test",
|
|
740
|
+
pidFile: "/home/test/.vellum/assistant.pid",
|
|
741
|
+
signingKey: "key",
|
|
742
|
+
daemonPort: 7821,
|
|
743
|
+
gatewayPort: 7830,
|
|
744
|
+
qdrantPort: 6333,
|
|
745
|
+
cesPort: 8090,
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
const dockerEntry = makeEntry("new-docker", { cloud: "docker" });
|
|
541
749
|
|
|
542
750
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
543
|
-
if (name === "local
|
|
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
|
-
});
|
|
751
|
+
if (name === "my-local") return localEntry;
|
|
550
752
|
return null;
|
|
551
753
|
});
|
|
552
754
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
755
|
+
// Simulate hatch creating a new docker entry
|
|
756
|
+
loadAllAssistantsMock.mockImplementation(() => {
|
|
757
|
+
if (hatchDockerMock.mock.calls.length > 0) {
|
|
758
|
+
return [localEntry, dockerEntry];
|
|
759
|
+
}
|
|
760
|
+
return [localEntry];
|
|
556
761
|
});
|
|
557
|
-
|
|
762
|
+
|
|
763
|
+
const originalFetch = globalThis.fetch;
|
|
764
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
558
765
|
|
|
559
766
|
try {
|
|
560
767
|
await teleport();
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
expect(
|
|
565
|
-
|
|
566
|
-
// Platform import: should call platformImportBundle
|
|
567
|
-
expect(platformImportBundleMock).toHaveBeenCalled();
|
|
568
|
-
|
|
569
|
-
// Should NOT call platform export functions
|
|
570
|
-
expect(platformInitiateExportMock).not.toHaveBeenCalled();
|
|
768
|
+
// Source should be stopped (slept) before hatch
|
|
769
|
+
expect(stopProcessByPidFileMock).toHaveBeenCalled();
|
|
770
|
+
// Retire happens after successful import
|
|
771
|
+
expect(retireLocalMock).toHaveBeenCalledWith("my-local", localEntry);
|
|
772
|
+
expect(removeAssistantEntryMock).toHaveBeenCalledWith("my-local");
|
|
571
773
|
} finally {
|
|
572
774
|
globalThis.fetch = originalFetch;
|
|
573
775
|
}
|
|
574
776
|
});
|
|
575
777
|
|
|
576
|
-
test("
|
|
577
|
-
setArgv("--from", "
|
|
778
|
+
test("docker -> local: sleeps containers before hatch, retires after import", async () => {
|
|
779
|
+
setArgv("--from", "my-docker", "--local");
|
|
780
|
+
|
|
781
|
+
const dockerEntry = makeEntry("my-docker", { cloud: "docker" });
|
|
782
|
+
const localEntry = makeEntry("new-local", { cloud: "local" });
|
|
578
783
|
|
|
579
784
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
580
|
-
if (name === "
|
|
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" });
|
|
785
|
+
if (name === "my-docker") return dockerEntry;
|
|
587
786
|
return null;
|
|
588
787
|
});
|
|
589
788
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
);
|
|
789
|
+
loadAllAssistantsMock.mockImplementation(() => {
|
|
790
|
+
if (hatchLocalMock.mock.calls.length > 0) {
|
|
791
|
+
return [dockerEntry, localEntry];
|
|
792
|
+
}
|
|
793
|
+
return [dockerEntry];
|
|
605
794
|
});
|
|
606
|
-
|
|
795
|
+
|
|
796
|
+
const originalFetch = globalThis.fetch;
|
|
797
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
607
798
|
|
|
608
799
|
try {
|
|
609
800
|
await teleport();
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
expect(
|
|
614
|
-
expect(
|
|
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();
|
|
801
|
+
// Docker source should be slept (containers stopped) before hatch
|
|
802
|
+
expect(sleepContainersMock).toHaveBeenCalled();
|
|
803
|
+
// Retire happens after successful import
|
|
804
|
+
expect(retireDockerMock).toHaveBeenCalledWith("my-docker");
|
|
805
|
+
expect(removeAssistantEntryMock).toHaveBeenCalledWith("my-docker");
|
|
625
806
|
} finally {
|
|
626
807
|
globalThis.fetch = originalFetch;
|
|
627
808
|
}
|
|
628
809
|
});
|
|
629
810
|
|
|
630
|
-
test("--
|
|
631
|
-
setArgv("--from", "
|
|
811
|
+
test("--keep-source skips retire and removeAssistantEntry", async () => {
|
|
812
|
+
setArgv("--from", "my-local", "--docker", "--keep-source");
|
|
813
|
+
|
|
814
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
815
|
+
const dockerEntry = makeEntry("new-docker", { cloud: "docker" });
|
|
632
816
|
|
|
633
817
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
634
|
-
if (name === "
|
|
635
|
-
if (name === "dst") return makeEntry("dst", { cloud: "local" });
|
|
818
|
+
if (name === "my-local") return localEntry;
|
|
636
819
|
return null;
|
|
637
820
|
});
|
|
638
821
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
);
|
|
822
|
+
loadAllAssistantsMock.mockImplementation(() => {
|
|
823
|
+
if (hatchDockerMock.mock.calls.length > 0) {
|
|
824
|
+
return [localEntry, dockerEntry];
|
|
658
825
|
}
|
|
659
|
-
return
|
|
826
|
+
return [localEntry];
|
|
660
827
|
});
|
|
661
|
-
|
|
828
|
+
|
|
829
|
+
const originalFetch = globalThis.fetch;
|
|
830
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
662
831
|
|
|
663
832
|
try {
|
|
664
833
|
await teleport();
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
expect(
|
|
669
|
-
|
|
670
|
-
// Should NOT call the actual import endpoint
|
|
671
|
-
const importCalls = filterFetchCalls(fetchMock, "/import").filter(
|
|
672
|
-
(call) => !extractUrl(call[0]).includes("preflight"),
|
|
834
|
+
expect(retireLocalMock).not.toHaveBeenCalled();
|
|
835
|
+
expect(retireDockerMock).not.toHaveBeenCalled();
|
|
836
|
+
expect(removeAssistantEntryMock).not.toHaveBeenCalled();
|
|
837
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
838
|
+
expect.stringContaining("kept (--keep-source)"),
|
|
673
839
|
);
|
|
674
|
-
expect(importCalls.length).toBe(0);
|
|
675
840
|
} finally {
|
|
676
841
|
globalThis.fetch = originalFetch;
|
|
677
842
|
}
|
|
678
843
|
});
|
|
679
844
|
|
|
680
|
-
test("
|
|
681
|
-
setArgv("--from", "
|
|
845
|
+
test("platform transfers skip retire", async () => {
|
|
846
|
+
setArgv("--from", "my-local", "--platform");
|
|
847
|
+
|
|
848
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
682
849
|
|
|
683
850
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
684
|
-
if (name === "
|
|
685
|
-
if (name === "dst")
|
|
686
|
-
return makeEntry("dst", {
|
|
687
|
-
cloud: "vellum",
|
|
688
|
-
runtimeUrl: "https://platform.vellum.ai",
|
|
689
|
-
});
|
|
851
|
+
if (name === "my-local") return localEntry;
|
|
690
852
|
return null;
|
|
691
853
|
});
|
|
692
854
|
|
|
693
855
|
const originalFetch = globalThis.fetch;
|
|
694
|
-
|
|
695
|
-
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
696
|
-
});
|
|
697
|
-
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
856
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
698
857
|
|
|
699
858
|
try {
|
|
700
859
|
await teleport();
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
expect(
|
|
704
|
-
|
|
705
|
-
// Should NOT call platformImportBundle
|
|
706
|
-
expect(platformImportBundleMock).not.toHaveBeenCalled();
|
|
860
|
+
expect(retireLocalMock).not.toHaveBeenCalled();
|
|
861
|
+
expect(retireDockerMock).not.toHaveBeenCalled();
|
|
862
|
+
expect(removeAssistantEntryMock).not.toHaveBeenCalled();
|
|
707
863
|
} finally {
|
|
708
864
|
globalThis.fetch = originalFetch;
|
|
709
865
|
}
|
|
710
866
|
});
|
|
711
867
|
|
|
712
|
-
test("
|
|
713
|
-
setArgv("--from", "
|
|
868
|
+
test("dry-run without existing target does not hatch or export", async () => {
|
|
869
|
+
setArgv("--from", "my-local", "--docker", "--dry-run");
|
|
870
|
+
|
|
871
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
714
872
|
|
|
715
873
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
716
|
-
if (name === "
|
|
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
|
-
});
|
|
874
|
+
if (name === "my-local") return localEntry;
|
|
726
875
|
return null;
|
|
727
876
|
});
|
|
728
877
|
|
|
729
878
|
await teleport();
|
|
730
879
|
|
|
731
|
-
//
|
|
732
|
-
expect(
|
|
733
|
-
expect(
|
|
734
|
-
expect(
|
|
880
|
+
// Should NOT hatch, export, import, or retire
|
|
881
|
+
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
882
|
+
expect(hatchLocalMock).not.toHaveBeenCalled();
|
|
883
|
+
expect(hatchAssistantMock).not.toHaveBeenCalled();
|
|
884
|
+
expect(retireLocalMock).not.toHaveBeenCalled();
|
|
885
|
+
expect(retireDockerMock).not.toHaveBeenCalled();
|
|
886
|
+
expect(removeAssistantEntryMock).not.toHaveBeenCalled();
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
test("dry-run with existing target runs preflight without hatching", async () => {
|
|
890
|
+
setArgv("--from", "my-local", "--docker", "my-docker", "--dry-run");
|
|
891
|
+
|
|
892
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
893
|
+
const dockerEntry = makeEntry("my-docker", { cloud: "docker" });
|
|
894
|
+
|
|
895
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
896
|
+
if (name === "my-local") return localEntry;
|
|
897
|
+
if (name === "my-docker") return dockerEntry;
|
|
898
|
+
return null;
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
const originalFetch = globalThis.fetch;
|
|
902
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
903
|
+
|
|
904
|
+
try {
|
|
905
|
+
await teleport();
|
|
735
906
|
|
|
736
|
-
|
|
737
|
-
|
|
907
|
+
// Should NOT hatch or retire
|
|
908
|
+
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
909
|
+
expect(hatchLocalMock).not.toHaveBeenCalled();
|
|
910
|
+
expect(retireLocalMock).not.toHaveBeenCalled();
|
|
911
|
+
expect(retireDockerMock).not.toHaveBeenCalled();
|
|
912
|
+
expect(removeAssistantEntryMock).not.toHaveBeenCalled();
|
|
913
|
+
} finally {
|
|
914
|
+
globalThis.fetch = originalFetch;
|
|
915
|
+
}
|
|
738
916
|
});
|
|
739
917
|
});
|
|
740
918
|
|
|
741
919
|
// ---------------------------------------------------------------------------
|
|
742
|
-
//
|
|
920
|
+
// Full flow tests
|
|
743
921
|
// ---------------------------------------------------------------------------
|
|
744
922
|
|
|
745
|
-
describe("teleport
|
|
746
|
-
test("
|
|
747
|
-
setArgv(
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
"dst",
|
|
752
|
-
"--bogus-flag",
|
|
753
|
-
"--another-unknown",
|
|
754
|
-
);
|
|
923
|
+
describe("teleport full flow", () => {
|
|
924
|
+
test("hatch and import: --from my-local --docker", async () => {
|
|
925
|
+
setArgv("--from", "my-local", "--docker");
|
|
926
|
+
|
|
927
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
928
|
+
const dockerEntry = makeEntry("new-docker", { cloud: "docker" });
|
|
755
929
|
|
|
756
930
|
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
757
|
-
if (name === "
|
|
758
|
-
if (name === "dst") return makeEntry("dst", { cloud: "local" });
|
|
931
|
+
if (name === "my-local") return localEntry;
|
|
759
932
|
return null;
|
|
760
933
|
});
|
|
761
934
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
if (urlStr.includes("/export")) {
|
|
766
|
-
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
935
|
+
loadAllAssistantsMock.mockImplementation(() => {
|
|
936
|
+
if (hatchDockerMock.mock.calls.length > 0) {
|
|
937
|
+
return [localEntry, dockerEntry];
|
|
767
938
|
}
|
|
768
|
-
return
|
|
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
|
-
);
|
|
939
|
+
return [localEntry];
|
|
781
940
|
});
|
|
941
|
+
|
|
942
|
+
const originalFetch = globalThis.fetch;
|
|
943
|
+
const fetchMock = createFetchMock();
|
|
782
944
|
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
783
945
|
|
|
784
946
|
try {
|
|
785
947
|
await teleport();
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
expect(
|
|
789
|
-
|
|
790
|
-
expect(
|
|
948
|
+
|
|
949
|
+
// Verify sequence: export, hatch, import, retire
|
|
950
|
+
expect(hatchDockerMock).toHaveBeenCalled();
|
|
951
|
+
expect(retireLocalMock).toHaveBeenCalledWith("my-local", localEntry);
|
|
952
|
+
expect(removeAssistantEntryMock).toHaveBeenCalledWith("my-local");
|
|
953
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
954
|
+
expect.stringContaining("Teleport complete"),
|
|
955
|
+
);
|
|
791
956
|
} finally {
|
|
792
957
|
globalThis.fetch = originalFetch;
|
|
793
958
|
}
|
|
794
959
|
});
|
|
795
|
-
});
|
|
796
960
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
// ---------------------------------------------------------------------------
|
|
961
|
+
test("existing target overwrite: --from my-local --docker my-existing", async () => {
|
|
962
|
+
setArgv("--from", "my-local", "--docker", "my-existing");
|
|
800
963
|
|
|
801
|
-
|
|
802
|
-
|
|
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
|
-
});
|
|
964
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
965
|
+
const dockerEntry = makeEntry("my-existing", { cloud: "docker" });
|
|
811
966
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
967
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
968
|
+
if (name === "my-local") return localEntry;
|
|
969
|
+
if (name === "my-existing") return dockerEntry;
|
|
970
|
+
return null;
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
const originalFetch = globalThis.fetch;
|
|
974
|
+
const fetchMock = createFetchMock();
|
|
975
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
976
|
+
|
|
977
|
+
try {
|
|
978
|
+
await teleport();
|
|
979
|
+
|
|
980
|
+
// No hatch should happen — existing target is used
|
|
981
|
+
expect(hatchDockerMock).not.toHaveBeenCalled();
|
|
982
|
+
// Source should still be retired
|
|
983
|
+
expect(retireLocalMock).toHaveBeenCalledWith("my-local", localEntry);
|
|
984
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
985
|
+
expect.stringContaining("Teleport complete"),
|
|
986
|
+
);
|
|
987
|
+
} finally {
|
|
988
|
+
globalThis.fetch = originalFetch;
|
|
989
|
+
}
|
|
818
990
|
});
|
|
819
991
|
|
|
820
|
-
test("
|
|
821
|
-
setArgv("--from", "--to", "target");
|
|
822
|
-
|
|
823
|
-
// --to then correctly consumes "target". from is undefined → prints help and exits 1.
|
|
992
|
+
test("legacy --to flag shows deprecation message", async () => {
|
|
993
|
+
setArgv("--from", "source", "--to", "target");
|
|
994
|
+
|
|
824
995
|
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
825
|
-
expect(
|
|
826
|
-
expect.stringContaining("
|
|
996
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
997
|
+
expect.stringContaining("--to is deprecated"),
|
|
827
998
|
);
|
|
828
999
|
});
|
|
829
1000
|
});
|