@vellumai/cli 0.10.0-dev.202606222147.354e06a → 0.10.0-dev.202606222307.aa8d8c0
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/index.ts +2 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +32 -3
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +5 -2
- package/node_modules/@vellumai/local-mode/src/status.ts +1 -0
- package/node_modules/@vellumai/local-mode/src/upgrade.ts +105 -0
- package/package.json +1 -1
- package/src/__tests__/upgrade-local.test.ts +443 -0
- package/src/commands/roadmap.ts +8 -10
- package/src/commands/upgrade.ts +351 -24
- package/src/lib/assistant-config.ts +4 -0
- package/src/lib/local.ts +204 -24
- package/src/lib/upgrade-lifecycle.ts +29 -16
|
@@ -39,6 +39,8 @@ export { runSleep } from "./sleep";
|
|
|
39
39
|
export type { SleepResult } from "./sleep";
|
|
40
40
|
export { runWake } from "./wake";
|
|
41
41
|
export type { WakeOptions, WakeResult } from "./wake";
|
|
42
|
+
export { runUpgrade } from "./upgrade";
|
|
43
|
+
export type { UpgradeOptions, UpgradeResult } from "./upgrade";
|
|
42
44
|
export { getLocalAssistantStatus } from "./status";
|
|
43
45
|
export type {
|
|
44
46
|
LocalAssistantRuntimeState,
|
|
@@ -65,7 +65,9 @@ describe("parseLockfile", () => {
|
|
|
65
65
|
// modeled fields. It must still be returned, normalized to `local`.
|
|
66
66
|
const parsed = parseLockfile({
|
|
67
67
|
activeAssistant: null,
|
|
68
|
-
assistants: [
|
|
68
|
+
assistants: [
|
|
69
|
+
{ assistantId: "asst_1", localUrl: "http://127.0.0.1:7777" },
|
|
70
|
+
],
|
|
69
71
|
});
|
|
70
72
|
expect(parsed.assistants).toEqual([
|
|
71
73
|
{ assistantId: "asst_1", cloud: "local" },
|
|
@@ -202,9 +204,32 @@ describe("parseLockfile", () => {
|
|
|
202
204
|
expect(assistant?.resources).toBeUndefined();
|
|
203
205
|
});
|
|
204
206
|
|
|
207
|
+
test("keeps local runtime resource fields when well-typed", () => {
|
|
208
|
+
const raw = {
|
|
209
|
+
assistants: [
|
|
210
|
+
{
|
|
211
|
+
assistantId: "asst_1",
|
|
212
|
+
cloud: "local",
|
|
213
|
+
runtimeUrl: "http://a",
|
|
214
|
+
resources: {
|
|
215
|
+
gatewayPort: 7777,
|
|
216
|
+
daemonPort: 7778,
|
|
217
|
+
runtimeVersion: "v0.8.13",
|
|
218
|
+
runtimeInstallDir: "/tmp/vellum/runtime/0.8.13",
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
activeAssistant: null,
|
|
223
|
+
};
|
|
224
|
+
expect(parseLockfile(raw).assistants[0]?.resources).toEqual({
|
|
225
|
+
gatewayPort: 7777,
|
|
226
|
+
daemonPort: 7778,
|
|
227
|
+
runtimeVersion: "v0.8.13",
|
|
228
|
+
runtimeInstallDir: "/tmp/vellum/runtime/0.8.13",
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
205
232
|
test("strips sensitive and host-only fields from resources", () => {
|
|
206
|
-
// The renderer-facing resources shape is ports + instanceDir only; the
|
|
207
|
-
// signing key and other ports are host-only and never cross the boundary.
|
|
208
233
|
const raw = {
|
|
209
234
|
assistants: [
|
|
210
235
|
{
|
|
@@ -214,6 +239,8 @@ describe("parseLockfile", () => {
|
|
|
214
239
|
instanceDir: "/data",
|
|
215
240
|
gatewayPort: 7777,
|
|
216
241
|
daemonPort: 7778,
|
|
242
|
+
runtimeVersion: "v0.8.13",
|
|
243
|
+
runtimeInstallDir: "/tmp/vellum/runtime/0.8.13",
|
|
217
244
|
qdrantPort: 7779,
|
|
218
245
|
cesPort: 7780,
|
|
219
246
|
signingKey: "hunter2",
|
|
@@ -226,6 +253,8 @@ describe("parseLockfile", () => {
|
|
|
226
253
|
instanceDir: "/data",
|
|
227
254
|
gatewayPort: 7777,
|
|
228
255
|
daemonPort: 7778,
|
|
256
|
+
runtimeVersion: "v0.8.13",
|
|
257
|
+
runtimeInstallDir: "/tmp/vellum/runtime/0.8.13",
|
|
229
258
|
});
|
|
230
259
|
});
|
|
231
260
|
|
|
@@ -78,13 +78,16 @@ export function resolveCloud(raw: {
|
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
80
|
* Per-instance resources for a local assistant: the renderer-facing subset of
|
|
81
|
-
* ports
|
|
82
|
-
* signing key) live on the CLI's
|
|
81
|
+
* ports, the instance directory, and the local runtime install metadata.
|
|
82
|
+
* Richer host-only fields (other ports, the signing key) live on the CLI's
|
|
83
|
+
* type and are stripped from this shape.
|
|
83
84
|
*/
|
|
84
85
|
export const LocalAssistantResourcesSchema = z.object({
|
|
85
86
|
instanceDir: z.string().optional(),
|
|
86
87
|
gatewayPort: z.number(),
|
|
87
88
|
daemonPort: z.number(),
|
|
89
|
+
runtimeVersion: z.string().optional(),
|
|
90
|
+
runtimeInstallDir: z.string().optional(),
|
|
88
91
|
});
|
|
89
92
|
export type LocalAssistantResources = z.infer<
|
|
90
93
|
typeof LocalAssistantResourcesSchema
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import type { CliInvocation } from "./util";
|
|
4
|
+
|
|
5
|
+
const UPGRADE_TIMEOUT_MS = 20 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
export type UpgradeResult =
|
|
8
|
+
| { ok: true; version?: string }
|
|
9
|
+
| { ok: false; status: number; error: string };
|
|
10
|
+
|
|
11
|
+
export interface UpgradeOptions {
|
|
12
|
+
version?: string;
|
|
13
|
+
latest?: boolean;
|
|
14
|
+
force?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function extractVersion(output: string): string | undefined {
|
|
18
|
+
const versionPattern = "(v?[0-9]+(?:\\.[0-9]+)*(?:[-+][\\w.-]+)?)";
|
|
19
|
+
const upgraded = output.match(
|
|
20
|
+
new RegExp(`\\bupgraded to\\s+${versionPattern}`, "i"),
|
|
21
|
+
)?.[1];
|
|
22
|
+
if (upgraded) return upgraded;
|
|
23
|
+
|
|
24
|
+
return output.match(new RegExp(`\\bAlready on\\s+${versionPattern}`, "i"))?.[1];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Upgrade a local assistant via the CLI. The CLI owns the full lifecycle
|
|
29
|
+
* (backup, process restart, health wait, rollback on failure); the host
|
|
30
|
+
* bridge only starts it and returns the structured result to the renderer.
|
|
31
|
+
*/
|
|
32
|
+
export function runUpgrade(
|
|
33
|
+
invocation: CliInvocation,
|
|
34
|
+
assistantId: string,
|
|
35
|
+
options?: UpgradeOptions,
|
|
36
|
+
): Promise<UpgradeResult> {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const args = [...invocation.baseArgs, "upgrade", assistantId];
|
|
39
|
+
if (options?.latest) {
|
|
40
|
+
args.push("--latest");
|
|
41
|
+
} else if (options?.version) {
|
|
42
|
+
args.push("--version", options.version);
|
|
43
|
+
}
|
|
44
|
+
if (options?.force) {
|
|
45
|
+
args.push("--force");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const child = spawn(invocation.command, args, {
|
|
49
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
let stdout = "";
|
|
53
|
+
let stderr = "";
|
|
54
|
+
let done = false;
|
|
55
|
+
|
|
56
|
+
const finish = (result: UpgradeResult) => {
|
|
57
|
+
if (done) return;
|
|
58
|
+
done = true;
|
|
59
|
+
clearTimeout(timeout);
|
|
60
|
+
resolve(result);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const timeout = setTimeout(() => {
|
|
64
|
+
child.kill("SIGTERM");
|
|
65
|
+
finish({
|
|
66
|
+
ok: false,
|
|
67
|
+
status: 500,
|
|
68
|
+
error: `Upgrade timed out after ${UPGRADE_TIMEOUT_MS / 1000} seconds`,
|
|
69
|
+
});
|
|
70
|
+
}, UPGRADE_TIMEOUT_MS);
|
|
71
|
+
|
|
72
|
+
child.stdout.on("data", (data: Buffer) => {
|
|
73
|
+
stdout += data.toString();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
child.stderr.on("data", (data: Buffer) => {
|
|
77
|
+
stderr += data.toString();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
child.on("close", (code) => {
|
|
81
|
+
if (code === 0) {
|
|
82
|
+
const version = extractVersion(stdout);
|
|
83
|
+
finish(version ? { ok: true, version } : { ok: true });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
finish({
|
|
88
|
+
ok: false,
|
|
89
|
+
status: 500,
|
|
90
|
+
error:
|
|
91
|
+
stderr.trim() ||
|
|
92
|
+
stdout.trim() ||
|
|
93
|
+
`Upgrade failed: the CLI exited with code ${code ?? "unknown"} and produced no output.`,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
child.on("error", (err) => {
|
|
98
|
+
finish({
|
|
99
|
+
ok: false,
|
|
100
|
+
status: 500,
|
|
101
|
+
error: `Failed to spawn CLI: ${err.message}`,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,443 @@
|
|
|
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 cliPkg from "../../package.json";
|
|
16
|
+
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
17
|
+
import * as assistantConfig from "../lib/assistant-config.js";
|
|
18
|
+
import * as backupOps from "../lib/backup-ops.js";
|
|
19
|
+
import type { GuardianTokenData } from "../lib/guardian-token.js";
|
|
20
|
+
import * as guardianToken from "../lib/guardian-token.js";
|
|
21
|
+
import * as local from "../lib/local.js";
|
|
22
|
+
import * as loopbackFetch from "../lib/loopback-fetch.js";
|
|
23
|
+
import * as ngrok from "../lib/ngrok.js";
|
|
24
|
+
import * as upgradeLifecycle from "../lib/upgrade-lifecycle.js";
|
|
25
|
+
|
|
26
|
+
const realAssistantConfig = { ...assistantConfig };
|
|
27
|
+
const realBackupOps = { ...backupOps };
|
|
28
|
+
const realGuardianToken = { ...guardianToken };
|
|
29
|
+
const realLocal = { ...local };
|
|
30
|
+
const realLoopbackFetch = { ...loopbackFetch };
|
|
31
|
+
const realNgrok = { ...ngrok };
|
|
32
|
+
const realUpgradeLifecycle = { ...upgradeLifecycle };
|
|
33
|
+
|
|
34
|
+
const findAssistantByNameMock =
|
|
35
|
+
mock<typeof assistantConfig.findAssistantByName>();
|
|
36
|
+
const getActiveAssistantMock = mock<typeof assistantConfig.getActiveAssistant>(
|
|
37
|
+
() => "local-assistant",
|
|
38
|
+
);
|
|
39
|
+
const loadAllAssistantsMock = mock<typeof assistantConfig.loadAllAssistants>(
|
|
40
|
+
() => [],
|
|
41
|
+
);
|
|
42
|
+
const saveAssistantEntryMock = mock<typeof assistantConfig.saveAssistantEntry>(
|
|
43
|
+
() => {},
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
mock.module("../lib/assistant-config", () => ({
|
|
47
|
+
...realAssistantConfig,
|
|
48
|
+
findAssistantByName: findAssistantByNameMock,
|
|
49
|
+
getActiveAssistant: getActiveAssistantMock,
|
|
50
|
+
loadAllAssistants: loadAllAssistantsMock,
|
|
51
|
+
saveAssistantEntry: saveAssistantEntryMock,
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
const createBackupMock = mock<typeof backupOps.createBackup>(
|
|
55
|
+
async () => "/tmp/local-pre-upgrade.vbundle",
|
|
56
|
+
);
|
|
57
|
+
const pruneOldBackupsMock = mock<typeof backupOps.pruneOldBackups>(() => {});
|
|
58
|
+
const restoreBackupMock = mock<typeof backupOps.restoreBackup>(
|
|
59
|
+
async () => true,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
mock.module("../lib/backup-ops.js", () => ({
|
|
63
|
+
...realBackupOps,
|
|
64
|
+
createBackup: createBackupMock,
|
|
65
|
+
pruneOldBackups: pruneOldBackupsMock,
|
|
66
|
+
restoreBackup: restoreBackupMock,
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
function makeGuardianToken(
|
|
70
|
+
overrides: Partial<GuardianTokenData> = {},
|
|
71
|
+
): GuardianTokenData {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
return {
|
|
74
|
+
guardianPrincipalId: "guardian-principal",
|
|
75
|
+
accessToken: "access-token",
|
|
76
|
+
accessTokenExpiresAt: new Date(now + 60_000).toISOString(),
|
|
77
|
+
refreshToken: "refresh-token",
|
|
78
|
+
refreshTokenExpiresAt: new Date(now + 3_600_000).toISOString(),
|
|
79
|
+
refreshAfter: new Date(now + 30_000).toISOString(),
|
|
80
|
+
isNew: false,
|
|
81
|
+
deviceId: "device-id",
|
|
82
|
+
leasedAt: new Date(now).toISOString(),
|
|
83
|
+
...overrides,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const leaseGuardianTokenMock = mock<typeof guardianToken.leaseGuardianToken>(
|
|
88
|
+
async () => makeGuardianToken({ isNew: true }),
|
|
89
|
+
);
|
|
90
|
+
const resetGuardianBootstrapMock = mock<
|
|
91
|
+
typeof guardianToken.resetGuardianBootstrap
|
|
92
|
+
>(async () => {});
|
|
93
|
+
const seedGuardianTokenFromSiblingEnvMock = mock<
|
|
94
|
+
typeof guardianToken.seedGuardianTokenFromSiblingEnv
|
|
95
|
+
>(() => false);
|
|
96
|
+
|
|
97
|
+
mock.module("../lib/guardian-token.js", () => ({
|
|
98
|
+
...realGuardianToken,
|
|
99
|
+
leaseGuardianToken: leaseGuardianTokenMock,
|
|
100
|
+
resetGuardianBootstrap: resetGuardianBootstrapMock,
|
|
101
|
+
seedGuardianTokenFromSiblingEnv: seedGuardianTokenFromSiblingEnvMock,
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
const generateLocalSigningKeyMock = mock<typeof local.generateLocalSigningKey>(
|
|
105
|
+
() => "generated-local-secret",
|
|
106
|
+
);
|
|
107
|
+
const ensureLocalRuntimeMock = mock<typeof local.ensureLocalRuntime>(
|
|
108
|
+
(resources, version) => ({
|
|
109
|
+
version,
|
|
110
|
+
installDir: join(resources.instanceDir, ".vellum", "runtime", version),
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
const startLocalDaemonMock = mock<typeof local.startLocalDaemon>(
|
|
114
|
+
async () => {},
|
|
115
|
+
);
|
|
116
|
+
const startGatewayMock = mock<typeof local.startGateway>(
|
|
117
|
+
async () => "http://127.0.0.1:7830",
|
|
118
|
+
);
|
|
119
|
+
const stopLocalProcessesMock = mock<typeof local.stopLocalProcesses>(
|
|
120
|
+
async () => {},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
mock.module("../lib/local.js", () => ({
|
|
124
|
+
...realLocal,
|
|
125
|
+
generateLocalSigningKey: generateLocalSigningKeyMock,
|
|
126
|
+
ensureLocalRuntime: ensureLocalRuntimeMock,
|
|
127
|
+
startLocalDaemon: startLocalDaemonMock,
|
|
128
|
+
startGateway: startGatewayMock,
|
|
129
|
+
stopLocalProcesses: stopLocalProcessesMock,
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
const loopbackSafeFetchMock = mock<typeof loopbackFetch.loopbackSafeFetch>(
|
|
133
|
+
async () =>
|
|
134
|
+
({
|
|
135
|
+
ok: true,
|
|
136
|
+
json: async () => ({
|
|
137
|
+
version: "v0.8.11",
|
|
138
|
+
migrations: {
|
|
139
|
+
dbVersion: 12,
|
|
140
|
+
lastWorkspaceMigrationId: "011-test",
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
}) as Response,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
mock.module("../lib/loopback-fetch.js", () => ({
|
|
147
|
+
...realLoopbackFetch,
|
|
148
|
+
loopbackSafeFetch: loopbackSafeFetchMock,
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
const maybeStartNgrokTunnelMock = mock<typeof ngrok.maybeStartNgrokTunnel>(
|
|
152
|
+
async () => null,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
mock.module("../lib/ngrok.js", () => ({
|
|
156
|
+
...realNgrok,
|
|
157
|
+
maybeStartNgrokTunnel: maybeStartNgrokTunnelMock,
|
|
158
|
+
}));
|
|
159
|
+
|
|
160
|
+
const broadcastUpgradeEventMock = mock<
|
|
161
|
+
typeof upgradeLifecycle.broadcastUpgradeEvent
|
|
162
|
+
>(async () => {});
|
|
163
|
+
const commitWorkspaceViaGatewayMock = mock<
|
|
164
|
+
typeof upgradeLifecycle.commitWorkspaceViaGateway
|
|
165
|
+
>(async () => {});
|
|
166
|
+
const waitForReadyMock = mock<typeof upgradeLifecycle.waitForReady>(
|
|
167
|
+
async () => true,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
mock.module("../lib/upgrade-lifecycle.js", () => ({
|
|
171
|
+
...realUpgradeLifecycle,
|
|
172
|
+
broadcastUpgradeEvent: broadcastUpgradeEventMock,
|
|
173
|
+
commitWorkspaceViaGateway: commitWorkspaceViaGatewayMock,
|
|
174
|
+
waitForReady: waitForReadyMock,
|
|
175
|
+
}));
|
|
176
|
+
|
|
177
|
+
const { targetVersionFromCli, upgrade } =
|
|
178
|
+
await import("../commands/upgrade.js");
|
|
179
|
+
|
|
180
|
+
let tempDir: string;
|
|
181
|
+
let originalArgv: string[];
|
|
182
|
+
let consoleLogSpy: ReturnType<typeof spyOn>;
|
|
183
|
+
let consoleWarnSpy: ReturnType<typeof spyOn>;
|
|
184
|
+
let consoleErrorSpy: ReturnType<typeof spyOn>;
|
|
185
|
+
let exitSpy: ReturnType<typeof spyOn>;
|
|
186
|
+
|
|
187
|
+
function makeLocalEntry(): AssistantEntry {
|
|
188
|
+
tempDir = mkdtempSync(join(tmpdir(), "vellum-upgrade-local-test-"));
|
|
189
|
+
mkdirSync(join(tempDir, ".vellum"), { recursive: true });
|
|
190
|
+
return {
|
|
191
|
+
assistantId: "local-assistant",
|
|
192
|
+
runtimeUrl: "http://lan.local:7830",
|
|
193
|
+
localUrl: "http://127.0.0.1:7830",
|
|
194
|
+
cloud: "local",
|
|
195
|
+
resources: {
|
|
196
|
+
instanceDir: tempDir,
|
|
197
|
+
daemonPort: 7821,
|
|
198
|
+
gatewayPort: 7830,
|
|
199
|
+
qdrantPort: 6333,
|
|
200
|
+
cesPort: 7822,
|
|
201
|
+
signingKey: "existing-signing-key",
|
|
202
|
+
},
|
|
203
|
+
guardianBootstrapSecret: "existing-bootstrap-secret",
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
beforeEach(() => {
|
|
208
|
+
originalArgv = [...process.argv];
|
|
209
|
+
tempDir = "";
|
|
210
|
+
process.argv = [
|
|
211
|
+
"bun",
|
|
212
|
+
"vellum",
|
|
213
|
+
"upgrade",
|
|
214
|
+
"local-assistant",
|
|
215
|
+
"--version",
|
|
216
|
+
cliPkg.version ? `v${cliPkg.version}` : "v0.8.12",
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
220
|
+
consoleWarnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
221
|
+
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
222
|
+
exitSpy = spyOn(process, "exit").mockImplementation((code?: number) => {
|
|
223
|
+
throw new Error(`process.exit(${code})`);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const entry = makeLocalEntry();
|
|
227
|
+
findAssistantByNameMock.mockReset();
|
|
228
|
+
findAssistantByNameMock.mockReturnValue(entry);
|
|
229
|
+
getActiveAssistantMock.mockReset();
|
|
230
|
+
getActiveAssistantMock.mockReturnValue("local-assistant");
|
|
231
|
+
loadAllAssistantsMock.mockReset();
|
|
232
|
+
loadAllAssistantsMock.mockReturnValue([entry]);
|
|
233
|
+
saveAssistantEntryMock.mockReset();
|
|
234
|
+
createBackupMock.mockReset();
|
|
235
|
+
createBackupMock.mockResolvedValue("/tmp/local-pre-upgrade.vbundle");
|
|
236
|
+
pruneOldBackupsMock.mockReset();
|
|
237
|
+
pruneOldBackupsMock.mockReturnValue(undefined);
|
|
238
|
+
restoreBackupMock.mockReset();
|
|
239
|
+
restoreBackupMock.mockResolvedValue(true);
|
|
240
|
+
leaseGuardianTokenMock.mockReset();
|
|
241
|
+
leaseGuardianTokenMock.mockResolvedValue(makeGuardianToken({ isNew: true }));
|
|
242
|
+
resetGuardianBootstrapMock.mockReset();
|
|
243
|
+
resetGuardianBootstrapMock.mockResolvedValue(undefined);
|
|
244
|
+
seedGuardianTokenFromSiblingEnvMock.mockReset();
|
|
245
|
+
seedGuardianTokenFromSiblingEnvMock.mockReturnValue(false);
|
|
246
|
+
generateLocalSigningKeyMock.mockReset();
|
|
247
|
+
generateLocalSigningKeyMock.mockReturnValue("generated-local-secret");
|
|
248
|
+
ensureLocalRuntimeMock.mockReset();
|
|
249
|
+
ensureLocalRuntimeMock.mockImplementation((resources, version) => ({
|
|
250
|
+
version,
|
|
251
|
+
installDir: join(resources.instanceDir, ".vellum", "runtime", version),
|
|
252
|
+
}));
|
|
253
|
+
startLocalDaemonMock.mockReset();
|
|
254
|
+
startLocalDaemonMock.mockResolvedValue(undefined);
|
|
255
|
+
startGatewayMock.mockReset();
|
|
256
|
+
startGatewayMock.mockResolvedValue("http://127.0.0.1:7830");
|
|
257
|
+
stopLocalProcessesMock.mockReset();
|
|
258
|
+
stopLocalProcessesMock.mockResolvedValue(undefined);
|
|
259
|
+
loopbackSafeFetchMock.mockReset();
|
|
260
|
+
loopbackSafeFetchMock.mockResolvedValue({
|
|
261
|
+
ok: true,
|
|
262
|
+
json: async () => ({
|
|
263
|
+
version: "v0.8.11",
|
|
264
|
+
migrations: {
|
|
265
|
+
dbVersion: 12,
|
|
266
|
+
lastWorkspaceMigrationId: "011-test",
|
|
267
|
+
},
|
|
268
|
+
}),
|
|
269
|
+
} as Response);
|
|
270
|
+
maybeStartNgrokTunnelMock.mockReset();
|
|
271
|
+
maybeStartNgrokTunnelMock.mockResolvedValue(null);
|
|
272
|
+
broadcastUpgradeEventMock.mockReset();
|
|
273
|
+
broadcastUpgradeEventMock.mockResolvedValue(undefined);
|
|
274
|
+
commitWorkspaceViaGatewayMock.mockReset();
|
|
275
|
+
commitWorkspaceViaGatewayMock.mockResolvedValue(undefined);
|
|
276
|
+
waitForReadyMock.mockReset();
|
|
277
|
+
waitForReadyMock.mockResolvedValue(true);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
afterEach(() => {
|
|
281
|
+
process.argv = originalArgv;
|
|
282
|
+
consoleLogSpy.mockRestore();
|
|
283
|
+
consoleWarnSpy.mockRestore();
|
|
284
|
+
consoleErrorSpy.mockRestore();
|
|
285
|
+
exitSpy.mockRestore();
|
|
286
|
+
if (tempDir) {
|
|
287
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
afterAll(() => {
|
|
292
|
+
mock.module("../lib/assistant-config", () => realAssistantConfig);
|
|
293
|
+
mock.module("../lib/backup-ops.js", () => realBackupOps);
|
|
294
|
+
mock.module("../lib/guardian-token.js", () => realGuardianToken);
|
|
295
|
+
mock.module("../lib/local.js", () => realLocal);
|
|
296
|
+
mock.module("../lib/loopback-fetch.js", () => realLoopbackFetch);
|
|
297
|
+
mock.module("../lib/ngrok.js", () => realNgrok);
|
|
298
|
+
mock.module("../lib/upgrade-lifecycle.js", () => realUpgradeLifecycle);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("vellum upgrade local", () => {
|
|
302
|
+
test("uses explicit target versions as-is", async () => {
|
|
303
|
+
const resolveLatest = mock(async () => "v0.9.9");
|
|
304
|
+
|
|
305
|
+
await expect(
|
|
306
|
+
targetVersionFromCli(
|
|
307
|
+
"v0.8.12",
|
|
308
|
+
"0.10.0-local.20260622155324.21c18fa3b3",
|
|
309
|
+
resolveLatest,
|
|
310
|
+
),
|
|
311
|
+
).resolves.toBe("v0.8.12");
|
|
312
|
+
expect(resolveLatest).not.toHaveBeenCalled();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("resolves explicit local build targets to the latest stable runtime", async () => {
|
|
316
|
+
const resolveLatest = mock(async () => "v0.9.9");
|
|
317
|
+
|
|
318
|
+
await expect(
|
|
319
|
+
targetVersionFromCli(
|
|
320
|
+
"0.10.0-local.20260622155324.21c18fa3b3",
|
|
321
|
+
"0.10.0",
|
|
322
|
+
resolveLatest,
|
|
323
|
+
),
|
|
324
|
+
).resolves.toBe("v0.9.9");
|
|
325
|
+
expect(resolveLatest).toHaveBeenCalledTimes(1);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("defaults published CLI builds to the CLI version", async () => {
|
|
329
|
+
const resolveLatest = mock(async () => "v0.9.9");
|
|
330
|
+
|
|
331
|
+
await expect(
|
|
332
|
+
targetVersionFromCli(null, "0.10.0", resolveLatest),
|
|
333
|
+
).resolves.toBe("v0.10.0");
|
|
334
|
+
expect(resolveLatest).not.toHaveBeenCalled();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("defaults local CLI builds to the latest stable runtime", async () => {
|
|
338
|
+
const resolveLatest = mock(async () => "v0.9.9");
|
|
339
|
+
|
|
340
|
+
await expect(
|
|
341
|
+
targetVersionFromCli(
|
|
342
|
+
null,
|
|
343
|
+
"0.10.0-local.20260622155324.21c18fa3b3",
|
|
344
|
+
resolveLatest,
|
|
345
|
+
),
|
|
346
|
+
).resolves.toBe("v0.9.9");
|
|
347
|
+
expect(resolveLatest).toHaveBeenCalledTimes(1);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("restarts local assistant processes and records upgrade lifecycle", async () => {
|
|
351
|
+
await upgrade();
|
|
352
|
+
|
|
353
|
+
expect(loopbackSafeFetchMock).toHaveBeenCalledWith(
|
|
354
|
+
"http://127.0.0.1:7830/healthz?include=migrations",
|
|
355
|
+
expect.any(Object),
|
|
356
|
+
);
|
|
357
|
+
expect(createBackupMock).toHaveBeenCalledWith(
|
|
358
|
+
"http://127.0.0.1:7830",
|
|
359
|
+
"local-assistant",
|
|
360
|
+
expect.objectContaining({
|
|
361
|
+
prefix: "local-assistant-pre-upgrade",
|
|
362
|
+
}),
|
|
363
|
+
);
|
|
364
|
+
expect(saveAssistantEntryMock).toHaveBeenCalledWith(
|
|
365
|
+
expect.objectContaining({
|
|
366
|
+
previousVersion: "v0.8.11",
|
|
367
|
+
previousDbMigrationVersion: 12,
|
|
368
|
+
previousWorkspaceMigrationId: "011-test",
|
|
369
|
+
preUpgradeBackupPath: "/tmp/local-pre-upgrade.vbundle",
|
|
370
|
+
}),
|
|
371
|
+
);
|
|
372
|
+
expect(stopLocalProcessesMock).toHaveBeenCalledWith(
|
|
373
|
+
expect.objectContaining({
|
|
374
|
+
instanceDir: tempDir,
|
|
375
|
+
runtimeVersion: cliPkg.version ? `v${cliPkg.version}` : "v0.8.12",
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
378
|
+
expect(ensureLocalRuntimeMock).toHaveBeenCalledWith(
|
|
379
|
+
expect.objectContaining({ instanceDir: tempDir }),
|
|
380
|
+
cliPkg.version ? `v${cliPkg.version}` : "v0.8.12",
|
|
381
|
+
{ force: false },
|
|
382
|
+
);
|
|
383
|
+
expect(startLocalDaemonMock).toHaveBeenCalledWith(
|
|
384
|
+
false,
|
|
385
|
+
expect.objectContaining({
|
|
386
|
+
instanceDir: tempDir,
|
|
387
|
+
runtimeVersion: cliPkg.version ? `v${cliPkg.version}` : "v0.8.12",
|
|
388
|
+
}),
|
|
389
|
+
{ signingKey: "existing-signing-key" },
|
|
390
|
+
);
|
|
391
|
+
expect(startGatewayMock).toHaveBeenCalledWith(
|
|
392
|
+
false,
|
|
393
|
+
expect.objectContaining({
|
|
394
|
+
instanceDir: tempDir,
|
|
395
|
+
runtimeVersion: cliPkg.version ? `v${cliPkg.version}` : "v0.8.12",
|
|
396
|
+
}),
|
|
397
|
+
{
|
|
398
|
+
signingKey: "existing-signing-key",
|
|
399
|
+
bootstrapSecret: "existing-bootstrap-secret",
|
|
400
|
+
},
|
|
401
|
+
);
|
|
402
|
+
expect(seedGuardianTokenFromSiblingEnvMock).toHaveBeenCalledWith(
|
|
403
|
+
"local-assistant",
|
|
404
|
+
);
|
|
405
|
+
expect(resetGuardianBootstrapMock).toHaveBeenCalledWith(
|
|
406
|
+
"http://127.0.0.1:7830",
|
|
407
|
+
"existing-bootstrap-secret",
|
|
408
|
+
);
|
|
409
|
+
expect(leaseGuardianTokenMock).toHaveBeenCalledWith(
|
|
410
|
+
"http://127.0.0.1:7830",
|
|
411
|
+
"local-assistant",
|
|
412
|
+
"existing-bootstrap-secret",
|
|
413
|
+
);
|
|
414
|
+
expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
|
|
415
|
+
7830,
|
|
416
|
+
join(tempDir, ".vellum", "workspace"),
|
|
417
|
+
);
|
|
418
|
+
expect(waitForReadyMock).toHaveBeenCalledWith("http://127.0.0.1:7830");
|
|
419
|
+
expect(commitWorkspaceViaGatewayMock).toHaveBeenCalledWith(
|
|
420
|
+
"http://127.0.0.1:7830",
|
|
421
|
+
"local-assistant",
|
|
422
|
+
expect.stringContaining("topology: local"),
|
|
423
|
+
);
|
|
424
|
+
expect(consoleLogSpy.mock.calls.flat().join("\n")).toContain("upgraded to");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("skips restart when the local assistant is already on the target version", async () => {
|
|
428
|
+
loopbackSafeFetchMock.mockResolvedValue({
|
|
429
|
+
ok: true,
|
|
430
|
+
json: async () => ({
|
|
431
|
+
version: cliPkg.version ? `v${cliPkg.version}` : "v0.8.12",
|
|
432
|
+
}),
|
|
433
|
+
} as Response);
|
|
434
|
+
|
|
435
|
+
await upgrade();
|
|
436
|
+
|
|
437
|
+
expect(stopLocalProcessesMock).not.toHaveBeenCalled();
|
|
438
|
+
expect(ensureLocalRuntimeMock).not.toHaveBeenCalled();
|
|
439
|
+
expect(startLocalDaemonMock).not.toHaveBeenCalled();
|
|
440
|
+
expect(startGatewayMock).not.toHaveBeenCalled();
|
|
441
|
+
expect(consoleLogSpy.mock.calls.flat().join("\n")).toContain("Already on");
|
|
442
|
+
});
|
|
443
|
+
});
|