@vellumai/cli 0.10.3 → 0.10.4-dev.202607010028.1a4efcc
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/node_modules/@vellumai/local-mode/src/__tests__/retire.test.ts +79 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +12 -3
- package/node_modules/@vellumai/local-mode/src/retire.ts +27 -5
- package/package.json +1 -1
- package/src/__tests__/flags.test.ts +10 -10
- package/src/__tests__/recover.test.ts +34 -27
- package/src/__tests__/retire.test.ts +292 -0
- package/src/__tests__/teleport.test.ts +42 -17
- package/src/commands/flags.ts +1 -1
- package/src/commands/retire.ts +145 -24
- package/src/commands/teleport.ts +42 -16
- package/src/lib/local-runtime-client.ts +38 -0
- package/src/lib/platform-client.ts +14 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
|
|
4
|
+
import type { CliInvocation } from "../util";
|
|
5
|
+
|
|
6
|
+
class FakeChild extends EventEmitter {
|
|
7
|
+
stdout = new EventEmitter();
|
|
8
|
+
stderr = new EventEmitter();
|
|
9
|
+
kill = mock(() => true);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let lastChild: FakeChild;
|
|
13
|
+
const spawnArgs: Array<
|
|
14
|
+
[string, string[], { env?: NodeJS.ProcessEnv; stdio?: unknown }]
|
|
15
|
+
> = [];
|
|
16
|
+
const spawnMock = mock(
|
|
17
|
+
(
|
|
18
|
+
command: string,
|
|
19
|
+
args: string[],
|
|
20
|
+
options: { env?: NodeJS.ProcessEnv; stdio?: unknown },
|
|
21
|
+
) => {
|
|
22
|
+
spawnArgs.push([command, args, options]);
|
|
23
|
+
lastChild = new FakeChild();
|
|
24
|
+
return lastChild;
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
mock.module("node:child_process", () => ({ spawn: spawnMock }));
|
|
29
|
+
|
|
30
|
+
let runRetire: typeof import("../retire").runRetire;
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
({ runRetire } = await import("../retire"));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
spawnArgs.length = 0;
|
|
38
|
+
spawnMock.mockClear();
|
|
39
|
+
delete process.env.VELLUM_PLATFORM_TOKEN;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const invocation: CliInvocation = { command: "bun", baseArgs: ["run", "cli"] };
|
|
43
|
+
|
|
44
|
+
describe("runRetire", () => {
|
|
45
|
+
test("spawns the CLI retire command", async () => {
|
|
46
|
+
const pending = runRetire(invocation, "asst-42");
|
|
47
|
+
lastChild.emit("close", 0);
|
|
48
|
+
|
|
49
|
+
expect(await pending).toEqual({ ok: true });
|
|
50
|
+
expect(spawnArgs[0]).toEqual([
|
|
51
|
+
"bun",
|
|
52
|
+
["run", "cli", "retire", "asst-42", "--yes"],
|
|
53
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("passes a host platform token to the CLI subprocess", async () => {
|
|
58
|
+
const pending = runRetire(invocation, "asst-42", {
|
|
59
|
+
platformToken: "session-token",
|
|
60
|
+
});
|
|
61
|
+
lastChild.emit("close", 0);
|
|
62
|
+
|
|
63
|
+
expect(await pending).toEqual({ ok: true });
|
|
64
|
+
expect(spawnArgs[0]?.[2].env?.VELLUM_PLATFORM_TOKEN).toBe("session-token");
|
|
65
|
+
expect(process.env.VELLUM_PLATFORM_TOKEN).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("a non-zero exit resolves to a failure carrying the CLI output", async () => {
|
|
69
|
+
const pending = runRetire(invocation, "asst-42");
|
|
70
|
+
lastChild.stderr.emit("data", Buffer.from("retire failed"));
|
|
71
|
+
lastChild.emit("close", 1);
|
|
72
|
+
|
|
73
|
+
expect(await pending).toEqual({
|
|
74
|
+
ok: false,
|
|
75
|
+
status: 500,
|
|
76
|
+
error: "retire failed",
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -14,9 +14,18 @@ export {
|
|
|
14
14
|
resolveDevCliInvocation,
|
|
15
15
|
} from "./util";
|
|
16
16
|
export type { CliInvocation } from "./util";
|
|
17
|
-
export {
|
|
17
|
+
export {
|
|
18
|
+
resolveLocalConfigFromEnv,
|
|
19
|
+
resolveLockfilePaths,
|
|
20
|
+
resolveConfigDir,
|
|
21
|
+
guardianTokenPath,
|
|
22
|
+
} from "./config";
|
|
18
23
|
export type { LocalEndpointConfig } from "./config";
|
|
19
|
-
export {
|
|
24
|
+
export {
|
|
25
|
+
defaultEnvironmentFilePath,
|
|
26
|
+
readDefaultEnvironment,
|
|
27
|
+
resolveEnvironmentName,
|
|
28
|
+
} from "./environment";
|
|
20
29
|
export {
|
|
21
30
|
getLockfileData,
|
|
22
31
|
upsertLockfileAssistant,
|
|
@@ -34,7 +43,7 @@ export type {
|
|
|
34
43
|
export { runHatch } from "./hatch";
|
|
35
44
|
export type { HatchResult } from "./hatch";
|
|
36
45
|
export { runRetire } from "./retire";
|
|
37
|
-
export type { RetireResult } from "./retire";
|
|
46
|
+
export type { RetireOptions, RetireResult } from "./retire";
|
|
38
47
|
export { runSleep } from "./sleep";
|
|
39
48
|
export type { SleepResult } from "./sleep";
|
|
40
49
|
export { runWake } from "./wake";
|
|
@@ -5,18 +5,32 @@ import type { CliInvocation } from "./util";
|
|
|
5
5
|
const RETIRE_TIMEOUT_MS = 60_000;
|
|
6
6
|
|
|
7
7
|
export type RetireResult =
|
|
8
|
-
| { ok:
|
|
9
|
-
|
|
8
|
+
{ ok: true } | { ok: false; status: number; error: string };
|
|
9
|
+
|
|
10
|
+
export interface RetireOptions {
|
|
11
|
+
platformToken?: string;
|
|
12
|
+
}
|
|
10
13
|
|
|
11
14
|
export function runRetire(
|
|
12
15
|
invocation: CliInvocation,
|
|
13
16
|
assistantId: string,
|
|
17
|
+
options: RetireOptions = {},
|
|
14
18
|
): Promise<RetireResult> {
|
|
15
19
|
return new Promise((resolve) => {
|
|
16
20
|
const child = spawn(
|
|
17
21
|
invocation.command,
|
|
18
22
|
[...invocation.baseArgs, "retire", assistantId, "--yes"],
|
|
19
|
-
{
|
|
23
|
+
{
|
|
24
|
+
...(options.platformToken
|
|
25
|
+
? {
|
|
26
|
+
env: {
|
|
27
|
+
...process.env,
|
|
28
|
+
VELLUM_PLATFORM_TOKEN: options.platformToken,
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
: {}),
|
|
32
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
33
|
+
},
|
|
20
34
|
);
|
|
21
35
|
|
|
22
36
|
let stdout = "";
|
|
@@ -32,7 +46,11 @@ export function runRetire(
|
|
|
32
46
|
|
|
33
47
|
const timeout = setTimeout(() => {
|
|
34
48
|
child.kill("SIGTERM");
|
|
35
|
-
finish({
|
|
49
|
+
finish({
|
|
50
|
+
ok: false,
|
|
51
|
+
status: 500,
|
|
52
|
+
error: "Retire timed out after 60 seconds",
|
|
53
|
+
});
|
|
36
54
|
}, RETIRE_TIMEOUT_MS);
|
|
37
55
|
|
|
38
56
|
child.stdout.on("data", (data: Buffer) => {
|
|
@@ -52,7 +70,11 @@ export function runRetire(
|
|
|
52
70
|
});
|
|
53
71
|
|
|
54
72
|
child.on("error", (err) => {
|
|
55
|
-
finish({
|
|
73
|
+
finish({
|
|
74
|
+
ok: false,
|
|
75
|
+
status: 500,
|
|
76
|
+
error: `Failed to spawn CLI: ${err.message}`,
|
|
77
|
+
});
|
|
56
78
|
});
|
|
57
79
|
});
|
|
58
80
|
}
|
package/package.json
CHANGED
|
@@ -83,10 +83,10 @@ describe("vellum flags --assistant routing", () => {
|
|
|
83
83
|
fetchCalls.push({ url, method });
|
|
84
84
|
if (method === "PATCH") {
|
|
85
85
|
return jsonResponse({
|
|
86
|
-
key: "
|
|
86
|
+
key: "voice-mode",
|
|
87
87
|
enabled: true,
|
|
88
88
|
defaultEnabled: false,
|
|
89
|
-
label: "
|
|
89
|
+
label: "Voice Mode",
|
|
90
90
|
description: "test",
|
|
91
91
|
});
|
|
92
92
|
}
|
|
@@ -133,7 +133,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
133
133
|
"vellum",
|
|
134
134
|
"flags",
|
|
135
135
|
"set",
|
|
136
|
-
"
|
|
136
|
+
"voice-mode",
|
|
137
137
|
"true",
|
|
138
138
|
"--assistant",
|
|
139
139
|
"Bob",
|
|
@@ -146,7 +146,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
146
146
|
// bob-2 has assistantId.length === 5, so port = 7800 + 5 = 7805.
|
|
147
147
|
expect(fetchCalls[0].url).toContain("http://127.0.0.1:7805");
|
|
148
148
|
expect(fetchCalls[0].url).toContain(
|
|
149
|
-
"/v1/assistants/bob-2/feature-flags/
|
|
149
|
+
"/v1/assistants/bob-2/feature-flags/voice-mode",
|
|
150
150
|
);
|
|
151
151
|
});
|
|
152
152
|
|
|
@@ -166,7 +166,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
166
166
|
"--assistant",
|
|
167
167
|
"Bob",
|
|
168
168
|
"set",
|
|
169
|
-
"
|
|
169
|
+
"voice-mode",
|
|
170
170
|
"true",
|
|
171
171
|
];
|
|
172
172
|
|
|
@@ -174,7 +174,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
174
174
|
|
|
175
175
|
expect(fetchCalls.length).toBe(1);
|
|
176
176
|
expect(fetchCalls[0].url).toContain(
|
|
177
|
-
"/v1/assistants/bob-2/feature-flags/
|
|
177
|
+
"/v1/assistants/bob-2/feature-flags/voice-mode",
|
|
178
178
|
);
|
|
179
179
|
});
|
|
180
180
|
|
|
@@ -193,7 +193,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
193
193
|
"vellum",
|
|
194
194
|
"flags",
|
|
195
195
|
"set",
|
|
196
|
-
"
|
|
196
|
+
"voice-mode",
|
|
197
197
|
"true",
|
|
198
198
|
];
|
|
199
199
|
|
|
@@ -203,7 +203,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
203
203
|
// alice-1 has assistantId.length === 7, so port = 7800 + 7 = 7807.
|
|
204
204
|
expect(fetchCalls[0].url).toContain("http://127.0.0.1:7807");
|
|
205
205
|
expect(fetchCalls[0].url).toContain(
|
|
206
|
-
"/v1/assistants/alice-1/feature-flags/
|
|
206
|
+
"/v1/assistants/alice-1/feature-flags/voice-mode",
|
|
207
207
|
);
|
|
208
208
|
});
|
|
209
209
|
|
|
@@ -214,7 +214,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
214
214
|
"vellum",
|
|
215
215
|
"flags",
|
|
216
216
|
"set",
|
|
217
|
-
"
|
|
217
|
+
"voice-mode",
|
|
218
218
|
"true",
|
|
219
219
|
"--assistant",
|
|
220
220
|
"Ghost",
|
|
@@ -234,7 +234,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
234
234
|
"vellum",
|
|
235
235
|
"flags",
|
|
236
236
|
"set",
|
|
237
|
-
"
|
|
237
|
+
"voice-mode",
|
|
238
238
|
"true",
|
|
239
239
|
"--assistant",
|
|
240
240
|
];
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
existsSync,
|
|
14
14
|
mkdirSync,
|
|
15
15
|
mkdtempSync,
|
|
16
|
+
renameSync,
|
|
16
17
|
rmSync,
|
|
17
18
|
writeFileSync,
|
|
18
19
|
} from "node:fs";
|
|
@@ -220,37 +221,43 @@ describe("recover error cases", () => {
|
|
|
220
221
|
describe("recover extraction path — default instance (instanceDir === homedir())", () => {
|
|
221
222
|
test("extracts to retiredDir and renames staging dir to instanceDir/.vellum", async () => {
|
|
222
223
|
const name = "default-instance";
|
|
223
|
-
// Default instance: instanceDir is the real home directory
|
|
224
224
|
const entry = makeEntry(name, homedir());
|
|
225
225
|
const { archivePath, extractedPath } = writeArchiveFixtures(name, entry);
|
|
226
226
|
|
|
227
227
|
const expectedTargetDir = join(homedir(), ".vellum");
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
228
|
+
// If a real ~/.vellum exists (e.g. the machine runs a live assistant),
|
|
229
|
+
// temporarily move it aside so the collision guard doesn't fire.
|
|
230
|
+
const backupDir = join(homedir(), ".vellum-recover-test-bak");
|
|
231
|
+
const hadExisting = existsSync(expectedTargetDir);
|
|
232
|
+
if (hadExisting) renameSync(expectedTargetDir, backupDir);
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
process.argv = ["bun", "vellum", "recover", name];
|
|
236
|
+
await recover();
|
|
237
|
+
|
|
238
|
+
// exec must have been called with -C retiredDir, NOT -C homedir()
|
|
239
|
+
expect(execMock).toHaveBeenCalledTimes(1);
|
|
240
|
+
const [cmd, args] = execMock.mock.calls[0] as [string, string[]];
|
|
241
|
+
expect(cmd).toBe("tar");
|
|
242
|
+
expect(args).toContain("-C");
|
|
243
|
+
const cIndex = args.indexOf("-C");
|
|
244
|
+
expect(args[cIndex + 1]).toBe(retiredDir);
|
|
245
|
+
expect(args[cIndex + 1]).not.toBe(homedir());
|
|
246
|
+
|
|
247
|
+
// Staging dir was renamed to the correct target
|
|
248
|
+
expect(existsSync(extractedPath)).toBe(false);
|
|
249
|
+
expect(existsSync(expectedTargetDir)).toBe(true);
|
|
250
|
+
|
|
251
|
+
// Archive and metadata were cleaned up
|
|
252
|
+
expect(existsSync(archivePath)).toBe(false);
|
|
253
|
+
|
|
254
|
+
// Daemon and gateway were started
|
|
255
|
+
expect(startLocalDaemonMock).toHaveBeenCalledTimes(1);
|
|
256
|
+
expect(startGatewayMock).toHaveBeenCalledTimes(1);
|
|
257
|
+
} finally {
|
|
258
|
+
rmSync(expectedTargetDir, { recursive: true, force: true });
|
|
259
|
+
if (hadExisting) renameSync(backupDir, expectedTargetDir);
|
|
260
|
+
}
|
|
254
261
|
});
|
|
255
262
|
});
|
|
256
263
|
|
|
@@ -20,30 +20,74 @@ import { join } from "node:path";
|
|
|
20
20
|
|
|
21
21
|
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
22
22
|
import { loadAllAssistants } from "../lib/assistant-config.js";
|
|
23
|
+
import * as loopbackFetchModule from "../lib/loopback-fetch.js";
|
|
24
|
+
import * as platformClientModule from "../lib/platform-client.js";
|
|
23
25
|
import * as retireLocalModule from "../lib/retire-local.js";
|
|
24
26
|
|
|
25
27
|
const testDir = mkdtempSync(join(tmpdir(), "cli-retire-test-"));
|
|
26
28
|
const originalArgv = [...process.argv];
|
|
27
29
|
const originalExit = process.exit;
|
|
28
30
|
const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
|
|
31
|
+
const originalPlatformToken = process.env.VELLUM_PLATFORM_TOKEN;
|
|
29
32
|
const originalStdinIsTTY = process.stdin.isTTY;
|
|
30
33
|
const originalStdoutIsTTY = process.stdout.isTTY;
|
|
31
34
|
const originalStdinIsRaw = process.stdin.isRaw;
|
|
32
35
|
const originalSetRawMode = process.stdin.setRawMode;
|
|
33
36
|
const originalStdoutWrite = process.stdout.write;
|
|
34
37
|
const realRetireLocalModule = { ...retireLocalModule };
|
|
38
|
+
const realPlatformClientModule = { ...platformClientModule };
|
|
39
|
+
const realLoopbackFetchModule = { ...loopbackFetchModule };
|
|
35
40
|
|
|
36
41
|
const retireLocalMock = mock(async () => {});
|
|
42
|
+
const readPlatformTokenMock = mock((): string | null => null);
|
|
43
|
+
const authHeadersMock = mock(async () => ({
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
"X-Session-Token": "session-token",
|
|
46
|
+
}));
|
|
47
|
+
const authHeadersForKnownOrganizationMock = mock(
|
|
48
|
+
(token: string, organizationId: string) => ({
|
|
49
|
+
"Content-Type": "application/json",
|
|
50
|
+
"X-Session-Token": token,
|
|
51
|
+
"Vellum-Organization-Id": organizationId,
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
const getPlatformUrlMock = mock(() => "https://platform.example.com");
|
|
55
|
+
const readGatewayCredentialMock = mock(
|
|
56
|
+
async (
|
|
57
|
+
_gatewayUrl: string,
|
|
58
|
+
_name: string,
|
|
59
|
+
_bearerToken?: string,
|
|
60
|
+
): Promise<{ value: string | null; unreachable: boolean }> => ({
|
|
61
|
+
value: null,
|
|
62
|
+
unreachable: false,
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
const loopbackSafeFetchMock = mock(
|
|
66
|
+
async (): Promise<Response> => new Response(null, { status: 204 }),
|
|
67
|
+
);
|
|
37
68
|
|
|
38
69
|
mock.module("../lib/retire-local.js", () => ({
|
|
39
70
|
...realRetireLocalModule,
|
|
40
71
|
retireLocal: retireLocalMock,
|
|
41
72
|
}));
|
|
42
73
|
|
|
74
|
+
mock.module("../lib/platform-client.js", () => ({
|
|
75
|
+
authHeaders: authHeadersMock,
|
|
76
|
+
authHeadersForKnownOrganization: authHeadersForKnownOrganizationMock,
|
|
77
|
+
getPlatformUrl: getPlatformUrlMock,
|
|
78
|
+
readGatewayCredential: readGatewayCredentialMock,
|
|
79
|
+
readPlatformToken: readPlatformTokenMock,
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
mock.module("../lib/loopback-fetch.js", () => ({
|
|
83
|
+
loopbackSafeFetch: loopbackSafeFetchMock,
|
|
84
|
+
}));
|
|
85
|
+
|
|
43
86
|
import { retire } from "../commands/retire.js";
|
|
44
87
|
|
|
45
88
|
let consoleLogSpy: ReturnType<typeof spyOn>;
|
|
46
89
|
let consoleErrorSpy: ReturnType<typeof spyOn>;
|
|
90
|
+
let consoleWarnSpy: ReturnType<typeof spyOn>;
|
|
47
91
|
|
|
48
92
|
function makeEntry(
|
|
49
93
|
assistantId: string,
|
|
@@ -123,6 +167,7 @@ function restoreTerminal(): void {
|
|
|
123
167
|
describe("vellum retire", () => {
|
|
124
168
|
beforeEach(() => {
|
|
125
169
|
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
170
|
+
delete process.env.VELLUM_PLATFORM_TOKEN;
|
|
126
171
|
rmSync(join(testDir, ".vellum.lock.json"), { force: true });
|
|
127
172
|
process.argv = ["bun", "vellum", "retire"];
|
|
128
173
|
process.exit = ((code?: number) => {
|
|
@@ -130,9 +175,36 @@ describe("vellum retire", () => {
|
|
|
130
175
|
}) as typeof process.exit;
|
|
131
176
|
retireLocalMock.mockReset();
|
|
132
177
|
retireLocalMock.mockResolvedValue(undefined);
|
|
178
|
+
readPlatformTokenMock.mockReset();
|
|
179
|
+
readPlatformTokenMock.mockReturnValue(null);
|
|
180
|
+
authHeadersMock.mockReset();
|
|
181
|
+
authHeadersMock.mockResolvedValue({
|
|
182
|
+
"Content-Type": "application/json",
|
|
183
|
+
"X-Session-Token": "session-token",
|
|
184
|
+
});
|
|
185
|
+
authHeadersForKnownOrganizationMock.mockReset();
|
|
186
|
+
authHeadersForKnownOrganizationMock.mockImplementation(
|
|
187
|
+
(token: string, organizationId: string) => ({
|
|
188
|
+
"Content-Type": "application/json",
|
|
189
|
+
"X-Session-Token": token,
|
|
190
|
+
"Vellum-Organization-Id": organizationId,
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
getPlatformUrlMock.mockReset();
|
|
194
|
+
getPlatformUrlMock.mockReturnValue("https://platform.example.com");
|
|
195
|
+
readGatewayCredentialMock.mockReset();
|
|
196
|
+
readGatewayCredentialMock.mockResolvedValue({
|
|
197
|
+
value: null,
|
|
198
|
+
unreachable: false,
|
|
199
|
+
});
|
|
200
|
+
loopbackSafeFetchMock.mockReset();
|
|
201
|
+
loopbackSafeFetchMock.mockResolvedValue(
|
|
202
|
+
new Response(null, { status: 204 }),
|
|
203
|
+
);
|
|
133
204
|
setTerminalMode(false);
|
|
134
205
|
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
135
206
|
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
207
|
+
consoleWarnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
136
208
|
});
|
|
137
209
|
|
|
138
210
|
afterEach(() => {
|
|
@@ -141,15 +213,23 @@ describe("vellum retire", () => {
|
|
|
141
213
|
restoreTerminal();
|
|
142
214
|
consoleLogSpy.mockRestore();
|
|
143
215
|
consoleErrorSpy.mockRestore();
|
|
216
|
+
consoleWarnSpy.mockRestore();
|
|
144
217
|
});
|
|
145
218
|
|
|
146
219
|
afterAll(() => {
|
|
147
220
|
mock.module("../lib/retire-local.js", () => realRetireLocalModule);
|
|
221
|
+
mock.module("../lib/platform-client.js", () => realPlatformClientModule);
|
|
222
|
+
mock.module("../lib/loopback-fetch.js", () => realLoopbackFetchModule);
|
|
148
223
|
if (originalLockfileDir === undefined) {
|
|
149
224
|
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
150
225
|
} else {
|
|
151
226
|
process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
|
|
152
227
|
}
|
|
228
|
+
if (originalPlatformToken === undefined) {
|
|
229
|
+
delete process.env.VELLUM_PLATFORM_TOKEN;
|
|
230
|
+
} else {
|
|
231
|
+
process.env.VELLUM_PLATFORM_TOKEN = originalPlatformToken;
|
|
232
|
+
}
|
|
153
233
|
rmSync(testDir, { recursive: true, force: true });
|
|
154
234
|
});
|
|
155
235
|
|
|
@@ -170,6 +250,218 @@ describe("vellum retire", () => {
|
|
|
170
250
|
);
|
|
171
251
|
});
|
|
172
252
|
|
|
253
|
+
test("local retire unregisters the self-hosted platform record first", async () => {
|
|
254
|
+
const entry = makeEntry("assistant-1", { name: "Example Assistant" });
|
|
255
|
+
writeLockfile([entry]);
|
|
256
|
+
readPlatformTokenMock.mockReturnValue("session-token");
|
|
257
|
+
getPlatformUrlMock.mockReturnValue("https://platform.example.test/api");
|
|
258
|
+
readGatewayCredentialMock.mockImplementation(async (_gatewayUrl, name) => {
|
|
259
|
+
if (name === "vellum:platform_assistant_id") {
|
|
260
|
+
return { value: "platform-assistant-1", unreachable: false };
|
|
261
|
+
}
|
|
262
|
+
if (name === "vellum:platform_base_url") {
|
|
263
|
+
return { value: "https://platform.example.test", unreachable: false };
|
|
264
|
+
}
|
|
265
|
+
if (name === "vellum:platform_organization_id") {
|
|
266
|
+
return { value: "org-123", unreachable: false };
|
|
267
|
+
}
|
|
268
|
+
return { value: null, unreachable: false };
|
|
269
|
+
});
|
|
270
|
+
process.argv = ["bun", "vellum", "retire", "assistant-1", "--yes"];
|
|
271
|
+
|
|
272
|
+
await retire();
|
|
273
|
+
|
|
274
|
+
expect(readGatewayCredentialMock).toHaveBeenCalledWith(
|
|
275
|
+
entry.localUrl ?? entry.runtimeUrl,
|
|
276
|
+
"vellum:platform_assistant_id",
|
|
277
|
+
entry.bearerToken,
|
|
278
|
+
);
|
|
279
|
+
expect(authHeadersMock).not.toHaveBeenCalled();
|
|
280
|
+
expect(loopbackSafeFetchMock).toHaveBeenCalledWith(
|
|
281
|
+
"https://platform.example.test/api/v1/assistants/platform-assistant-1/retire/",
|
|
282
|
+
{
|
|
283
|
+
method: "DELETE",
|
|
284
|
+
headers: {
|
|
285
|
+
"Content-Type": "application/json",
|
|
286
|
+
"X-Session-Token": "session-token",
|
|
287
|
+
"Vellum-Organization-Id": "org-123",
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
);
|
|
291
|
+
expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
|
|
292
|
+
expect(loadAllAssistants()).toEqual([]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("local retire reads platform registration through the loopback gateway URL", async () => {
|
|
296
|
+
const localGatewayUrl = "http://127.0.0.1:7831";
|
|
297
|
+
const entry = makeEntry("assistant-1", {
|
|
298
|
+
name: "Example Assistant",
|
|
299
|
+
runtimeUrl: "http://assistant-1.local:7831",
|
|
300
|
+
localUrl: localGatewayUrl,
|
|
301
|
+
});
|
|
302
|
+
writeLockfile([entry]);
|
|
303
|
+
readPlatformTokenMock.mockReturnValue("session-token");
|
|
304
|
+
getPlatformUrlMock.mockReturnValue("https://platform.example.test");
|
|
305
|
+
readGatewayCredentialMock.mockImplementation(async (gatewayUrl, name) => {
|
|
306
|
+
expect(gatewayUrl).toBe(localGatewayUrl);
|
|
307
|
+
if (name === "vellum:platform_assistant_id") {
|
|
308
|
+
return { value: "platform-assistant-1", unreachable: false };
|
|
309
|
+
}
|
|
310
|
+
if (name === "vellum:platform_base_url") {
|
|
311
|
+
return { value: "https://platform.example.test", unreachable: false };
|
|
312
|
+
}
|
|
313
|
+
if (name === "vellum:platform_organization_id") {
|
|
314
|
+
return { value: "org-123", unreachable: false };
|
|
315
|
+
}
|
|
316
|
+
return { value: null, unreachable: false };
|
|
317
|
+
});
|
|
318
|
+
process.argv = ["bun", "vellum", "retire", "assistant-1", "--yes"];
|
|
319
|
+
|
|
320
|
+
await retire();
|
|
321
|
+
|
|
322
|
+
expect(readGatewayCredentialMock).toHaveBeenCalledWith(
|
|
323
|
+
localGatewayUrl,
|
|
324
|
+
"vellum:platform_assistant_id",
|
|
325
|
+
entry.bearerToken,
|
|
326
|
+
);
|
|
327
|
+
expect(readGatewayCredentialMock).toHaveBeenCalledWith(
|
|
328
|
+
localGatewayUrl,
|
|
329
|
+
"vellum:platform_base_url",
|
|
330
|
+
entry.bearerToken,
|
|
331
|
+
);
|
|
332
|
+
expect(readGatewayCredentialMock).toHaveBeenCalledWith(
|
|
333
|
+
localGatewayUrl,
|
|
334
|
+
"vellum:platform_organization_id",
|
|
335
|
+
entry.bearerToken,
|
|
336
|
+
);
|
|
337
|
+
expect(loopbackSafeFetchMock).toHaveBeenCalledWith(
|
|
338
|
+
"https://platform.example.test/v1/assistants/platform-assistant-1/retire/",
|
|
339
|
+
expect.any(Object),
|
|
340
|
+
);
|
|
341
|
+
expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("local retire skips platform unregister when stored platform URL origin mismatches", async () => {
|
|
345
|
+
const entry = makeEntry("assistant-1", { name: "Example Assistant" });
|
|
346
|
+
writeLockfile([entry]);
|
|
347
|
+
readPlatformTokenMock.mockReturnValue("session-token");
|
|
348
|
+
getPlatformUrlMock.mockReturnValue("https://platform.example.com");
|
|
349
|
+
readGatewayCredentialMock.mockImplementation(async (_gatewayUrl, name) => {
|
|
350
|
+
if (name === "vellum:platform_assistant_id") {
|
|
351
|
+
return { value: "platform-assistant-1", unreachable: false };
|
|
352
|
+
}
|
|
353
|
+
if (name === "vellum:platform_base_url") {
|
|
354
|
+
return { value: "https://malicious.example.test", unreachable: false };
|
|
355
|
+
}
|
|
356
|
+
if (name === "vellum:platform_organization_id") {
|
|
357
|
+
return { value: "org-123", unreachable: false };
|
|
358
|
+
}
|
|
359
|
+
return { value: null, unreachable: false };
|
|
360
|
+
});
|
|
361
|
+
process.argv = ["bun", "vellum", "retire", "assistant-1", "--yes"];
|
|
362
|
+
|
|
363
|
+
await retire();
|
|
364
|
+
|
|
365
|
+
expect(loopbackSafeFetchMock).not.toHaveBeenCalled();
|
|
366
|
+
expect(consoleWarnSpy.mock.calls.flat().join("\n")).toContain(
|
|
367
|
+
"Stored platform URL does not match the configured platform URL",
|
|
368
|
+
);
|
|
369
|
+
expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("local retire skips platform unregister when stored organization ID is missing", async () => {
|
|
373
|
+
const entry = makeEntry("assistant-1", { name: "Example Assistant" });
|
|
374
|
+
writeLockfile([entry]);
|
|
375
|
+
readPlatformTokenMock.mockReturnValue("session-token");
|
|
376
|
+
getPlatformUrlMock.mockReturnValue("https://platform.example.test");
|
|
377
|
+
readGatewayCredentialMock.mockImplementation(async (_gatewayUrl, name) => {
|
|
378
|
+
if (name === "vellum:platform_assistant_id") {
|
|
379
|
+
return { value: "platform-assistant-1", unreachable: false };
|
|
380
|
+
}
|
|
381
|
+
if (name === "vellum:platform_base_url") {
|
|
382
|
+
return { value: "https://platform.example.test", unreachable: false };
|
|
383
|
+
}
|
|
384
|
+
return { value: null, unreachable: false };
|
|
385
|
+
});
|
|
386
|
+
process.argv = ["bun", "vellum", "retire", "assistant-1", "--yes"];
|
|
387
|
+
|
|
388
|
+
await retire();
|
|
389
|
+
|
|
390
|
+
expect(loopbackSafeFetchMock).not.toHaveBeenCalled();
|
|
391
|
+
expect(consoleWarnSpy.mock.calls.flat().join("\n")).toContain(
|
|
392
|
+
"Could not read platform organization ID",
|
|
393
|
+
);
|
|
394
|
+
expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
|
|
395
|
+
expect(loadAllAssistants()).toEqual([]);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("local retire uses injected platform token from the app host", async () => {
|
|
399
|
+
const entry = makeEntry("assistant-1", { name: "Example Assistant" });
|
|
400
|
+
writeLockfile([entry]);
|
|
401
|
+
process.env.VELLUM_PLATFORM_TOKEN = "host-session-token";
|
|
402
|
+
readPlatformTokenMock.mockReturnValue(null);
|
|
403
|
+
getPlatformUrlMock.mockReturnValue("https://platform.example.test");
|
|
404
|
+
readGatewayCredentialMock.mockImplementation(async (_gatewayUrl, name) => {
|
|
405
|
+
if (name === "vellum:platform_assistant_id") {
|
|
406
|
+
return { value: "platform-assistant-1", unreachable: false };
|
|
407
|
+
}
|
|
408
|
+
if (name === "vellum:platform_base_url") {
|
|
409
|
+
return { value: "https://platform.example.test", unreachable: false };
|
|
410
|
+
}
|
|
411
|
+
if (name === "vellum:platform_organization_id") {
|
|
412
|
+
return { value: "org-123", unreachable: false };
|
|
413
|
+
}
|
|
414
|
+
return { value: null, unreachable: false };
|
|
415
|
+
});
|
|
416
|
+
process.argv = ["bun", "vellum", "retire", "assistant-1", "--yes"];
|
|
417
|
+
|
|
418
|
+
await retire();
|
|
419
|
+
|
|
420
|
+
expect(loopbackSafeFetchMock).toHaveBeenCalledWith(
|
|
421
|
+
"https://platform.example.test/v1/assistants/platform-assistant-1/retire/",
|
|
422
|
+
{
|
|
423
|
+
method: "DELETE",
|
|
424
|
+
headers: {
|
|
425
|
+
"Content-Type": "application/json",
|
|
426
|
+
"X-Session-Token": "host-session-token",
|
|
427
|
+
"Vellum-Organization-Id": "org-123",
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
);
|
|
431
|
+
expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("local retire continues when self-hosted platform unregister fails", async () => {
|
|
435
|
+
const entry = makeEntry("assistant-1", { name: "Example Assistant" });
|
|
436
|
+
writeLockfile([entry]);
|
|
437
|
+
readPlatformTokenMock.mockReturnValue("session-token");
|
|
438
|
+
getPlatformUrlMock.mockReturnValue("https://platform.example.test");
|
|
439
|
+
readGatewayCredentialMock.mockImplementation(async (_gatewayUrl, name) => {
|
|
440
|
+
if (name === "vellum:platform_assistant_id") {
|
|
441
|
+
return { value: "platform-assistant-1", unreachable: false };
|
|
442
|
+
}
|
|
443
|
+
if (name === "vellum:platform_base_url") {
|
|
444
|
+
return { value: "https://platform.example.test", unreachable: false };
|
|
445
|
+
}
|
|
446
|
+
if (name === "vellum:platform_organization_id") {
|
|
447
|
+
return { value: "org-123", unreachable: false };
|
|
448
|
+
}
|
|
449
|
+
return { value: null, unreachable: false };
|
|
450
|
+
});
|
|
451
|
+
loopbackSafeFetchMock.mockResolvedValue(
|
|
452
|
+
new Response("platform unavailable", { status: 503 }),
|
|
453
|
+
);
|
|
454
|
+
process.argv = ["bun", "vellum", "retire", "assistant-1", "--yes"];
|
|
455
|
+
|
|
456
|
+
await retire();
|
|
457
|
+
|
|
458
|
+
expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
|
|
459
|
+
expect(loadAllAssistants()).toEqual([]);
|
|
460
|
+
expect(consoleWarnSpy.mock.calls.flat().join("\n")).toContain(
|
|
461
|
+
"Platform self-hosted unregister failed (503): platform unavailable",
|
|
462
|
+
);
|
|
463
|
+
});
|
|
464
|
+
|
|
173
465
|
test("non-interactive retire without --yes fails before deleting", async () => {
|
|
174
466
|
const entry = makeEntry("assistant-1", { name: "Example Assistant" });
|
|
175
467
|
writeLockfile([entry]);
|
|
@@ -259,12 +259,27 @@ const localRuntimePollJobStatusMock = mock<
|
|
|
259
259
|
},
|
|
260
260
|
}));
|
|
261
261
|
|
|
262
|
+
const localRuntimePreflightFromGcsMock = mock<
|
|
263
|
+
typeof localRuntimeClient.localRuntimePreflightFromGcs
|
|
264
|
+
>(async () => ({
|
|
265
|
+
can_import: true,
|
|
266
|
+
summary: {
|
|
267
|
+
files_to_create: 2,
|
|
268
|
+
files_to_overwrite: 1,
|
|
269
|
+
files_unchanged: 0,
|
|
270
|
+
total_files: 3,
|
|
271
|
+
},
|
|
272
|
+
files: [],
|
|
273
|
+
conflicts: [],
|
|
274
|
+
}));
|
|
275
|
+
|
|
262
276
|
mock.module("../lib/local-runtime-client.js", () => ({
|
|
263
277
|
...realLocalRuntimeClient,
|
|
264
278
|
localRuntimeExportToGcs: localRuntimeExportToGcsMock,
|
|
265
279
|
localRuntimeImportFromGcs: localRuntimeImportFromGcsMock,
|
|
266
280
|
localRuntimeIdentity: localRuntimeIdentityMock,
|
|
267
281
|
localRuntimePollJobStatus: localRuntimePollJobStatusMock,
|
|
282
|
+
localRuntimePreflightFromGcs: localRuntimePreflightFromGcsMock,
|
|
268
283
|
}));
|
|
269
284
|
|
|
270
285
|
const hatchLocalMock = mock(async () => {});
|
|
@@ -483,6 +498,18 @@ beforeEach(() => {
|
|
|
483
498
|
localRuntimeImportFromGcsMock.mockResolvedValue({
|
|
484
499
|
jobId: "local-import-job-1",
|
|
485
500
|
});
|
|
501
|
+
localRuntimePreflightFromGcsMock.mockReset();
|
|
502
|
+
localRuntimePreflightFromGcsMock.mockResolvedValue({
|
|
503
|
+
can_import: true,
|
|
504
|
+
summary: {
|
|
505
|
+
files_to_create: 2,
|
|
506
|
+
files_to_overwrite: 1,
|
|
507
|
+
files_unchanged: 0,
|
|
508
|
+
total_files: 3,
|
|
509
|
+
},
|
|
510
|
+
files: [],
|
|
511
|
+
conflicts: [],
|
|
512
|
+
});
|
|
486
513
|
localRuntimeIdentityMock.mockReset();
|
|
487
514
|
localRuntimeIdentityMock.mockResolvedValue({ version: "0.6.5" });
|
|
488
515
|
localRuntimePollJobStatusMock.mockReset();
|
|
@@ -1932,7 +1959,7 @@ describe("dry-run", () => {
|
|
|
1932
1959
|
}
|
|
1933
1960
|
});
|
|
1934
1961
|
|
|
1935
|
-
test("dry-run
|
|
1962
|
+
test("dry-run with existing local target calls preflight-from-gcs on runtime", async () => {
|
|
1936
1963
|
setArgv("--from", "my-platform", "--local", "my-local", "--dry-run");
|
|
1937
1964
|
|
|
1938
1965
|
const platformEntry = makeEntry("my-platform", {
|
|
@@ -1947,26 +1974,24 @@ describe("dry-run", () => {
|
|
|
1947
1974
|
return null;
|
|
1948
1975
|
});
|
|
1949
1976
|
|
|
1950
|
-
platformPollJobStatusMock.mockResolvedValue({
|
|
1951
|
-
jobId: "platform-export-job-1",
|
|
1952
|
-
type: "export",
|
|
1953
|
-
status: "complete",
|
|
1954
|
-
bundleKey: "bundle-key-from-platform",
|
|
1955
|
-
});
|
|
1956
|
-
|
|
1957
1977
|
const restoreFetch = installTrackingFetch();
|
|
1958
1978
|
try {
|
|
1959
|
-
await
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1979
|
+
await teleport();
|
|
1980
|
+
|
|
1981
|
+
// Preflight was run against the local runtime
|
|
1982
|
+
expect(localRuntimePreflightFromGcsMock).toHaveBeenCalledTimes(1);
|
|
1983
|
+
expect(localRuntimePreflightFromGcsMock).toHaveBeenCalledWith(
|
|
1984
|
+
expect.objectContaining({ assistantId: "my-local" }),
|
|
1985
|
+
expect.any(String),
|
|
1986
|
+
{ bundleUrl: "https://storage.googleapis.com/bucket/signed-download" },
|
|
1964
1987
|
);
|
|
1965
1988
|
|
|
1966
|
-
//
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1989
|
+
// No actual import happened — dry-run only
|
|
1990
|
+
expect(localRuntimeImportFromGcsMock).not.toHaveBeenCalled();
|
|
1991
|
+
|
|
1992
|
+
// Source was not retired
|
|
1993
|
+
expect(retireLocalMock).not.toHaveBeenCalled();
|
|
1994
|
+
expect(retireDockerMock).not.toHaveBeenCalled();
|
|
1970
1995
|
} finally {
|
|
1971
1996
|
restoreFetch();
|
|
1972
1997
|
}
|
package/src/commands/flags.ts
CHANGED
|
@@ -98,7 +98,7 @@ function printHelp(): void {
|
|
|
98
98
|
" $ vellum flags set voice-mode true # enable a flag",
|
|
99
99
|
);
|
|
100
100
|
console.log(
|
|
101
|
-
" $ vellum flags set
|
|
101
|
+
" $ vellum flags set voice-mode true --assistant eval-1 # target by name/id",
|
|
102
102
|
);
|
|
103
103
|
}
|
|
104
104
|
|
package/src/commands/retire.ts
CHANGED
|
@@ -20,7 +20,9 @@ import { getConfigDir } from "../lib/environments/paths.js";
|
|
|
20
20
|
import { getCurrentEnvironment } from "../lib/environments/resolve.js";
|
|
21
21
|
import {
|
|
22
22
|
authHeaders,
|
|
23
|
+
authHeadersForKnownOrganization,
|
|
23
24
|
getPlatformUrl,
|
|
25
|
+
readGatewayCredential,
|
|
24
26
|
readPlatformToken,
|
|
25
27
|
} from "../lib/platform-client.js";
|
|
26
28
|
import { retireInstance as retireAwsInstance } from "../lib/aws.js";
|
|
@@ -45,6 +47,128 @@ interface RetireArgs {
|
|
|
45
47
|
yes: boolean;
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
const PLATFORM_TOKEN_ENV = "VELLUM_PLATFORM_TOKEN";
|
|
51
|
+
|
|
52
|
+
function readPlatformRetireToken(): string | null {
|
|
53
|
+
const envToken = process.env[PLATFORM_TOKEN_ENV]?.trim();
|
|
54
|
+
if (envToken) return envToken;
|
|
55
|
+
return readPlatformToken();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function platformOrigin(platformUrl: string): string | null {
|
|
59
|
+
try {
|
|
60
|
+
return new URL(platformUrl).origin;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveTrustedSelfHostedPlatformUrl(
|
|
67
|
+
storedPlatformUrl: string | null,
|
|
68
|
+
): string | null {
|
|
69
|
+
const configuredPlatformUrl = getPlatformUrl();
|
|
70
|
+
if (!storedPlatformUrl) return configuredPlatformUrl;
|
|
71
|
+
|
|
72
|
+
const storedOrigin = platformOrigin(storedPlatformUrl);
|
|
73
|
+
const configuredOrigin = platformOrigin(configuredPlatformUrl);
|
|
74
|
+
if (storedOrigin && configuredOrigin && storedOrigin === configuredOrigin) {
|
|
75
|
+
return configuredPlatformUrl;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.warn(
|
|
79
|
+
"⚠️ Stored platform URL does not match the configured platform URL; skipping platform unregister.",
|
|
80
|
+
);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function deletePlatformAssistant(
|
|
85
|
+
assistantId: string,
|
|
86
|
+
options: {
|
|
87
|
+
token: string;
|
|
88
|
+
platformUrl?: string;
|
|
89
|
+
organizationId?: string;
|
|
90
|
+
label: string;
|
|
91
|
+
successMessage: string;
|
|
92
|
+
alreadyGoneMessage: string;
|
|
93
|
+
},
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const platformUrl = options.platformUrl || getPlatformUrl();
|
|
96
|
+
const url = `${platformUrl}/v1/assistants/${encodeURIComponent(assistantId)}/retire/`;
|
|
97
|
+
const headers = options.organizationId
|
|
98
|
+
? authHeadersForKnownOrganization(options.token, options.organizationId)
|
|
99
|
+
: await authHeaders(options.token, platformUrl);
|
|
100
|
+
|
|
101
|
+
const response = await loopbackSafeFetch(url, {
|
|
102
|
+
method: "DELETE",
|
|
103
|
+
headers,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (!response.ok && response.status !== 404) {
|
|
107
|
+
const body = await response.text();
|
|
108
|
+
throw new Error(`${options.label} failed (${response.status}): ${body}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (response.status === 404) {
|
|
112
|
+
console.log(options.alreadyGoneMessage);
|
|
113
|
+
} else {
|
|
114
|
+
console.log(options.successMessage);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function unregisterSelfHostedLocalPlatformRecord(
|
|
119
|
+
entry: AssistantEntry,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
const token = readPlatformRetireToken();
|
|
122
|
+
if (!token) return;
|
|
123
|
+
|
|
124
|
+
const gatewayUrl = entry.localUrl ?? entry.runtimeUrl;
|
|
125
|
+
const platformAssistant = await readGatewayCredential(
|
|
126
|
+
gatewayUrl,
|
|
127
|
+
"vellum:platform_assistant_id",
|
|
128
|
+
entry.bearerToken,
|
|
129
|
+
);
|
|
130
|
+
if (platformAssistant.unreachable) {
|
|
131
|
+
console.warn(
|
|
132
|
+
"\u26a0\ufe0f Could not read platform registration from the local assistant; skipping platform unregister.",
|
|
133
|
+
);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!platformAssistant.value) return;
|
|
137
|
+
|
|
138
|
+
const [platformBaseUrl, organizationId] = await Promise.all([
|
|
139
|
+
readGatewayCredential(
|
|
140
|
+
gatewayUrl,
|
|
141
|
+
"vellum:platform_base_url",
|
|
142
|
+
entry.bearerToken,
|
|
143
|
+
),
|
|
144
|
+
readGatewayCredential(
|
|
145
|
+
gatewayUrl,
|
|
146
|
+
"vellum:platform_organization_id",
|
|
147
|
+
entry.bearerToken,
|
|
148
|
+
),
|
|
149
|
+
]);
|
|
150
|
+
const platformUrl = resolveTrustedSelfHostedPlatformUrl(
|
|
151
|
+
platformBaseUrl.value,
|
|
152
|
+
);
|
|
153
|
+
if (!platformUrl) return;
|
|
154
|
+
if (!organizationId.value) {
|
|
155
|
+
console.warn(
|
|
156
|
+
"⚠️ Could not read platform organization ID from the local assistant; skipping platform unregister.",
|
|
157
|
+
);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await deletePlatformAssistant(platformAssistant.value, {
|
|
162
|
+
token,
|
|
163
|
+
platformUrl,
|
|
164
|
+
organizationId: organizationId.value,
|
|
165
|
+
label: "Platform self-hosted unregister",
|
|
166
|
+
successMessage: "\u2705 Platform self-hosted registration unregistered.",
|
|
167
|
+
alreadyGoneMessage:
|
|
168
|
+
"\u2705 Platform self-hosted registration already unregistered (404).",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
48
172
|
async function retireCustom(entry: AssistantEntry): Promise<void> {
|
|
49
173
|
const host = extractHostFromUrl(entry.runtimeUrl);
|
|
50
174
|
const sshUser = entry.sshUser ?? "root";
|
|
@@ -86,7 +210,7 @@ async function retireVellum(
|
|
|
86
210
|
): Promise<void> {
|
|
87
211
|
console.log("\u{1F5D1}\ufe0f Retiring platform-hosted instance...\n");
|
|
88
212
|
|
|
89
|
-
const token =
|
|
213
|
+
const token = readPlatformRetireToken();
|
|
90
214
|
if (!token) {
|
|
91
215
|
console.error(
|
|
92
216
|
"Error: Not logged in. Run `vellum login --token <token>` first.",
|
|
@@ -94,33 +218,21 @@ async function retireVellum(
|
|
|
94
218
|
process.exit(1);
|
|
95
219
|
}
|
|
96
220
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
// cleanup avoids leaving a stale entry that would otherwise wedge the
|
|
108
|
-
// macOS app in a permanent health-check loop.
|
|
109
|
-
if (!response.ok && response.status !== 404) {
|
|
110
|
-
const body = await response.text();
|
|
221
|
+
try {
|
|
222
|
+
await deletePlatformAssistant(assistantId, {
|
|
223
|
+
token,
|
|
224
|
+
platformUrl: runtimeUrl,
|
|
225
|
+
label: "Platform retire",
|
|
226
|
+
successMessage: "\u2705 Platform-hosted instance retired.",
|
|
227
|
+
alreadyGoneMessage:
|
|
228
|
+
"\u2705 Platform-hosted instance already retired (404) \u2014 cleaning up local state.",
|
|
229
|
+
});
|
|
230
|
+
} catch (error) {
|
|
111
231
|
console.error(
|
|
112
|
-
`Error:
|
|
232
|
+
`Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
113
233
|
);
|
|
114
234
|
process.exit(1);
|
|
115
235
|
}
|
|
116
|
-
|
|
117
|
-
if (response.status === 404) {
|
|
118
|
-
console.log(
|
|
119
|
-
"\u2705 Platform-hosted instance already retired (404) — cleaning up local state.",
|
|
120
|
-
);
|
|
121
|
-
} else {
|
|
122
|
-
console.log("\u2705 Platform-hosted instance retired.");
|
|
123
|
-
}
|
|
124
236
|
}
|
|
125
237
|
|
|
126
238
|
function parseRetireArgs(args: string[]): RetireArgs {
|
|
@@ -303,6 +415,15 @@ async function retireInner(): Promise<void> {
|
|
|
303
415
|
} else if (cloud === "docker") {
|
|
304
416
|
await retireDocker(assistantId);
|
|
305
417
|
} else if (cloud === "local") {
|
|
418
|
+
try {
|
|
419
|
+
await unregisterSelfHostedLocalPlatformRecord(entry);
|
|
420
|
+
} catch (error) {
|
|
421
|
+
console.warn(
|
|
422
|
+
`⚠️ Platform self-hosted unregister failed; continuing local retire: ${
|
|
423
|
+
error instanceof Error ? error.message : String(error)
|
|
424
|
+
}`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
306
427
|
await retireLocal(assistantId, entry);
|
|
307
428
|
} else if (cloud === "custom") {
|
|
308
429
|
await retireCustom(entry);
|
package/src/commands/teleport.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
localRuntimeExportToGcs,
|
|
34
34
|
localRuntimeIdentity,
|
|
35
35
|
localRuntimeImportFromGcs,
|
|
36
|
+
localRuntimePreflightFromGcs,
|
|
36
37
|
localRuntimePollJobStatus,
|
|
37
38
|
MigrationInProgressError,
|
|
38
39
|
} from "../lib/local-runtime-client.js";
|
|
@@ -707,11 +708,46 @@ async function importToAssistant(
|
|
|
707
708
|
|
|
708
709
|
if (cloud === "local" || cloud === "docker") {
|
|
709
710
|
if (dryRun) {
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
711
|
+
console.log("Running preflight analysis...\n");
|
|
712
|
+
|
|
713
|
+
// Query the target runtime's version before requesting a signed
|
|
714
|
+
// download URL — the platform uses it for the bundle compatibility check.
|
|
715
|
+
let targetRuntimeVersion: string;
|
|
716
|
+
try {
|
|
717
|
+
const identity = await callRuntimeWithAuthRetry(entry, (token) =>
|
|
718
|
+
localRuntimeIdentity(entry, token),
|
|
719
|
+
);
|
|
720
|
+
targetRuntimeVersion = identity.version;
|
|
721
|
+
} catch (err) {
|
|
722
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
723
|
+
console.error(
|
|
724
|
+
`Error: Could not read target runtime version from '${entry.assistantId}': ${msg}`,
|
|
725
|
+
);
|
|
726
|
+
console.error(`Try: vellum wake ${entry.assistantId}`);
|
|
727
|
+
process.exit(1);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
let bundleUrl: string;
|
|
731
|
+
try {
|
|
732
|
+
const result = await platformRequestSignedUrl(
|
|
733
|
+
{ operation: "download", bundleKey, targetRuntimeVersion },
|
|
734
|
+
platformToken,
|
|
735
|
+
bundlePlatformUrl,
|
|
736
|
+
);
|
|
737
|
+
bundleUrl = result.url;
|
|
738
|
+
} catch (err) {
|
|
739
|
+
if (err instanceof VersionMismatchError) {
|
|
740
|
+
console.error(`Error: ${err.message}`);
|
|
741
|
+
process.exit(1);
|
|
742
|
+
}
|
|
743
|
+
throw err;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const preflightData = await callRuntimeWithAuthRetry(entry, (token) =>
|
|
747
|
+
localRuntimePreflightFromGcs(entry, token, { bundleUrl }),
|
|
713
748
|
);
|
|
714
|
-
|
|
749
|
+
printPreflightSummary(preflightData as unknown as PreflightResponse);
|
|
750
|
+
return;
|
|
715
751
|
}
|
|
716
752
|
|
|
717
753
|
// Ask the platform for a signed download URL and hand it to the local
|
|
@@ -1231,18 +1267,8 @@ export async function teleport(): Promise<void> {
|
|
|
1231
1267
|
process.exit(1);
|
|
1232
1268
|
}
|
|
1233
1269
|
|
|
1234
|
-
//
|
|
1235
|
-
//
|
|
1236
|
-
// so we can't actually run a dry-run against it; burning a GCS upload
|
|
1237
|
-
// just to fail afterwards would be wasteful.
|
|
1238
|
-
// TODO(cli): support dry-run against local targets (needs a
|
|
1239
|
-
// preflight-from-gcs endpoint on the runtime).
|
|
1240
|
-
if (toCloud === "local" || toCloud === "docker") {
|
|
1241
|
-
console.error(
|
|
1242
|
-
"Error: --dry-run is not yet supported for local or docker targets (no preflight-from-gcs endpoint on the runtime).",
|
|
1243
|
-
);
|
|
1244
|
-
process.exit(1);
|
|
1245
|
-
}
|
|
1270
|
+
// Nothing to reject — preflight-from-gcs is now implemented for all
|
|
1271
|
+
// target topologies including local and docker.
|
|
1246
1272
|
|
|
1247
1273
|
// Version guard: block platform→non-platform when target is behind
|
|
1248
1274
|
if (fromCloud === "vellum" && toCloud !== "vellum") {
|
|
@@ -195,6 +195,44 @@ export async function localRuntimeImportFromGcs(
|
|
|
195
195
|
return { jobId: json.job_id };
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Run a dry-run preflight analysis on a .vbundle archive stored at a signed
|
|
200
|
+
* GCS download URL.
|
|
201
|
+
*
|
|
202
|
+
* For local/docker assistants this POSTs to
|
|
203
|
+
* `{runtimeUrl}/v1/migrations/preflight-from-gcs` with guardian-token bearer
|
|
204
|
+
* auth. The daemon fetches the bundle from GCS, validates it, and returns a
|
|
205
|
+
* report of what would change on import — without writing anything to disk.
|
|
206
|
+
*
|
|
207
|
+
* Returns the raw preflight response body. Callers should cast to their
|
|
208
|
+
* local `PreflightResponse` shape (mirrors the `/import-preflight` shape).
|
|
209
|
+
*/
|
|
210
|
+
export async function localRuntimePreflightFromGcs(
|
|
211
|
+
entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
|
|
212
|
+
token: string,
|
|
213
|
+
params: { bundleUrl: string },
|
|
214
|
+
): Promise<Record<string, unknown>> {
|
|
215
|
+
const response = await fetch(
|
|
216
|
+
resolveRuntimeMigrationUrl(entry, "preflight-from-gcs"),
|
|
217
|
+
{
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: await migrationRequestHeaders(entry, token),
|
|
220
|
+
body: JSON.stringify({ bundle_url: params.bundleUrl }),
|
|
221
|
+
},
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
if (response.status !== 200) {
|
|
225
|
+
const errText = await response.text().catch(() => "");
|
|
226
|
+
throw new Error(
|
|
227
|
+
`Local runtime preflight-from-gcs failed (${response.status}): ${
|
|
228
|
+
errText || response.statusText
|
|
229
|
+
}`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return (await response.json()) as Record<string, unknown>;
|
|
234
|
+
}
|
|
235
|
+
|
|
198
236
|
/**
|
|
199
237
|
* Poll the runtime's unified job-status endpoint.
|
|
200
238
|
*
|
|
@@ -96,6 +96,20 @@ function tokenAuthHeader(token: string): Record<string, string> {
|
|
|
96
96
|
return { "X-Session-Token": token };
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
export function authHeadersForKnownOrganization(
|
|
100
|
+
token: string,
|
|
101
|
+
organizationId: string,
|
|
102
|
+
): Record<string, string> {
|
|
103
|
+
const headers: Record<string, string> = {
|
|
104
|
+
"Content-Type": "application/json",
|
|
105
|
+
...tokenAuthHeader(token),
|
|
106
|
+
};
|
|
107
|
+
if (!token.startsWith(VAK_PREFIX)) {
|
|
108
|
+
headers["Vellum-Organization-Id"] = organizationId;
|
|
109
|
+
}
|
|
110
|
+
return headers;
|
|
111
|
+
}
|
|
112
|
+
|
|
99
113
|
/** Module-level cache for org IDs to avoid redundant fetches in polling loops. */
|
|
100
114
|
const orgIdCache = new Map<string, { orgId: string; expiresAt: number }>();
|
|
101
115
|
const ORG_ID_CACHE_TTL_MS = 60_000; // 60 seconds
|