@vellumai/cli 0.8.4 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +17 -1
- package/knip.json +2 -1
- package/package.json +1 -1
- package/src/__tests__/api-key-check.test.ts +78 -0
- package/src/__tests__/backup.test.ts +38 -0
- package/src/__tests__/recover.test.ts +307 -0
- package/src/__tests__/retire.test.ts +241 -0
- package/src/__tests__/wake.test.ts +215 -0
- package/src/commands/backup.ts +2 -0
- package/src/commands/client.ts +62 -32
- package/src/commands/flags.ts +197 -0
- package/src/commands/gateway/token.ts +73 -0
- package/src/commands/gateway.ts +29 -0
- package/src/commands/logs.ts +6 -18
- package/src/commands/ps.ts +41 -41
- package/src/commands/recover.ts +47 -9
- package/src/commands/restore.ts +8 -1
- package/src/commands/retire.ts +145 -55
- package/src/commands/roadmap.ts +449 -0
- package/src/commands/rollback.ts +2 -14
- package/src/commands/ssh.ts +5 -24
- package/src/commands/teleport.ts +34 -26
- package/src/commands/upgrade.ts +8 -16
- package/src/commands/wake.ts +68 -45
- package/src/index.ts +9 -0
- package/src/lib/__tests__/port-allocator.test.ts +117 -0
- package/src/lib/__tests__/step-runner.test.ts +133 -0
- package/src/lib/api-key-check.ts +40 -0
- package/src/lib/assistant-config.ts +13 -0
- package/src/lib/config-utils.ts +24 -3
- package/src/lib/docker.ts +72 -8
- package/src/lib/hatch-local.ts +15 -2
- package/src/lib/http-client.ts +1 -3
- package/src/lib/local.ts +173 -292
- package/src/lib/orphan-detection.ts +9 -5
- package/src/lib/pgrep.ts +5 -1
- package/src/lib/platform-client.ts +97 -49
- package/src/lib/port-allocator.ts +93 -0
- package/src/lib/process.ts +109 -39
- package/src/lib/statefulset.ts +0 -10
- package/src/lib/step-runner.ts +102 -9
- package/src/lib/sync-cloud-assistants.ts +17 -0
- package/src/shared/provider-env-vars.ts +1 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
mock,
|
|
8
|
+
spyOn,
|
|
9
|
+
test,
|
|
10
|
+
} from "bun:test";
|
|
11
|
+
import {
|
|
12
|
+
mkdirSync,
|
|
13
|
+
mkdtempSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
22
|
+
import { loadAllAssistants } from "../lib/assistant-config.js";
|
|
23
|
+
import * as retireLocalModule from "../lib/retire-local.js";
|
|
24
|
+
|
|
25
|
+
const testDir = mkdtempSync(join(tmpdir(), "cli-retire-test-"));
|
|
26
|
+
const originalArgv = [...process.argv];
|
|
27
|
+
const originalExit = process.exit;
|
|
28
|
+
const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
|
|
29
|
+
const originalStdinIsTTY = process.stdin.isTTY;
|
|
30
|
+
const originalStdoutIsTTY = process.stdout.isTTY;
|
|
31
|
+
const originalStdinIsRaw = process.stdin.isRaw;
|
|
32
|
+
const originalSetRawMode = process.stdin.setRawMode;
|
|
33
|
+
const originalStdoutWrite = process.stdout.write;
|
|
34
|
+
const realRetireLocalModule = { ...retireLocalModule };
|
|
35
|
+
|
|
36
|
+
const retireLocalMock = mock(async () => {});
|
|
37
|
+
|
|
38
|
+
mock.module("../lib/retire-local.js", () => ({
|
|
39
|
+
...realRetireLocalModule,
|
|
40
|
+
retireLocal: retireLocalMock,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
import { retire } from "../commands/retire.js";
|
|
44
|
+
|
|
45
|
+
let consoleLogSpy: ReturnType<typeof spyOn>;
|
|
46
|
+
let consoleErrorSpy: ReturnType<typeof spyOn>;
|
|
47
|
+
|
|
48
|
+
function makeEntry(
|
|
49
|
+
assistantId: string,
|
|
50
|
+
extra: Partial<AssistantEntry> = {},
|
|
51
|
+
): AssistantEntry {
|
|
52
|
+
return {
|
|
53
|
+
assistantId,
|
|
54
|
+
runtimeUrl: `http://127.0.0.1:${7800 + assistantId.length}`,
|
|
55
|
+
cloud: "local",
|
|
56
|
+
resources: {
|
|
57
|
+
instanceDir: join(testDir, assistantId),
|
|
58
|
+
daemonPort: 7801,
|
|
59
|
+
gatewayPort: 7831,
|
|
60
|
+
qdrantPort: 6334,
|
|
61
|
+
cesPort: 7790,
|
|
62
|
+
},
|
|
63
|
+
...extra,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeLockfile(entries: AssistantEntry[]): void {
|
|
68
|
+
mkdirSync(testDir, { recursive: true });
|
|
69
|
+
writeFileSync(
|
|
70
|
+
join(testDir, ".vellum.lock.json"),
|
|
71
|
+
JSON.stringify({ assistants: entries }, null, 2) + "\n",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readLockfile(): string {
|
|
76
|
+
return readFileSync(join(testDir, ".vellum.lock.json"), "utf-8");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function setTerminalMode(isTTY: boolean): void {
|
|
80
|
+
Object.defineProperty(process.stdin, "isTTY", {
|
|
81
|
+
configurable: true,
|
|
82
|
+
value: isTTY,
|
|
83
|
+
});
|
|
84
|
+
Object.defineProperty(process.stdout, "isTTY", {
|
|
85
|
+
configurable: true,
|
|
86
|
+
value: isTTY,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function setInteractiveTerminal(): void {
|
|
91
|
+
setTerminalMode(true);
|
|
92
|
+
Object.defineProperty(process.stdin, "isRaw", {
|
|
93
|
+
configurable: true,
|
|
94
|
+
value: false,
|
|
95
|
+
});
|
|
96
|
+
Object.defineProperty(process.stdin, "setRawMode", {
|
|
97
|
+
configurable: true,
|
|
98
|
+
value: mock(() => process.stdin),
|
|
99
|
+
});
|
|
100
|
+
process.stdout.write = (() => true) as typeof process.stdout.write;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function restoreTerminal(): void {
|
|
104
|
+
Object.defineProperty(process.stdin, "isTTY", {
|
|
105
|
+
configurable: true,
|
|
106
|
+
value: originalStdinIsTTY,
|
|
107
|
+
});
|
|
108
|
+
Object.defineProperty(process.stdout, "isTTY", {
|
|
109
|
+
configurable: true,
|
|
110
|
+
value: originalStdoutIsTTY,
|
|
111
|
+
});
|
|
112
|
+
Object.defineProperty(process.stdin, "isRaw", {
|
|
113
|
+
configurable: true,
|
|
114
|
+
value: originalStdinIsRaw,
|
|
115
|
+
});
|
|
116
|
+
Object.defineProperty(process.stdin, "setRawMode", {
|
|
117
|
+
configurable: true,
|
|
118
|
+
value: originalSetRawMode,
|
|
119
|
+
});
|
|
120
|
+
process.stdout.write = originalStdoutWrite;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
describe("vellum retire", () => {
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
126
|
+
rmSync(join(testDir, ".vellum.lock.json"), { force: true });
|
|
127
|
+
process.argv = ["bun", "vellum", "retire"];
|
|
128
|
+
process.exit = ((code?: number) => {
|
|
129
|
+
throw new Error(`process.exit:${code}`);
|
|
130
|
+
}) as typeof process.exit;
|
|
131
|
+
retireLocalMock.mockReset();
|
|
132
|
+
retireLocalMock.mockResolvedValue(undefined);
|
|
133
|
+
setTerminalMode(false);
|
|
134
|
+
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
135
|
+
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
afterEach(() => {
|
|
139
|
+
process.argv = originalArgv;
|
|
140
|
+
process.exit = originalExit;
|
|
141
|
+
restoreTerminal();
|
|
142
|
+
consoleLogSpy.mockRestore();
|
|
143
|
+
consoleErrorSpy.mockRestore();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
afterAll(() => {
|
|
147
|
+
mock.module("../lib/retire-local.js", () => realRetireLocalModule);
|
|
148
|
+
if (originalLockfileDir === undefined) {
|
|
149
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
150
|
+
} else {
|
|
151
|
+
process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
|
|
152
|
+
}
|
|
153
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("--yes retires by unquoted display name and removes by assistant ID", async () => {
|
|
157
|
+
const entry = makeEntry("assistant-1", { name: "Example Assistant" });
|
|
158
|
+
writeLockfile([entry]);
|
|
159
|
+
process.argv = ["bun", "vellum", "retire", "Example", "Assistant", "--yes"];
|
|
160
|
+
|
|
161
|
+
await retire();
|
|
162
|
+
|
|
163
|
+
expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
|
|
164
|
+
expect(loadAllAssistants()).toEqual([]);
|
|
165
|
+
const output = consoleLogSpy.mock.calls.flat().join("\n");
|
|
166
|
+
expect(output).toContain("Name: Example Assistant");
|
|
167
|
+
expect(output).toContain("ID: assistant-1");
|
|
168
|
+
expect(output).toContain(
|
|
169
|
+
"Removed Example Assistant (assistant-1) from config.",
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("non-interactive retire without --yes fails before deleting", async () => {
|
|
174
|
+
const entry = makeEntry("assistant-1", { name: "Example Assistant" });
|
|
175
|
+
writeLockfile([entry]);
|
|
176
|
+
const before = readLockfile();
|
|
177
|
+
process.argv = ["bun", "vellum", "retire", "Example", "Assistant"];
|
|
178
|
+
|
|
179
|
+
await expect(retire()).rejects.toThrow("process.exit:1");
|
|
180
|
+
|
|
181
|
+
expect(retireLocalMock).not.toHaveBeenCalled();
|
|
182
|
+
expect(readLockfile()).toBe(before);
|
|
183
|
+
const output = consoleErrorSpy.mock.calls.flat().join("\n");
|
|
184
|
+
expect(output).toContain("Refusing to retire without confirmation");
|
|
185
|
+
expect(output).toContain("--yes");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("interactive cancel leaves the assistant untouched", async () => {
|
|
189
|
+
const entry = makeEntry("assistant-1", { name: "Example Assistant" });
|
|
190
|
+
writeLockfile([entry]);
|
|
191
|
+
const before = readLockfile();
|
|
192
|
+
setInteractiveTerminal();
|
|
193
|
+
process.argv = ["bun", "vellum", "retire", "Example", "Assistant"];
|
|
194
|
+
|
|
195
|
+
const pending = retire();
|
|
196
|
+
queueMicrotask(() => {
|
|
197
|
+
process.stdin.emit("data", Buffer.from("q"));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await expect(pending).rejects.toThrow("process.exit:1");
|
|
201
|
+
expect(retireLocalMock).not.toHaveBeenCalled();
|
|
202
|
+
expect(readLockfile()).toBe(before);
|
|
203
|
+
expect(consoleLogSpy.mock.calls.flat().join("\n")).toContain(
|
|
204
|
+
"Retire cancelled.",
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("interactive confirmation retires the assistant", async () => {
|
|
209
|
+
const entry = makeEntry("assistant-1", { name: "Example Assistant" });
|
|
210
|
+
writeLockfile([entry]);
|
|
211
|
+
setInteractiveTerminal();
|
|
212
|
+
process.argv = ["bun", "vellum", "retire", "Example", "Assistant"];
|
|
213
|
+
|
|
214
|
+
const pending = retire();
|
|
215
|
+
queueMicrotask(() => {
|
|
216
|
+
process.stdin.emit("data", Buffer.from([13]));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await pending;
|
|
220
|
+
expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
|
|
221
|
+
expect(loadAllAssistants()).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("ambiguous display names fail before deleting", async () => {
|
|
225
|
+
writeLockfile([
|
|
226
|
+
makeEntry("assistant-1", { name: "Example Assistant" }),
|
|
227
|
+
makeEntry("assistant-2", { name: "Example Assistant" }),
|
|
228
|
+
]);
|
|
229
|
+
const before = readLockfile();
|
|
230
|
+
process.argv = ["bun", "vellum", "retire", "Example", "Assistant", "--yes"];
|
|
231
|
+
|
|
232
|
+
await expect(retire()).rejects.toThrow("process.exit:1");
|
|
233
|
+
|
|
234
|
+
expect(retireLocalMock).not.toHaveBeenCalled();
|
|
235
|
+
expect(readLockfile()).toBe(before);
|
|
236
|
+
const output = consoleErrorSpy.mock.calls.flat().join("\n");
|
|
237
|
+
expect(output).toContain("Multiple assistants match 'Example Assistant'");
|
|
238
|
+
expect(output).toContain("assistant-1");
|
|
239
|
+
expect(output).toContain("assistant-2");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
mock,
|
|
8
|
+
spyOn,
|
|
9
|
+
test,
|
|
10
|
+
} from "bun:test";
|
|
11
|
+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import * as assistantConfig from "../lib/assistant-config.js";
|
|
16
|
+
import * as docker from "../lib/docker.js";
|
|
17
|
+
import * as guardianToken from "../lib/guardian-token.js";
|
|
18
|
+
import * as local from "../lib/local.js";
|
|
19
|
+
import * as ngrok from "../lib/ngrok.js";
|
|
20
|
+
import * as processLib from "../lib/process.js";
|
|
21
|
+
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
22
|
+
|
|
23
|
+
const realAssistantConfig = { ...assistantConfig };
|
|
24
|
+
const realDocker = { ...docker };
|
|
25
|
+
const realGuardianToken = { ...guardianToken };
|
|
26
|
+
const realLocal = { ...local };
|
|
27
|
+
const realNgrok = { ...ngrok };
|
|
28
|
+
const realProcessLib = { ...processLib };
|
|
29
|
+
|
|
30
|
+
const resolveTargetAssistantMock = mock<
|
|
31
|
+
typeof assistantConfig.resolveTargetAssistant
|
|
32
|
+
>();
|
|
33
|
+
const saveAssistantEntryMock = mock<typeof assistantConfig.saveAssistantEntry>(
|
|
34
|
+
() => {},
|
|
35
|
+
);
|
|
36
|
+
const getDaemonPidPathMock = mock<typeof assistantConfig.getDaemonPidPath>(
|
|
37
|
+
(resources) => join(resources!.instanceDir, ".vellum", "daemon.pid"),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
mock.module("../lib/assistant-config.js", () => ({
|
|
41
|
+
...realAssistantConfig,
|
|
42
|
+
resolveTargetAssistant: resolveTargetAssistantMock,
|
|
43
|
+
saveAssistantEntry: saveAssistantEntryMock,
|
|
44
|
+
getDaemonPidPath: getDaemonPidPathMock,
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const dockerResourceNamesMock = mock<typeof docker.dockerResourceNames>(
|
|
48
|
+
realDocker.dockerResourceNames,
|
|
49
|
+
);
|
|
50
|
+
const wakeContainersMock = mock<typeof docker.wakeContainers>(async () => {});
|
|
51
|
+
|
|
52
|
+
mock.module("../lib/docker.js", () => ({
|
|
53
|
+
...realDocker,
|
|
54
|
+
dockerResourceNames: dockerResourceNamesMock,
|
|
55
|
+
wakeContainers: wakeContainersMock,
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
const seedGuardianTokenFromSiblingEnvMock = mock<
|
|
59
|
+
typeof guardianToken.seedGuardianTokenFromSiblingEnv
|
|
60
|
+
>(() => false);
|
|
61
|
+
|
|
62
|
+
mock.module("../lib/guardian-token.js", () => ({
|
|
63
|
+
...realGuardianToken,
|
|
64
|
+
seedGuardianTokenFromSiblingEnv: seedGuardianTokenFromSiblingEnvMock,
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
const resolveProcessStateMock = mock<typeof processLib.resolveProcessState>(
|
|
68
|
+
async (_pidFile, _port, label) => ({
|
|
69
|
+
status: "healthy",
|
|
70
|
+
pid: label === "Gateway" ? 456 : 123,
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
const stopProcessByPidFileMock = mock<typeof processLib.stopProcessByPidFile>(
|
|
74
|
+
async () => true,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
mock.module("../lib/process", () => ({
|
|
78
|
+
...realProcessLib,
|
|
79
|
+
resolveProcessState: resolveProcessStateMock,
|
|
80
|
+
stopProcessByPidFile: stopProcessByPidFileMock,
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
const generateLocalSigningKeyMock = mock<typeof local.generateLocalSigningKey>(
|
|
84
|
+
() => "generated-bootstrap-secret",
|
|
85
|
+
);
|
|
86
|
+
const isAssistantWatchModeAvailableMock = mock<
|
|
87
|
+
typeof local.isAssistantWatchModeAvailable
|
|
88
|
+
>(() => false);
|
|
89
|
+
const isGatewayWatchModeAvailableMock = mock<
|
|
90
|
+
typeof local.isGatewayWatchModeAvailable
|
|
91
|
+
>(() => false);
|
|
92
|
+
const startLocalDaemonMock = mock<typeof local.startLocalDaemon>(async () => {});
|
|
93
|
+
const startGatewayMock = mock<typeof local.startGateway>(
|
|
94
|
+
async () => "http://127.0.0.1:7830",
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
mock.module("../lib/local", () => ({
|
|
98
|
+
...realLocal,
|
|
99
|
+
generateLocalSigningKey: generateLocalSigningKeyMock,
|
|
100
|
+
isAssistantWatchModeAvailable: isAssistantWatchModeAvailableMock,
|
|
101
|
+
isGatewayWatchModeAvailable: isGatewayWatchModeAvailableMock,
|
|
102
|
+
startLocalDaemon: startLocalDaemonMock,
|
|
103
|
+
startGateway: startGatewayMock,
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
const maybeStartNgrokTunnelMock = mock<typeof ngrok.maybeStartNgrokTunnel>(
|
|
107
|
+
async () => null,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
mock.module("../lib/ngrok", () => ({
|
|
111
|
+
...realNgrok,
|
|
112
|
+
maybeStartNgrokTunnel: maybeStartNgrokTunnelMock,
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
const { wake } = await import("../commands/wake.js");
|
|
116
|
+
|
|
117
|
+
let tempDir: string;
|
|
118
|
+
let originalArgv: string[];
|
|
119
|
+
let logSpy: ReturnType<typeof spyOn>;
|
|
120
|
+
|
|
121
|
+
function makeLocalEntry(): AssistantEntry {
|
|
122
|
+
tempDir = mkdtempSync(join(tmpdir(), "vellum-wake-test-"));
|
|
123
|
+
mkdirSync(join(tempDir, ".vellum"), { recursive: true });
|
|
124
|
+
return {
|
|
125
|
+
assistantId: "local-assistant",
|
|
126
|
+
runtimeUrl: "http://127.0.0.1:7830",
|
|
127
|
+
cloud: "local",
|
|
128
|
+
resources: {
|
|
129
|
+
instanceDir: tempDir,
|
|
130
|
+
daemonPort: 7821,
|
|
131
|
+
gatewayPort: 7830,
|
|
132
|
+
qdrantPort: 6333,
|
|
133
|
+
cesPort: 7822,
|
|
134
|
+
signingKey: "existing-signing-key",
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
beforeEach(() => {
|
|
140
|
+
originalArgv = [...process.argv];
|
|
141
|
+
tempDir = "";
|
|
142
|
+
process.argv = ["bun", "vellum", "wake", "--watch", "local-assistant"];
|
|
143
|
+
logSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
144
|
+
|
|
145
|
+
const entry = makeLocalEntry();
|
|
146
|
+
resolveTargetAssistantMock.mockReset();
|
|
147
|
+
resolveTargetAssistantMock.mockReturnValue(entry);
|
|
148
|
+
saveAssistantEntryMock.mockReset();
|
|
149
|
+
getDaemonPidPathMock.mockReset();
|
|
150
|
+
getDaemonPidPathMock.mockImplementation((resources) =>
|
|
151
|
+
join(resources!.instanceDir, ".vellum", "daemon.pid"),
|
|
152
|
+
);
|
|
153
|
+
resolveProcessStateMock.mockReset();
|
|
154
|
+
resolveProcessStateMock.mockImplementation(async (_pidFile, _port, label) => ({
|
|
155
|
+
status: "healthy",
|
|
156
|
+
pid: label === "Gateway" ? 456 : 123,
|
|
157
|
+
}));
|
|
158
|
+
stopProcessByPidFileMock.mockReset();
|
|
159
|
+
stopProcessByPidFileMock.mockResolvedValue(true);
|
|
160
|
+
generateLocalSigningKeyMock.mockReset();
|
|
161
|
+
generateLocalSigningKeyMock.mockReturnValue("generated-bootstrap-secret");
|
|
162
|
+
isAssistantWatchModeAvailableMock.mockReset();
|
|
163
|
+
isAssistantWatchModeAvailableMock.mockReturnValue(false);
|
|
164
|
+
isGatewayWatchModeAvailableMock.mockReset();
|
|
165
|
+
isGatewayWatchModeAvailableMock.mockReturnValue(false);
|
|
166
|
+
startLocalDaemonMock.mockReset();
|
|
167
|
+
startLocalDaemonMock.mockResolvedValue(undefined);
|
|
168
|
+
startGatewayMock.mockReset();
|
|
169
|
+
startGatewayMock.mockResolvedValue("http://127.0.0.1:7830");
|
|
170
|
+
seedGuardianTokenFromSiblingEnvMock.mockReset();
|
|
171
|
+
seedGuardianTokenFromSiblingEnvMock.mockReturnValue(false);
|
|
172
|
+
maybeStartNgrokTunnelMock.mockReset();
|
|
173
|
+
maybeStartNgrokTunnelMock.mockResolvedValue(null);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
afterEach(() => {
|
|
177
|
+
process.argv = originalArgv;
|
|
178
|
+
logSpy.mockRestore();
|
|
179
|
+
if (tempDir) {
|
|
180
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
afterAll(() => {
|
|
185
|
+
mock.module("../lib/assistant-config.js", () => realAssistantConfig);
|
|
186
|
+
mock.module("../lib/docker.js", () => realDocker);
|
|
187
|
+
mock.module("../lib/guardian-token.js", () => realGuardianToken);
|
|
188
|
+
mock.module("../lib/process", () => realProcessLib);
|
|
189
|
+
mock.module("../lib/local", () => realLocal);
|
|
190
|
+
mock.module("../lib/ngrok", () => realNgrok);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("vellum wake", () => {
|
|
194
|
+
test("restarts a running gateway without watch mode when backfilling the bootstrap secret", async () => {
|
|
195
|
+
await wake();
|
|
196
|
+
|
|
197
|
+
expect(saveAssistantEntryMock).toHaveBeenCalledWith(
|
|
198
|
+
expect.objectContaining({
|
|
199
|
+
guardianBootstrapSecret: "generated-bootstrap-secret",
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
expect(stopProcessByPidFileMock).toHaveBeenCalledWith(
|
|
203
|
+
join(tempDir, ".vellum", "gateway.pid"),
|
|
204
|
+
"gateway",
|
|
205
|
+
);
|
|
206
|
+
expect(startGatewayMock).toHaveBeenCalledWith(
|
|
207
|
+
false,
|
|
208
|
+
expect.objectContaining({ instanceDir: tempDir }),
|
|
209
|
+
{
|
|
210
|
+
signingKey: "existing-signing-key",
|
|
211
|
+
bootstrapSecret: "generated-bootstrap-secret",
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
});
|
package/src/commands/backup.ts
CHANGED
|
@@ -93,6 +93,7 @@ export async function backup(): Promise<void> {
|
|
|
93
93
|
const freshToken = await leaseGuardianToken(
|
|
94
94
|
entry.runtimeUrl,
|
|
95
95
|
entry.assistantId,
|
|
96
|
+
entry.guardianBootstrapSecret,
|
|
96
97
|
);
|
|
97
98
|
accessToken = freshToken.accessToken;
|
|
98
99
|
} catch (err) {
|
|
@@ -129,6 +130,7 @@ export async function backup(): Promise<void> {
|
|
|
129
130
|
const freshToken = await leaseGuardianToken(
|
|
130
131
|
entry.runtimeUrl,
|
|
131
132
|
entry.assistantId,
|
|
133
|
+
entry.guardianBootstrapSecret,
|
|
132
134
|
);
|
|
133
135
|
refreshedToken = freshToken.accessToken;
|
|
134
136
|
} catch {
|
package/src/commands/client.ts
CHANGED
|
@@ -278,18 +278,29 @@ async function maybeHydratePlatformAssistantName(
|
|
|
278
278
|
}
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
const SPA_BASE = "/assistant/";
|
|
282
|
+
|
|
281
283
|
/**
|
|
282
|
-
*
|
|
284
|
+
* Locate the pre-built @vellumai/web dist directory.
|
|
283
285
|
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
* `--interface web` path as source-checkout-only.
|
|
286
|
+
* Resolution order:
|
|
287
|
+
* 1. npm-installed package — require.resolve('@vellumai/web/package.json')
|
|
288
|
+
* 2. Source checkout — walk up from cli/ to find apps/web/dist/
|
|
288
289
|
*/
|
|
289
|
-
function
|
|
290
|
+
function findWebDistDir(): string | null {
|
|
291
|
+
try {
|
|
292
|
+
const pkgPath = require.resolve("@vellumai/web/package.json");
|
|
293
|
+
const distDir = path.join(path.dirname(pkgPath), "dist");
|
|
294
|
+
if (existsSync(path.join(distDir, "index.html"))) {
|
|
295
|
+
return distDir;
|
|
296
|
+
}
|
|
297
|
+
} catch {
|
|
298
|
+
// Package not installed; try source checkout.
|
|
299
|
+
}
|
|
300
|
+
|
|
290
301
|
let dir = import.meta.dir;
|
|
291
302
|
for (let depth = 0; depth < 8; depth++) {
|
|
292
|
-
const candidate = path.join(dir, "
|
|
303
|
+
const candidate = path.join(dir, "apps", "web", "dist", "index.html");
|
|
293
304
|
if (existsSync(candidate)) {
|
|
294
305
|
return path.dirname(candidate);
|
|
295
306
|
}
|
|
@@ -300,42 +311,61 @@ function findClientsWebDir(): string | null {
|
|
|
300
311
|
return null;
|
|
301
312
|
}
|
|
302
313
|
|
|
303
|
-
/**
|
|
304
|
-
* Spawn the `clients/web` package's `local` script and proxy its lifecycle.
|
|
305
|
-
*
|
|
306
|
-
* The web client is deliberately not declared as a dependency of `@vellumai/cli`:
|
|
307
|
-
* the CLI is published, the web package is not. Locating it on disk and
|
|
308
|
-
* shelling out keeps the two packages independent.
|
|
309
|
-
*/
|
|
310
314
|
async function runWebInterface(): Promise<void> {
|
|
311
|
-
const
|
|
312
|
-
if (!
|
|
315
|
+
const distDir = findWebDistDir();
|
|
316
|
+
if (!distDir) {
|
|
313
317
|
console.error(
|
|
314
318
|
`${ANSI.bold}--interface web${ANSI.reset}: unable to locate ` +
|
|
315
|
-
|
|
316
|
-
`
|
|
319
|
+
`@vellumai/web assets.\n\n` +
|
|
320
|
+
` npm/bunx install: npm install @vellumai/web\n` +
|
|
321
|
+
` source checkout: cd apps/web && VITE_PLATFORM_MODE=false bun run build`,
|
|
317
322
|
);
|
|
318
323
|
process.exit(1);
|
|
319
324
|
}
|
|
320
325
|
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
326
|
+
const indexHtml = await Bun.file(path.join(distDir, "index.html")).text();
|
|
327
|
+
|
|
328
|
+
const server = Bun.serve({
|
|
329
|
+
port: 3000,
|
|
330
|
+
hostname: "127.0.0.1",
|
|
331
|
+
fetch: async (req) => {
|
|
332
|
+
const url = new URL(req.url);
|
|
333
|
+
const { pathname } = url;
|
|
334
|
+
|
|
335
|
+
if (pathname === "/") {
|
|
336
|
+
return Response.redirect(SPA_BASE, 302);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (pathname.startsWith(SPA_BASE)) {
|
|
340
|
+
const relPath = pathname.slice(SPA_BASE.length);
|
|
341
|
+
if (relPath) {
|
|
342
|
+
const filePath = path.join(distDir, relPath);
|
|
343
|
+
const file = Bun.file(filePath);
|
|
344
|
+
if (await file.exists()) {
|
|
345
|
+
return new Response(file);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return new Response(indexHtml, {
|
|
349
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return new Response("Not Found", { status: 404 });
|
|
354
|
+
},
|
|
325
355
|
});
|
|
326
356
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
357
|
+
console.log(
|
|
358
|
+
`Vellum web interface: http://${server.hostname}:${server.port}${SPA_BASE}`,
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
const shutdown = (): void => {
|
|
362
|
+
server.stop();
|
|
363
|
+
process.exit(0);
|
|
333
364
|
};
|
|
334
|
-
process.on("SIGINT",
|
|
335
|
-
process.on("SIGTERM",
|
|
365
|
+
process.on("SIGINT", shutdown);
|
|
366
|
+
process.on("SIGTERM", shutdown);
|
|
336
367
|
|
|
337
|
-
|
|
338
|
-
process.exit(typeof exitCode === "number" ? exitCode : 0);
|
|
368
|
+
await new Promise(() => {});
|
|
339
369
|
}
|
|
340
370
|
|
|
341
371
|
export async function client(): Promise<void> {
|