@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
package/AGENTS.md
CHANGED
|
@@ -16,7 +16,17 @@ Examples: `hatch`, `wake`, `sleep`, `retire`, `ps`, `ssh` belong here. `config`,
|
|
|
16
16
|
|
|
17
17
|
## Assistant targeting convention
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
New or modified commands that act on a specific assistant should accept an assistant display name or ID as an argument. Exact assistant ID matches must win over display-name matches. Unique display-name matches may resolve to the matching assistant ID, but ambiguous display names must fail with an error that lists the matching IDs.
|
|
20
|
+
|
|
21
|
+
Use the shared helpers from `lib/assistant-config` instead of hand-rolled lookup:
|
|
22
|
+
|
|
23
|
+
- `lookupAssistantByIdentifier()` for commands that require an explicit target and need custom error handling.
|
|
24
|
+
- `resolveTargetAssistant()` for commands that may fall back to the active assistant or sole lockfile entry.
|
|
25
|
+
- `formatAssistantReference()` for user-facing output that should include both display name and ID when they differ.
|
|
26
|
+
|
|
27
|
+
Use `parseAssistantTargetArg()` from `lib/assistant-target-args` when parsing command arguments that may contain an unquoted multi-word display name. Do not store raw display names in `activeAssistant`; persist the resolved `assistantId`.
|
|
28
|
+
|
|
29
|
+
New or modified destructive lifecycle commands must be explicit and safe. A command that deletes, retires, archives, or removes assistant state must print the resolved assistant identity before acting and require an interactive confirmation, with a documented `--yes` bypass only for automation or higher-level clients that already own confirmation. Do not expose destructive lifecycle actions as `vellum client` slash commands.
|
|
20
30
|
|
|
21
31
|
## Conventions
|
|
22
32
|
|
|
@@ -53,6 +63,12 @@ The CLI must **never** read from or write to the `.vellum/` directory (e.g. `~/.
|
|
|
53
63
|
|
|
54
64
|
For example, the signing key used for JWT auth between the daemon and gateway is persisted in the lockfile (`resources.signingKey`) so that client actor tokens survive daemon/gateway restarts. On first start (or when the key is missing), the CLI generates a new key via `generateLocalSigningKey()` in `lib/local.ts`, saves it to the lockfile entry, and passes it to both `startLocalDaemon` and `startGateway` as the `ACTOR_TOKEN_SIGNING_KEY` env var. The CLI does **not** read or write to the `.vellum/` directory for signing keys — it uses the lockfile instead.
|
|
55
65
|
|
|
66
|
+
## Process liveness
|
|
67
|
+
|
|
68
|
+
Use `resolveProcessState()` from `lib/process.ts` when checking whether a daemon or gateway should be (re)started. It combines PID existence with an HTTP `/healthz` probe, a readiness grace period, and a [`isVellumProcess()`](https://man7.org/linux/man-pages/man1/ps.1.html) guard against PID reuse — see the function's JSDoc for the full flow.
|
|
69
|
+
|
|
70
|
+
Reserve `isProcessAlive()` for teardown paths (`sleep`, `retire`) where you need to kill a process regardless of its health.
|
|
71
|
+
|
|
56
72
|
## Docker Volume Management
|
|
57
73
|
|
|
58
74
|
The CLI creates and manages six per-instance Docker volumes with strict per-service access boundaries (least-privilege at the container level).
|
package/knip.json
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { checkProviderApiKey } from "../lib/api-key-check.js";
|
|
4
|
+
|
|
5
|
+
const PROVIDER_KEYS = [
|
|
6
|
+
"ANTHROPIC_API_KEY",
|
|
7
|
+
"OPENAI_API_KEY",
|
|
8
|
+
"GEMINI_API_KEY",
|
|
9
|
+
"FIREWORKS_API_KEY",
|
|
10
|
+
"OPENROUTER_API_KEY",
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
for (const key of PROVIDER_KEYS) {
|
|
15
|
+
delete process.env[key];
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const key of PROVIDER_KEYS) {
|
|
21
|
+
delete process.env[key];
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("checkProviderApiKey", () => {
|
|
26
|
+
test("returns hasKey:false when no provider keys are in process.env", () => {
|
|
27
|
+
const result = checkProviderApiKey();
|
|
28
|
+
expect(result.hasKey).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("returns hasKey:false when ANTHROPIC_API_KEY is a placeholder", () => {
|
|
32
|
+
process.env.ANTHROPIC_API_KEY = "sk-ant-...";
|
|
33
|
+
const result = checkProviderApiKey();
|
|
34
|
+
expect(result.hasKey).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("returns hasKey:false when OPENAI_API_KEY is a placeholder", () => {
|
|
38
|
+
process.env.OPENAI_API_KEY = "sk-...";
|
|
39
|
+
const result = checkProviderApiKey();
|
|
40
|
+
expect(result.hasKey).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("returns hasKey:false when key is empty", () => {
|
|
44
|
+
process.env.ANTHROPIC_API_KEY = "";
|
|
45
|
+
const result = checkProviderApiKey();
|
|
46
|
+
expect(result.hasKey).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("returns hasKey:true when ANTHROPIC_API_KEY is a real key", () => {
|
|
50
|
+
process.env.ANTHROPIC_API_KEY = "sk-ant-api03-realkey123";
|
|
51
|
+
const result = checkProviderApiKey();
|
|
52
|
+
expect(result.hasKey).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("returns hasKey:true when OPENAI_API_KEY is a real key", () => {
|
|
56
|
+
process.env.OPENAI_API_KEY = "sk-proj-realkey123";
|
|
57
|
+
const result = checkProviderApiKey();
|
|
58
|
+
expect(result.hasKey).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("returns hasKey:true when GEMINI_API_KEY is a real key", () => {
|
|
62
|
+
process.env.GEMINI_API_KEY = "AIzaSyRealKey123";
|
|
63
|
+
const result = checkProviderApiKey();
|
|
64
|
+
expect(result.hasKey).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("returns hasKey:true when FIREWORKS_API_KEY is a real key", () => {
|
|
68
|
+
process.env.FIREWORKS_API_KEY = "fw-realkey123";
|
|
69
|
+
const result = checkProviderApiKey();
|
|
70
|
+
expect(result.hasKey).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("returns hasKey:true when OPENROUTER_API_KEY is a real key", () => {
|
|
74
|
+
process.env.OPENROUTER_API_KEY = "sk-or-realkey123";
|
|
75
|
+
const result = checkProviderApiKey();
|
|
76
|
+
expect(result.hasKey).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -162,6 +162,16 @@ beforeEach(() => {
|
|
|
162
162
|
});
|
|
163
163
|
getBackupsDirMock.mockReset();
|
|
164
164
|
getBackupsDirMock.mockReturnValue("/tmp/backups-default");
|
|
165
|
+
loadGuardianTokenSpy.mockReset();
|
|
166
|
+
loadGuardianTokenSpy.mockReturnValue({
|
|
167
|
+
accessToken: "local-token",
|
|
168
|
+
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
169
|
+
} as unknown as ReturnType<typeof guardianToken.loadGuardianToken>);
|
|
170
|
+
leaseGuardianTokenSpy.mockReset();
|
|
171
|
+
leaseGuardianTokenSpy.mockResolvedValue({
|
|
172
|
+
accessToken: "leased-token",
|
|
173
|
+
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
174
|
+
} as unknown as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>);
|
|
165
175
|
mkdirSyncMock.mockReset();
|
|
166
176
|
mkdirSyncMock.mockImplementation((() => undefined) as never);
|
|
167
177
|
writeFileSyncMock.mockReset();
|
|
@@ -207,6 +217,34 @@ function mockGcsDownload(body: Uint8Array, ok = true, status = 200) {
|
|
|
207
217
|
}) as unknown as typeof globalThis.fetch;
|
|
208
218
|
}
|
|
209
219
|
|
|
220
|
+
describe("vellum backup <local>: guardian bootstrap secret", () => {
|
|
221
|
+
test("passes the lockfile bootstrap secret when leasing a fresh guardian token", async () => {
|
|
222
|
+
const localEntry = {
|
|
223
|
+
assistantId: "local-assistant",
|
|
224
|
+
runtimeUrl: "http://127.0.0.1:7830",
|
|
225
|
+
cloud: "local",
|
|
226
|
+
guardianBootstrapSecret: "bootstrap-secret-value",
|
|
227
|
+
} satisfies assistantConfig.AssistantEntry;
|
|
228
|
+
findAssistantByNameMock.mockReturnValue(localEntry);
|
|
229
|
+
loadGuardianTokenSpy.mockReturnValue(null);
|
|
230
|
+
setArgv("my-local", "--output", "/tmp/local-backup.vbundle");
|
|
231
|
+
|
|
232
|
+
globalThis.fetch = mock(async () => {
|
|
233
|
+
return new Response(new Uint8Array([1, 2, 3]), {
|
|
234
|
+
status: 200,
|
|
235
|
+
});
|
|
236
|
+
}) as unknown as typeof globalThis.fetch;
|
|
237
|
+
|
|
238
|
+
await backup();
|
|
239
|
+
|
|
240
|
+
expect(leaseGuardianTokenSpy).toHaveBeenCalledWith(
|
|
241
|
+
"http://127.0.0.1:7830",
|
|
242
|
+
"local-assistant",
|
|
243
|
+
"bootstrap-secret-value",
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
210
248
|
describe("vellum backup <platform-managed>: GCS happy path", () => {
|
|
211
249
|
test("requests upload URL → kicks off runtime export → polls → downloads from GCS → writes file", async () => {
|
|
212
250
|
findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
|
|
@@ -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
|
+
});
|