@vellumai/cli 0.5.5 → 0.5.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/knip.json +4 -1
- package/package.json +1 -1
- package/src/__tests__/health-check.test.ts +26 -1
- package/src/commands/backup.ts +28 -13
- package/src/commands/hatch.ts +96 -60
- package/src/commands/ps.ts +6 -1
- package/src/commands/restore.ts +50 -30
- package/src/commands/retire.ts +5 -5
- package/src/commands/rollback.ts +443 -0
- package/src/commands/upgrade.ts +586 -120
- package/src/commands/wake.ts +8 -0
- package/src/index.ts +5 -0
- package/src/lib/assistant-config.ts +62 -7
- package/src/lib/aws.ts +2 -0
- package/src/lib/backup-ops.ts +213 -0
- package/src/lib/cli-error.ts +91 -0
- package/src/lib/config-utils.ts +59 -0
- package/src/lib/docker.ts +82 -10
- package/src/lib/doctor-client.ts +11 -1
- package/src/lib/gcp.ts +5 -1
- package/src/lib/guardian-token.ts +46 -1
- package/src/lib/health-check.ts +4 -0
- package/src/lib/local.ts +29 -9
- package/src/lib/platform-client.ts +19 -4
- package/src/lib/platform-releases.ts +112 -0
- package/src/lib/upgrade-lifecycle.ts +237 -0
- package/src/lib/version-compat.ts +45 -0
- package/src/lib/workspace-git.ts +39 -0
package/src/commands/wake.ts
CHANGED
|
@@ -40,6 +40,14 @@ export async function wake(): Promise<void> {
|
|
|
40
40
|
const entry = resolveTargetAssistant(nameArg);
|
|
41
41
|
|
|
42
42
|
if (entry.cloud === "docker") {
|
|
43
|
+
if (watch || foreground) {
|
|
44
|
+
const ignored = [watch && "--watch", foreground && "--foreground"]
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.join(" and ");
|
|
47
|
+
console.warn(
|
|
48
|
+
`Warning: ${ignored} ignored for Docker instances (not supported).`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
43
51
|
const res = dockerResourceNames(entry.assistantId);
|
|
44
52
|
await wakeContainers(res);
|
|
45
53
|
console.log("Docker containers started.");
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { ps } from "./commands/ps";
|
|
|
11
11
|
import { recover } from "./commands/recover";
|
|
12
12
|
import { restore } from "./commands/restore";
|
|
13
13
|
import { retire } from "./commands/retire";
|
|
14
|
+
import { rollback } from "./commands/rollback";
|
|
14
15
|
import { setup } from "./commands/setup";
|
|
15
16
|
import { sleep } from "./commands/sleep";
|
|
16
17
|
import { ssh } from "./commands/ssh";
|
|
@@ -39,6 +40,7 @@ const commands = {
|
|
|
39
40
|
recover,
|
|
40
41
|
restore,
|
|
41
42
|
retire,
|
|
43
|
+
rollback,
|
|
42
44
|
setup,
|
|
43
45
|
sleep,
|
|
44
46
|
ssh,
|
|
@@ -68,6 +70,9 @@ function printHelp(): void {
|
|
|
68
70
|
console.log(" recover Restore a previously retired local assistant");
|
|
69
71
|
console.log(" restore Restore a .vbundle backup into a running assistant");
|
|
70
72
|
console.log(" retire Delete an assistant instance");
|
|
73
|
+
console.log(
|
|
74
|
+
" rollback Roll back a Docker assistant to the previous version",
|
|
75
|
+
);
|
|
71
76
|
console.log(" setup Configure API keys interactively");
|
|
72
77
|
console.log(" sleep Stop the assistant process");
|
|
73
78
|
console.log(" ssh SSH into a remote assistant instance");
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomBytes } from "crypto";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "fs";
|
|
2
10
|
import { homedir } from "os";
|
|
3
11
|
import { join } from "path";
|
|
4
12
|
|
|
@@ -78,6 +86,18 @@ export interface AssistantEntry {
|
|
|
78
86
|
serviceGroupVersion?: string;
|
|
79
87
|
/** Docker image metadata for rollback. Only present for docker topology entries. */
|
|
80
88
|
containerInfo?: ContainerInfo;
|
|
89
|
+
/** The service group version that was running before the last upgrade. */
|
|
90
|
+
previousServiceGroupVersion?: string;
|
|
91
|
+
/** Docker image metadata from before the last upgrade. Enables rollback to the prior version. */
|
|
92
|
+
previousContainerInfo?: ContainerInfo;
|
|
93
|
+
/** Path to the .vbundle backup created for the most recent upgrade. Used by rollback to restore
|
|
94
|
+
* only the backup from the specific upgrade being rolled back — never a stale backup from a
|
|
95
|
+
* previous upgrade cycle. */
|
|
96
|
+
preUpgradeBackupPath?: string;
|
|
97
|
+
/** Pre-upgrade DB migration version — used by rollback to know how far back to revert. */
|
|
98
|
+
previousDbMigrationVersion?: number;
|
|
99
|
+
/** Pre-upgrade workspace migration ID — used by rollback to know how far back to revert. */
|
|
100
|
+
previousWorkspaceMigrationId?: string;
|
|
81
101
|
[key: string]: unknown;
|
|
82
102
|
}
|
|
83
103
|
|
|
@@ -88,7 +108,7 @@ interface LockfileData {
|
|
|
88
108
|
[key: string]: unknown;
|
|
89
109
|
}
|
|
90
110
|
|
|
91
|
-
function getBaseDir(): string {
|
|
111
|
+
export function getBaseDir(): string {
|
|
92
112
|
return process.env.BASE_DATA_DIR?.trim() || homedir();
|
|
93
113
|
}
|
|
94
114
|
|
|
@@ -120,7 +140,16 @@ function readLockfile(): LockfileData {
|
|
|
120
140
|
|
|
121
141
|
function writeLockfile(data: LockfileData): void {
|
|
122
142
|
const lockfilePath = join(getLockfileDir(), ".vellum.lock.json");
|
|
123
|
-
|
|
143
|
+
const tmpPath = `${lockfilePath}.${randomBytes(4).toString("hex")}.tmp`;
|
|
144
|
+
try {
|
|
145
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n");
|
|
146
|
+
renameSync(tmpPath, lockfilePath);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
try {
|
|
149
|
+
unlinkSync(tmpPath);
|
|
150
|
+
} catch {}
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
124
153
|
}
|
|
125
154
|
|
|
126
155
|
/**
|
|
@@ -360,6 +389,23 @@ export function saveAssistantEntry(entry: AssistantEntry): void {
|
|
|
360
389
|
writeAssistants(entries);
|
|
361
390
|
}
|
|
362
391
|
|
|
392
|
+
/**
|
|
393
|
+
* Update just the serviceGroupVersion field on a lockfile entry.
|
|
394
|
+
* Reads the current entry, updates the version if changed, and writes back.
|
|
395
|
+
* No-op if the entry doesn't exist or the version hasn't changed.
|
|
396
|
+
*/
|
|
397
|
+
export function updateServiceGroupVersion(
|
|
398
|
+
assistantId: string,
|
|
399
|
+
version: string,
|
|
400
|
+
): void {
|
|
401
|
+
const entries = readAssistants();
|
|
402
|
+
const entry = entries.find((e) => e.assistantId === assistantId);
|
|
403
|
+
if (!entry) return;
|
|
404
|
+
if (entry.serviceGroupVersion === version) return;
|
|
405
|
+
entry.serviceGroupVersion = version;
|
|
406
|
+
writeAssistants(entries);
|
|
407
|
+
}
|
|
408
|
+
|
|
363
409
|
/**
|
|
364
410
|
* Scan upward from `basePort` to find an available port. A port is considered
|
|
365
411
|
* available when `probePort()` returns false (nothing listening). Scans up to
|
|
@@ -391,12 +437,14 @@ export async function allocateLocalResources(
|
|
|
391
437
|
instanceName: string,
|
|
392
438
|
): Promise<LocalInstanceResources> {
|
|
393
439
|
// First local assistant gets the home directory with default ports.
|
|
440
|
+
// Respect BASE_DATA_DIR when set (e.g. in e2e tests) so the daemon,
|
|
441
|
+
// gateway, and keychain broker all resolve paths under the same root.
|
|
394
442
|
const existingLocals = loadAllAssistants().filter((e) => e.cloud === "local");
|
|
395
443
|
if (existingLocals.length === 0) {
|
|
396
|
-
const
|
|
397
|
-
const vellumDir = join(
|
|
444
|
+
const baseDir = getBaseDir();
|
|
445
|
+
const vellumDir = join(baseDir, ".vellum");
|
|
398
446
|
return {
|
|
399
|
-
instanceDir:
|
|
447
|
+
instanceDir: baseDir,
|
|
400
448
|
daemonPort: DEFAULT_DAEMON_PORT,
|
|
401
449
|
gatewayPort: DEFAULT_GATEWAY_PORT,
|
|
402
450
|
qdrantPort: DEFAULT_QDRANT_PORT,
|
|
@@ -426,6 +474,7 @@ export async function allocateLocalResources(
|
|
|
426
474
|
entry.resources.daemonPort,
|
|
427
475
|
entry.resources.gatewayPort,
|
|
428
476
|
entry.resources.qdrantPort,
|
|
477
|
+
entry.resources.cesPort,
|
|
429
478
|
);
|
|
430
479
|
}
|
|
431
480
|
}
|
|
@@ -445,13 +494,19 @@ export async function allocateLocalResources(
|
|
|
445
494
|
daemonPort,
|
|
446
495
|
gatewayPort,
|
|
447
496
|
]);
|
|
497
|
+
const cesPort = await findAvailablePort(DEFAULT_CES_PORT, [
|
|
498
|
+
...reservedPorts,
|
|
499
|
+
daemonPort,
|
|
500
|
+
gatewayPort,
|
|
501
|
+
qdrantPort,
|
|
502
|
+
]);
|
|
448
503
|
|
|
449
504
|
return {
|
|
450
505
|
instanceDir,
|
|
451
506
|
daemonPort,
|
|
452
507
|
gatewayPort,
|
|
453
508
|
qdrantPort,
|
|
454
|
-
cesPort
|
|
509
|
+
cesPort,
|
|
455
510
|
pidFile: join(instanceDir, ".vellum", "vellum.pid"),
|
|
456
511
|
};
|
|
457
512
|
}
|
package/src/lib/aws.ts
CHANGED
|
@@ -374,6 +374,7 @@ export async function hatchAws(
|
|
|
374
374
|
species: Species,
|
|
375
375
|
detached: boolean,
|
|
376
376
|
name: string | null,
|
|
377
|
+
configValues: Record<string, string> = {},
|
|
377
378
|
): Promise<void> {
|
|
378
379
|
const startTime = Date.now();
|
|
379
380
|
try {
|
|
@@ -448,6 +449,7 @@ export async function hatchAws(
|
|
|
448
449
|
providerApiKeys,
|
|
449
450
|
instanceName,
|
|
450
451
|
"aws",
|
|
452
|
+
configValues,
|
|
451
453
|
);
|
|
452
454
|
const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
|
|
453
455
|
writeFileSync(startupScriptPath, startupScript);
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "fs";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
import { dirname, join } from "path";
|
|
11
|
+
|
|
12
|
+
import { loadGuardianToken, leaseGuardianToken } from "./guardian-token.js";
|
|
13
|
+
|
|
14
|
+
/** Default backup directory following XDG convention */
|
|
15
|
+
export function getBackupsDir(): string {
|
|
16
|
+
const dataHome =
|
|
17
|
+
process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share");
|
|
18
|
+
return join(dataHome, "vellum", "backups");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Human-readable file size */
|
|
22
|
+
export function formatSize(bytes: number): string {
|
|
23
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
24
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
25
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Obtain a valid guardian access token (cached or fresh lease) */
|
|
29
|
+
async function getGuardianAccessToken(
|
|
30
|
+
runtimeUrl: string,
|
|
31
|
+
assistantId: string,
|
|
32
|
+
forceRefresh?: boolean,
|
|
33
|
+
): Promise<string> {
|
|
34
|
+
if (!forceRefresh) {
|
|
35
|
+
const tokenData = loadGuardianToken(assistantId);
|
|
36
|
+
if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
|
|
37
|
+
return tokenData.accessToken;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const freshToken = await leaseGuardianToken(runtimeUrl, assistantId);
|
|
41
|
+
return freshToken.accessToken;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create a .vbundle backup of a running assistant.
|
|
46
|
+
* Returns the path to the saved backup, or null if backup failed.
|
|
47
|
+
* Never throws — failures are logged as warnings.
|
|
48
|
+
*/
|
|
49
|
+
export async function createBackup(
|
|
50
|
+
runtimeUrl: string,
|
|
51
|
+
assistantId: string,
|
|
52
|
+
options?: { prefix?: string; description?: string },
|
|
53
|
+
): Promise<string | null> {
|
|
54
|
+
try {
|
|
55
|
+
let accessToken = await getGuardianAccessToken(runtimeUrl, assistantId);
|
|
56
|
+
|
|
57
|
+
let response = await fetch(`${runtimeUrl}/v1/migrations/export`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: {
|
|
60
|
+
Authorization: `Bearer ${accessToken}`,
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
},
|
|
63
|
+
body: JSON.stringify({
|
|
64
|
+
description: options?.description ?? "CLI backup",
|
|
65
|
+
}),
|
|
66
|
+
signal: AbortSignal.timeout(120_000),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Retry once with a fresh token on 401 — the cached token may be stale
|
|
70
|
+
// after a container restart that generated a new gateway signing key.
|
|
71
|
+
if (response.status === 401) {
|
|
72
|
+
accessToken = await getGuardianAccessToken(runtimeUrl, assistantId, true);
|
|
73
|
+
response = await fetch(`${runtimeUrl}/v1/migrations/export`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: {
|
|
76
|
+
Authorization: `Bearer ${accessToken}`,
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
description: options?.description ?? "CLI backup",
|
|
81
|
+
}),
|
|
82
|
+
signal: AbortSignal.timeout(120_000),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
const body = await response.text();
|
|
88
|
+
console.warn(
|
|
89
|
+
`Warning: backup export failed (${response.status}): ${body}`,
|
|
90
|
+
);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
95
|
+
const data = new Uint8Array(arrayBuffer);
|
|
96
|
+
|
|
97
|
+
const isoTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
98
|
+
const prefix = options?.prefix ?? assistantId;
|
|
99
|
+
const outputPath = join(
|
|
100
|
+
getBackupsDir(),
|
|
101
|
+
`${prefix}-${isoTimestamp}.vbundle`,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
105
|
+
writeFileSync(outputPath, data);
|
|
106
|
+
|
|
107
|
+
return outputPath;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
110
|
+
console.warn(`Warning: backup failed: ${msg}`);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Restore a .vbundle backup into a running assistant.
|
|
117
|
+
* Returns true if restore succeeded, false otherwise.
|
|
118
|
+
* Never throws — failures are logged as warnings.
|
|
119
|
+
*/
|
|
120
|
+
export async function restoreBackup(
|
|
121
|
+
runtimeUrl: string,
|
|
122
|
+
assistantId: string,
|
|
123
|
+
backupPath: string,
|
|
124
|
+
): Promise<boolean> {
|
|
125
|
+
try {
|
|
126
|
+
if (!existsSync(backupPath)) {
|
|
127
|
+
console.warn(`Warning: backup file not found: ${backupPath}`);
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const bundleData = readFileSync(backupPath);
|
|
132
|
+
let accessToken = await getGuardianAccessToken(runtimeUrl, assistantId);
|
|
133
|
+
|
|
134
|
+
let response = await fetch(`${runtimeUrl}/v1/migrations/import`, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: {
|
|
137
|
+
Authorization: `Bearer ${accessToken}`,
|
|
138
|
+
"Content-Type": "application/octet-stream",
|
|
139
|
+
},
|
|
140
|
+
body: bundleData,
|
|
141
|
+
signal: AbortSignal.timeout(120_000),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Retry once with a fresh token on 401 — the cached token may be stale
|
|
145
|
+
// after a container restart that generated a new gateway signing key.
|
|
146
|
+
if (response.status === 401) {
|
|
147
|
+
accessToken = await getGuardianAccessToken(runtimeUrl, assistantId, true);
|
|
148
|
+
response = await fetch(`${runtimeUrl}/v1/migrations/import`, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: {
|
|
151
|
+
Authorization: `Bearer ${accessToken}`,
|
|
152
|
+
"Content-Type": "application/octet-stream",
|
|
153
|
+
},
|
|
154
|
+
body: bundleData,
|
|
155
|
+
signal: AbortSignal.timeout(120_000),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
const body = await response.text();
|
|
161
|
+
console.warn(`Warning: restore failed (${response.status}): ${body}`);
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const result = (await response.json()) as {
|
|
166
|
+
success: boolean;
|
|
167
|
+
message?: string;
|
|
168
|
+
reason?: string;
|
|
169
|
+
};
|
|
170
|
+
if (!result.success) {
|
|
171
|
+
console.warn(
|
|
172
|
+
`Warning: restore failed — ${result.message ?? result.reason ?? "unknown reason"}`,
|
|
173
|
+
);
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return true;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
180
|
+
console.warn(`Warning: restore failed: ${msg}`);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Keep only the N most recent pre-upgrade backups for an assistant,
|
|
187
|
+
* deleting older ones. Default: keep 3.
|
|
188
|
+
* Never throws — failures are silently ignored.
|
|
189
|
+
*/
|
|
190
|
+
export function pruneOldBackups(assistantId: string, keep: number = 3): void {
|
|
191
|
+
try {
|
|
192
|
+
const backupsDir = getBackupsDir();
|
|
193
|
+
if (!existsSync(backupsDir)) return;
|
|
194
|
+
|
|
195
|
+
const prefix = `${assistantId}-pre-upgrade-`;
|
|
196
|
+
const entries = readdirSync(backupsDir)
|
|
197
|
+
.filter((f) => f.startsWith(prefix) && f.endsWith(".vbundle"))
|
|
198
|
+
.sort();
|
|
199
|
+
|
|
200
|
+
if (entries.length <= keep) return;
|
|
201
|
+
|
|
202
|
+
const toDelete = entries.slice(0, entries.length - keep);
|
|
203
|
+
for (const file of toDelete) {
|
|
204
|
+
try {
|
|
205
|
+
unlinkSync(join(backupsDir, file));
|
|
206
|
+
} catch {
|
|
207
|
+
// Best-effort cleanup — ignore individual file errors
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
// Best-effort cleanup — never block the upgrade
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured CLI error reporting for upgrade/rollback commands.
|
|
3
|
+
*
|
|
4
|
+
* When a CLI command fails, it can emit a machine-readable JSON object
|
|
5
|
+
* prefixed with `CLI_ERROR:` to stderr so that consumers (e.g. the
|
|
6
|
+
* desktop app) can parse it reliably. Modeled after the DAEMON_ERROR
|
|
7
|
+
* protocol in `assistant/src/daemon/startup-error.ts`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Known error categories emitted by CLI commands. */
|
|
11
|
+
export type CliErrorCategory =
|
|
12
|
+
| "DOCKER_NOT_RUNNING"
|
|
13
|
+
| "IMAGE_PULL_FAILED"
|
|
14
|
+
| "READINESS_TIMEOUT"
|
|
15
|
+
| "ROLLBACK_FAILED"
|
|
16
|
+
| "ROLLBACK_NO_STATE"
|
|
17
|
+
| "AUTH_FAILED"
|
|
18
|
+
| "NETWORK_ERROR"
|
|
19
|
+
| "UNSUPPORTED_TOPOLOGY"
|
|
20
|
+
| "ASSISTANT_NOT_FOUND"
|
|
21
|
+
| "PLATFORM_API_ERROR"
|
|
22
|
+
| "UNKNOWN";
|
|
23
|
+
|
|
24
|
+
interface CliErrorPayload {
|
|
25
|
+
error: CliErrorCategory;
|
|
26
|
+
message: string;
|
|
27
|
+
detail?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const CLI_ERROR_PREFIX = "CLI_ERROR:";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Write a structured error line to stderr. The line is prefixed with
|
|
34
|
+
* `CLI_ERROR:` followed by JSON, making it unambiguous even if other
|
|
35
|
+
* stderr output precedes it.
|
|
36
|
+
*/
|
|
37
|
+
export function emitCliError(
|
|
38
|
+
category: CliErrorCategory,
|
|
39
|
+
message: string,
|
|
40
|
+
detail?: string,
|
|
41
|
+
): void {
|
|
42
|
+
const payload: CliErrorPayload = { error: category, message, detail };
|
|
43
|
+
const line = `${CLI_ERROR_PREFIX}${JSON.stringify(payload)}`;
|
|
44
|
+
process.stderr.write(line + "\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Inspect an error string and return the most appropriate
|
|
49
|
+
* {@link CliErrorCategory} for common upgrade/rollback failures.
|
|
50
|
+
*/
|
|
51
|
+
export function categorizeUpgradeError(err: unknown): CliErrorCategory {
|
|
52
|
+
const msg = String(err).toLowerCase();
|
|
53
|
+
|
|
54
|
+
if (
|
|
55
|
+
msg.includes("cannot connect to the docker") ||
|
|
56
|
+
msg.includes("is docker running")
|
|
57
|
+
) {
|
|
58
|
+
return "DOCKER_NOT_RUNNING";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (
|
|
62
|
+
msg.includes("manifest unknown") ||
|
|
63
|
+
msg.includes("manifest not found") ||
|
|
64
|
+
msg.includes("pull access denied") ||
|
|
65
|
+
msg.includes("repository does not exist")
|
|
66
|
+
) {
|
|
67
|
+
return "IMAGE_PULL_FAILED";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (msg.includes("timeout") || msg.includes("readyz")) {
|
|
71
|
+
return "READINESS_TIMEOUT";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (
|
|
75
|
+
msg.includes("401") ||
|
|
76
|
+
msg.includes("403") ||
|
|
77
|
+
msg.includes("unauthorized")
|
|
78
|
+
) {
|
|
79
|
+
return "AUTH_FAILED";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (
|
|
83
|
+
msg.includes("enotfound") ||
|
|
84
|
+
msg.includes("econnrefused") ||
|
|
85
|
+
msg.includes("network")
|
|
86
|
+
) {
|
|
87
|
+
return "NETWORK_ERROR";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return "UNKNOWN";
|
|
91
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { writeFileSync } from "fs";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Convert flat dot-notation key=value pairs into a nested config object.
|
|
7
|
+
*
|
|
8
|
+
* e.g. {"services.inference.provider": "anthropic", "services.inference.model": "claude-opus-4-6"}
|
|
9
|
+
* → {services: {inference: {provider: "anthropic", model: "claude-opus-4-6"}}}
|
|
10
|
+
*/
|
|
11
|
+
export function buildNestedConfig(
|
|
12
|
+
configValues: Record<string, string>,
|
|
13
|
+
): Record<string, unknown> {
|
|
14
|
+
const config: Record<string, unknown> = {};
|
|
15
|
+
for (const [dotKey, value] of Object.entries(configValues)) {
|
|
16
|
+
const parts = dotKey.split(".");
|
|
17
|
+
let target: Record<string, unknown> = config;
|
|
18
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
19
|
+
const part = parts[i];
|
|
20
|
+
const existing = target[part];
|
|
21
|
+
if (
|
|
22
|
+
existing == null ||
|
|
23
|
+
typeof existing !== "object" ||
|
|
24
|
+
Array.isArray(existing)
|
|
25
|
+
) {
|
|
26
|
+
target[part] = {};
|
|
27
|
+
}
|
|
28
|
+
target = target[part] as Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
target[parts[parts.length - 1]] = value;
|
|
31
|
+
}
|
|
32
|
+
return config;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Write arbitrary key-value pairs to a temporary JSON file and return its
|
|
37
|
+
* path. The caller passes this path to the daemon via the
|
|
38
|
+
* VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH env var so the daemon can merge the
|
|
39
|
+
* values into its workspace config on first boot.
|
|
40
|
+
*
|
|
41
|
+
* Keys use dot-notation to address nested fields. For example:
|
|
42
|
+
* "services.inference.provider" → {services: {inference: {provider: ...}}}
|
|
43
|
+
* "services.inference.model" → {services: {inference: {model: ...}}}
|
|
44
|
+
*
|
|
45
|
+
* Returns undefined when configValues is empty (nothing to write).
|
|
46
|
+
*/
|
|
47
|
+
export function writeInitialConfig(
|
|
48
|
+
configValues: Record<string, string>,
|
|
49
|
+
): string | undefined {
|
|
50
|
+
if (Object.keys(configValues).length === 0) return undefined;
|
|
51
|
+
|
|
52
|
+
const config = buildNestedConfig(configValues);
|
|
53
|
+
const tempPath = join(
|
|
54
|
+
tmpdir(),
|
|
55
|
+
`vellum-default-workspace-config-${process.pid}-${Date.now()}.json`,
|
|
56
|
+
);
|
|
57
|
+
writeFileSync(tempPath, JSON.stringify(config, null, 2) + "\n");
|
|
58
|
+
return tempPath;
|
|
59
|
+
}
|