@vellumai/cli 0.8.5 → 0.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +6 -0
- package/bun.lock +8 -0
- package/knip.json +6 -1
- package/node_modules/@vellumai/environments/bun.lock +24 -0
- package/node_modules/@vellumai/environments/package.json +18 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
- package/node_modules/@vellumai/environments/src/index.ts +11 -0
- package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
- package/node_modules/@vellumai/environments/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/bun.lock +29 -0
- package/node_modules/@vellumai/local-mode/package.json +21 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +93 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +59 -0
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +67 -0
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
- package/node_modules/@vellumai/local-mode/src/hatch.ts +74 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +26 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +131 -0
- package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
- package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
- package/package.json +12 -1
- package/src/__tests__/backup.test.ts +38 -0
- package/src/__tests__/env-drift.test.ts +32 -44
- package/src/__tests__/flags.test.ts +248 -0
- package/src/__tests__/multi-local.test.ts +1 -1
- package/src/__tests__/orphan-detection.test.ts +8 -6
- package/src/__tests__/recover.test.ts +307 -0
- package/src/__tests__/segments-to-plain-text.test.ts +37 -0
- package/src/__tests__/wake.test.ts +215 -0
- package/src/commands/backup.ts +2 -0
- package/src/commands/client.ts +471 -30
- package/src/commands/env.ts +1 -1
- package/src/commands/flags.ts +269 -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 +3 -23
- 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/components/DefaultMainScreen.tsx +16 -1
- package/src/index.ts +6 -0
- package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
- package/src/lib/__tests__/step-runner.test.ts +49 -1
- package/src/lib/assistant-config.ts +16 -3
- package/src/lib/config-utils.ts +24 -3
- package/src/lib/docker.ts +57 -7
- package/src/lib/environments/__tests__/paths.test.ts +2 -1
- package/src/lib/environments/__tests__/seeds.test.ts +2 -1
- package/src/lib/environments/paths.ts +1 -1
- package/src/lib/environments/resolve.ts +2 -5
- package/src/lib/guardian-token.ts +12 -5
- package/src/lib/hatch-local.ts +75 -33
- package/src/lib/http-client.ts +1 -3
- package/src/lib/lifecycle-reporter.ts +31 -0
- 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/process.ts +109 -39
- package/src/lib/retire-local.ts +28 -14
- package/src/lib/segments-to-plain-text.ts +35 -0
- package/src/lib/step-runner.ts +67 -7
- package/src/lib/sync-cloud-assistants.ts +17 -0
- /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeAll,
|
|
5
|
+
beforeEach,
|
|
6
|
+
describe,
|
|
7
|
+
expect,
|
|
8
|
+
mock,
|
|
9
|
+
spyOn,
|
|
10
|
+
test,
|
|
11
|
+
} from "bun:test";
|
|
12
|
+
import {
|
|
13
|
+
existsSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
mkdtempSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from "node:fs";
|
|
19
|
+
import { homedir, tmpdir } from "node:os";
|
|
20
|
+
import { basename, join } from "node:path";
|
|
21
|
+
|
|
22
|
+
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
23
|
+
import * as localModule from "../lib/local.js";
|
|
24
|
+
import * as stepRunnerModule from "../lib/step-runner.js";
|
|
25
|
+
|
|
26
|
+
// Captured real exports — afterAll restores these so module mocks don't
|
|
27
|
+
// leak into other test files in the same `bun test` run.
|
|
28
|
+
const realLocal = {
|
|
29
|
+
generateLocalSigningKey: localModule.generateLocalSigningKey,
|
|
30
|
+
startLocalDaemon: localModule.startLocalDaemon,
|
|
31
|
+
startGateway: localModule.startGateway,
|
|
32
|
+
};
|
|
33
|
+
const realExec = stepRunnerModule.exec;
|
|
34
|
+
|
|
35
|
+
// Prevent real daemon / gateway from starting
|
|
36
|
+
const startLocalDaemonMock = mock(async () => {});
|
|
37
|
+
const startGatewayMock = mock(async () => {});
|
|
38
|
+
|
|
39
|
+
// Capture exec calls without running real tar
|
|
40
|
+
const execMock = mock(async (_cmd: string, _args: string[]) => {});
|
|
41
|
+
|
|
42
|
+
beforeAll(() => {
|
|
43
|
+
mock.module("../lib/local.js", () => ({
|
|
44
|
+
generateLocalSigningKey: () => "deadbeefdeadbeefdeadbeefdeadbeef",
|
|
45
|
+
startLocalDaemon: startLocalDaemonMock,
|
|
46
|
+
startGateway: startGatewayMock,
|
|
47
|
+
}));
|
|
48
|
+
mock.module("../lib/step-runner.js", () => ({ exec: execMock }));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterAll(() => {
|
|
52
|
+
mock.module("../lib/local.js", () => realLocal);
|
|
53
|
+
mock.module("../lib/step-runner.js", () => ({ exec: realExec }));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
import { recover } from "../commands/recover.js";
|
|
57
|
+
|
|
58
|
+
// ── Test fixtures ─────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
const testDir = mkdtempSync(join(tmpdir(), "cli-recover-test-"));
|
|
61
|
+
const originalArgv = [...process.argv];
|
|
62
|
+
const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
|
|
63
|
+
const originalXdgData = process.env.XDG_DATA_HOME;
|
|
64
|
+
|
|
65
|
+
// Directories that getRetiredDir() will use when XDG_DATA_HOME is overridden
|
|
66
|
+
const retiredDir = join(testDir, "vellum", "retired");
|
|
67
|
+
|
|
68
|
+
function makeEntry(assistantId: string, instanceDir: string): AssistantEntry {
|
|
69
|
+
return {
|
|
70
|
+
assistantId,
|
|
71
|
+
runtimeUrl: "http://127.0.0.1:7831",
|
|
72
|
+
cloud: "local",
|
|
73
|
+
resources: {
|
|
74
|
+
instanceDir,
|
|
75
|
+
daemonPort: 7801,
|
|
76
|
+
gatewayPort: 7831,
|
|
77
|
+
qdrantPort: 6334,
|
|
78
|
+
cesPort: 7790,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function writeArchiveFixtures(
|
|
84
|
+
name: string,
|
|
85
|
+
entry: AssistantEntry,
|
|
86
|
+
): {
|
|
87
|
+
archivePath: string;
|
|
88
|
+
metadataPath: string;
|
|
89
|
+
extractedPath: string;
|
|
90
|
+
} {
|
|
91
|
+
mkdirSync(retiredDir, { recursive: true });
|
|
92
|
+
const archivePath = join(retiredDir, `${name}.tar.gz`);
|
|
93
|
+
const metadataPath = join(retiredDir, `${name}.json`);
|
|
94
|
+
// The staging dir is what tar extracts — <archive>.staging relative to retiredDir
|
|
95
|
+
const extractedPath = join(retiredDir, basename(archivePath) + ".staging");
|
|
96
|
+
|
|
97
|
+
// Write a placeholder archive file (exec is mocked; content doesn't matter)
|
|
98
|
+
writeFileSync(archivePath, "");
|
|
99
|
+
writeFileSync(metadataPath, JSON.stringify(entry, null, 2) + "\n");
|
|
100
|
+
// Create the staging dir that tar would have created
|
|
101
|
+
mkdirSync(extractedPath, { recursive: true });
|
|
102
|
+
|
|
103
|
+
return { archivePath, metadataPath, extractedPath };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let consoleLogSpy: ReturnType<typeof spyOn>;
|
|
107
|
+
let consoleErrorSpy: ReturnType<typeof spyOn>;
|
|
108
|
+
let exitSpy: ReturnType<typeof spyOn>;
|
|
109
|
+
|
|
110
|
+
beforeEach(() => {
|
|
111
|
+
// Route lockfile and retired archives to the temp directory
|
|
112
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
113
|
+
process.env.XDG_DATA_HOME = testDir;
|
|
114
|
+
// Write an empty lockfile so saveAssistantEntry has a dir to write to
|
|
115
|
+
mkdirSync(testDir, { recursive: true });
|
|
116
|
+
writeFileSync(
|
|
117
|
+
join(testDir, ".vellum.lock.json"),
|
|
118
|
+
JSON.stringify({ assistants: [] }) + "\n",
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
122
|
+
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
123
|
+
exitSpy = spyOn(process, "exit").mockImplementation((_code?: number) => {
|
|
124
|
+
throw new Error(`process.exit(${_code})`);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
execMock.mockClear();
|
|
128
|
+
startLocalDaemonMock.mockClear();
|
|
129
|
+
startGatewayMock.mockClear();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
afterEach(() => {
|
|
133
|
+
process.argv = [...originalArgv];
|
|
134
|
+
process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
|
|
135
|
+
process.env.XDG_DATA_HOME = originalXdgData;
|
|
136
|
+
consoleLogSpy.mockRestore();
|
|
137
|
+
consoleErrorSpy.mockRestore();
|
|
138
|
+
exitSpy.mockRestore();
|
|
139
|
+
// Clean up per-test artifacts inside testDir/vellum/
|
|
140
|
+
if (existsSync(join(testDir, "vellum"))) {
|
|
141
|
+
rmSync(join(testDir, "vellum"), { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Runs after all tests finish
|
|
146
|
+
afterAll(() => {
|
|
147
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
148
|
+
process.argv = [...originalArgv];
|
|
149
|
+
if (originalLockfileDir !== undefined) {
|
|
150
|
+
process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
|
|
151
|
+
} else {
|
|
152
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
153
|
+
}
|
|
154
|
+
if (originalXdgData !== undefined) {
|
|
155
|
+
process.env.XDG_DATA_HOME = originalXdgData;
|
|
156
|
+
} else {
|
|
157
|
+
delete process.env.XDG_DATA_HOME;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
describe("recover --help", () => {
|
|
164
|
+
test("prints usage, description, and examples then exits 0", async () => {
|
|
165
|
+
process.argv = ["bun", "vellum", "recover", "--help"];
|
|
166
|
+
await expect(recover()).rejects.toThrow("process.exit(0)");
|
|
167
|
+
const output = consoleLogSpy.mock.calls.flat().join("\n");
|
|
168
|
+
expect(output).toContain("Usage: vellum recover <name>");
|
|
169
|
+
expect(output).toContain("Examples:");
|
|
170
|
+
expect(output).toContain("vellum recover");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("recover error cases", () => {
|
|
175
|
+
test("exits 1 when no name is given", async () => {
|
|
176
|
+
process.argv = ["bun", "vellum", "recover"];
|
|
177
|
+
await expect(recover()).rejects.toThrow("process.exit(1)");
|
|
178
|
+
expect(consoleErrorSpy.mock.calls[0][0]).toContain("Usage:");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("exits 1 when archive is missing", async () => {
|
|
182
|
+
process.argv = ["bun", "vellum", "recover", "ghost-assistant"];
|
|
183
|
+
await expect(recover()).rejects.toThrow("process.exit(1)");
|
|
184
|
+
expect(consoleErrorSpy.mock.calls[0][0]).toContain(
|
|
185
|
+
"No retired archive found for 'ghost-assistant'",
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("throws when metadata has no resources", async () => {
|
|
190
|
+
const name = "no-resources";
|
|
191
|
+
mkdirSync(retiredDir, { recursive: true });
|
|
192
|
+
writeFileSync(join(retiredDir, `${name}.tar.gz`), "");
|
|
193
|
+
const entry: Partial<AssistantEntry> = {
|
|
194
|
+
assistantId: name,
|
|
195
|
+
runtimeUrl: "http://127.0.0.1:7831",
|
|
196
|
+
cloud: "local",
|
|
197
|
+
// resources intentionally omitted
|
|
198
|
+
};
|
|
199
|
+
writeFileSync(
|
|
200
|
+
join(retiredDir, `${name}.json`),
|
|
201
|
+
JSON.stringify(entry, null, 2) + "\n",
|
|
202
|
+
);
|
|
203
|
+
process.argv = ["bun", "vellum", "recover", name];
|
|
204
|
+
await expect(recover()).rejects.toThrow("missing resource configuration");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("exits 1 when target .vellum/ already exists", async () => {
|
|
208
|
+
const name = "already-exists";
|
|
209
|
+
const instanceDir = join(testDir, name);
|
|
210
|
+
const entry = makeEntry(name, instanceDir);
|
|
211
|
+
writeArchiveFixtures(name, entry);
|
|
212
|
+
// Pre-create the collision path that recover checks
|
|
213
|
+
mkdirSync(join(instanceDir, ".vellum"), { recursive: true });
|
|
214
|
+
process.argv = ["bun", "vellum", "recover", name];
|
|
215
|
+
await expect(recover()).rejects.toThrow("process.exit(1)");
|
|
216
|
+
expect(consoleErrorSpy.mock.calls[0][0]).toContain("already exists");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("recover extraction path — default instance (instanceDir === homedir())", () => {
|
|
221
|
+
test("extracts to retiredDir and renames staging dir to instanceDir/.vellum", async () => {
|
|
222
|
+
const name = "default-instance";
|
|
223
|
+
// Default instance: instanceDir is the real home directory
|
|
224
|
+
const entry = makeEntry(name, homedir());
|
|
225
|
+
const { archivePath, extractedPath } = writeArchiveFixtures(name, entry);
|
|
226
|
+
|
|
227
|
+
const expectedTargetDir = join(homedir(), ".vellum");
|
|
228
|
+
|
|
229
|
+
process.argv = ["bun", "vellum", "recover", name];
|
|
230
|
+
await recover();
|
|
231
|
+
|
|
232
|
+
// exec must have been called with -C retiredDir, NOT -C homedir()
|
|
233
|
+
expect(execMock).toHaveBeenCalledTimes(1);
|
|
234
|
+
const [cmd, args] = execMock.mock.calls[0] as [string, string[]];
|
|
235
|
+
expect(cmd).toBe("tar");
|
|
236
|
+
expect(args).toContain("-C");
|
|
237
|
+
const cIndex = args.indexOf("-C");
|
|
238
|
+
expect(args[cIndex + 1]).toBe(retiredDir);
|
|
239
|
+
expect(args[cIndex + 1]).not.toBe(homedir());
|
|
240
|
+
|
|
241
|
+
// Staging dir was renamed to the correct target
|
|
242
|
+
expect(existsSync(extractedPath)).toBe(false);
|
|
243
|
+
expect(existsSync(expectedTargetDir)).toBe(true);
|
|
244
|
+
|
|
245
|
+
// Archive and metadata were cleaned up
|
|
246
|
+
expect(existsSync(archivePath)).toBe(false);
|
|
247
|
+
|
|
248
|
+
// Daemon and gateway were started
|
|
249
|
+
expect(startLocalDaemonMock).toHaveBeenCalledTimes(1);
|
|
250
|
+
expect(startGatewayMock).toHaveBeenCalledTimes(1);
|
|
251
|
+
|
|
252
|
+
// Clean up so we don't leave a .vellum dir in the real home dir
|
|
253
|
+
rmSync(expectedTargetDir, { recursive: true, force: true });
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("recover extraction path — named instance (instanceDir !== homedir())", () => {
|
|
258
|
+
test("extracts to retiredDir and renames staging dir to instanceDir directly", async () => {
|
|
259
|
+
const name = "named-instance";
|
|
260
|
+
const instanceDir = join(testDir, "custom-location", name);
|
|
261
|
+
const entry = makeEntry(name, instanceDir);
|
|
262
|
+
const { archivePath, extractedPath } = writeArchiveFixtures(name, entry);
|
|
263
|
+
|
|
264
|
+
// Named instance: targetDir is instanceDir itself (not instanceDir/.vellum)
|
|
265
|
+
const expectedTargetDir = instanceDir;
|
|
266
|
+
// Parent dir must exist for renameSync
|
|
267
|
+
mkdirSync(join(testDir, "custom-location"), { recursive: true });
|
|
268
|
+
|
|
269
|
+
process.argv = ["bun", "vellum", "recover", name];
|
|
270
|
+
await recover();
|
|
271
|
+
|
|
272
|
+
// exec must have been called with -C retiredDir
|
|
273
|
+
expect(execMock).toHaveBeenCalledTimes(1);
|
|
274
|
+
const [cmd, args] = execMock.mock.calls[0] as [string, string[]];
|
|
275
|
+
expect(cmd).toBe("tar");
|
|
276
|
+
const cIndex = args.indexOf("-C");
|
|
277
|
+
expect(args[cIndex + 1]).toBe(retiredDir);
|
|
278
|
+
|
|
279
|
+
// Staging dir was renamed to instanceDir (not instanceDir/.vellum)
|
|
280
|
+
expect(existsSync(extractedPath)).toBe(false);
|
|
281
|
+
expect(existsSync(expectedTargetDir)).toBe(true);
|
|
282
|
+
|
|
283
|
+
// Archive cleaned up
|
|
284
|
+
expect(existsSync(archivePath)).toBe(false);
|
|
285
|
+
|
|
286
|
+
// Daemon and gateway were started
|
|
287
|
+
expect(startLocalDaemonMock).toHaveBeenCalledTimes(1);
|
|
288
|
+
expect(startGatewayMock).toHaveBeenCalledTimes(1);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("creates parent directories of instanceDir when they do not exist", async () => {
|
|
292
|
+
const name = "deep-nested-instance";
|
|
293
|
+
// Use a path whose parent directory does not yet exist
|
|
294
|
+
const instanceDir = join(testDir, "new-parent", "deeper", name);
|
|
295
|
+
const entry = makeEntry(name, instanceDir);
|
|
296
|
+
const { archivePath, extractedPath } = writeArchiveFixtures(name, entry);
|
|
297
|
+
|
|
298
|
+
process.argv = ["bun", "vellum", "recover", name];
|
|
299
|
+
await recover();
|
|
300
|
+
|
|
301
|
+
expect(existsSync(extractedPath)).toBe(false);
|
|
302
|
+
expect(existsSync(instanceDir)).toBe(true);
|
|
303
|
+
expect(existsSync(archivePath)).toBe(false);
|
|
304
|
+
|
|
305
|
+
rmSync(instanceDir, { recursive: true, force: true });
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { segmentsToPlainText } from "../lib/segments-to-plain-text.js";
|
|
3
|
+
|
|
4
|
+
describe("segmentsToPlainText", () => {
|
|
5
|
+
test("returns empty string for missing or empty segments", () => {
|
|
6
|
+
// GIVEN a message whose history payload carries no text segments
|
|
7
|
+
// WHEN deriving its flat body
|
|
8
|
+
// THEN it is the empty string (matching the daemon's old empty `content`)
|
|
9
|
+
expect(segmentsToPlainText(undefined)).toBe("");
|
|
10
|
+
expect(segmentsToPlainText([])).toBe("");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("returns a single segment unchanged", () => {
|
|
14
|
+
// GIVEN a plain-text message with one segment
|
|
15
|
+
// WHEN deriving its flat body
|
|
16
|
+
// THEN the segment is returned verbatim
|
|
17
|
+
expect(segmentsToPlainText(["Real reply."])).toBe("Real reply.");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("joins adjacent segments with a single inserted space", () => {
|
|
21
|
+
// GIVEN segments split at a tool_use boundary with no surrounding whitespace
|
|
22
|
+
// WHEN deriving the flat body
|
|
23
|
+
// THEN a single space is inserted between them
|
|
24
|
+
expect(segmentsToPlainText(["before", "after"])).toBe("before after");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("does not double-space when either side already has whitespace", () => {
|
|
28
|
+
// GIVEN segments where one side already ends/starts with whitespace
|
|
29
|
+
// WHEN deriving the flat body
|
|
30
|
+
// THEN no extra space is inserted (mirrors daemon joinWithSpacing)
|
|
31
|
+
expect(segmentsToPlainText(["before ", "after"])).toBe("before after");
|
|
32
|
+
expect(segmentsToPlainText(["before", " after"])).toBe("before after");
|
|
33
|
+
expect(segmentsToPlainText(["line one\n", "line two"])).toBe(
|
|
34
|
+
"line one\nline two",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -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 {
|