@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
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { DOCKER_READY_TIMEOUT_MS } from "./docker.js";
|
|
2
|
+
import { loadGuardianToken } from "./guardian-token.js";
|
|
3
|
+
import { execOutput } from "./step-runner.js";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Shared constants & builders for upgrade / rollback lifecycle events
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
/** User-facing progress messages shared across upgrade and rollback flows. */
|
|
10
|
+
export const UPGRADE_PROGRESS = {
|
|
11
|
+
DOWNLOADING: "Downloading the update…",
|
|
12
|
+
BACKING_UP: "Saving a backup of your data…",
|
|
13
|
+
INSTALLING: "Installing the update…",
|
|
14
|
+
REVERTING: "The update didn't work. Reverting to the previous version…",
|
|
15
|
+
REVERTING_MIGRATIONS: "Reverting database changes…",
|
|
16
|
+
RESTORING: "Restoring your data…",
|
|
17
|
+
SWITCHING: "Switching to the previous version…",
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
export function buildStartingEvent(
|
|
21
|
+
targetVersion: string,
|
|
22
|
+
expectedDowntimeSeconds = 60,
|
|
23
|
+
) {
|
|
24
|
+
return { type: "starting" as const, targetVersion, expectedDowntimeSeconds };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildProgressEvent(statusMessage: string) {
|
|
28
|
+
return { type: "progress" as const, statusMessage };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildCompleteEvent(
|
|
32
|
+
installedVersion: string,
|
|
33
|
+
success: boolean,
|
|
34
|
+
rolledBackToVersion?: string,
|
|
35
|
+
) {
|
|
36
|
+
return {
|
|
37
|
+
type: "complete" as const,
|
|
38
|
+
installedVersion,
|
|
39
|
+
success,
|
|
40
|
+
...(rolledBackToVersion ? { rolledBackToVersion } : {}),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildUpgradeCommitMessage(options: {
|
|
45
|
+
action: "upgrade" | "rollback";
|
|
46
|
+
phase: "starting" | "complete";
|
|
47
|
+
from: string;
|
|
48
|
+
to: string;
|
|
49
|
+
topology: "docker" | "managed";
|
|
50
|
+
assistantId: string;
|
|
51
|
+
result?: "success" | "failure";
|
|
52
|
+
}): string {
|
|
53
|
+
const { action, phase, from, to, topology, assistantId, result } = options;
|
|
54
|
+
const header =
|
|
55
|
+
phase === "starting"
|
|
56
|
+
? `[${action}] Starting: ${from} → ${to}`
|
|
57
|
+
: `[${action}] Complete: ${from} → ${to}`;
|
|
58
|
+
const lines = [
|
|
59
|
+
header,
|
|
60
|
+
"",
|
|
61
|
+
`assistant: ${assistantId}`,
|
|
62
|
+
`from: ${from}`,
|
|
63
|
+
`to: ${to}`,
|
|
64
|
+
];
|
|
65
|
+
if (result) lines.push(`result: ${result}`);
|
|
66
|
+
lines.push(`topology: ${topology}`);
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Environment variable keys that are set by CLI run arguments and should
|
|
72
|
+
* not be replayed from a captured container environment during upgrades
|
|
73
|
+
* or rollbacks. Shared between upgrade.ts and rollback.ts.
|
|
74
|
+
*/
|
|
75
|
+
export const CONTAINER_ENV_EXCLUDE_KEYS: ReadonlySet<string> = new Set([
|
|
76
|
+
"CES_SERVICE_TOKEN",
|
|
77
|
+
"VELLUM_ASSISTANT_NAME",
|
|
78
|
+
"RUNTIME_HTTP_HOST",
|
|
79
|
+
"PATH",
|
|
80
|
+
"ACTOR_TOKEN_SIGNING_KEY",
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Capture environment variables from a running Docker container so they
|
|
85
|
+
* can be replayed onto the replacement container after upgrade.
|
|
86
|
+
*/
|
|
87
|
+
export async function captureContainerEnv(
|
|
88
|
+
containerName: string,
|
|
89
|
+
): Promise<Record<string, string>> {
|
|
90
|
+
const captured: Record<string, string> = {};
|
|
91
|
+
try {
|
|
92
|
+
const raw = await execOutput("docker", [
|
|
93
|
+
"inspect",
|
|
94
|
+
"--format",
|
|
95
|
+
"{{json .Config.Env}}",
|
|
96
|
+
containerName,
|
|
97
|
+
]);
|
|
98
|
+
const entries = JSON.parse(raw) as string[];
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
const eqIdx = entry.indexOf("=");
|
|
101
|
+
if (eqIdx > 0) {
|
|
102
|
+
captured[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Container may not exist or not be inspectable
|
|
107
|
+
}
|
|
108
|
+
return captured;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Poll the gateway `/readyz` endpoint until it returns 200 or the timeout
|
|
113
|
+
* elapses. Returns whether the assistant became ready.
|
|
114
|
+
*/
|
|
115
|
+
export async function waitForReady(runtimeUrl: string): Promise<boolean> {
|
|
116
|
+
const readyUrl = `${runtimeUrl}/readyz`;
|
|
117
|
+
const start = Date.now();
|
|
118
|
+
|
|
119
|
+
while (Date.now() - start < DOCKER_READY_TIMEOUT_MS) {
|
|
120
|
+
try {
|
|
121
|
+
const resp = await fetch(readyUrl, {
|
|
122
|
+
signal: AbortSignal.timeout(5000),
|
|
123
|
+
});
|
|
124
|
+
if (resp.ok) {
|
|
125
|
+
const elapsedSec = ((Date.now() - start) / 1000).toFixed(1);
|
|
126
|
+
console.log(`Assistant ready after ${elapsedSec}s`);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
let detail = "";
|
|
130
|
+
try {
|
|
131
|
+
const body = await resp.text();
|
|
132
|
+
const json = JSON.parse(body);
|
|
133
|
+
const parts = [json.status];
|
|
134
|
+
if (json.upstream != null) parts.push(`upstream=${json.upstream}`);
|
|
135
|
+
detail = ` — ${parts.join(", ")}`;
|
|
136
|
+
} catch {
|
|
137
|
+
// ignore parse errors
|
|
138
|
+
}
|
|
139
|
+
console.log(`Readiness check: ${resp.status}${detail} (retrying...)`);
|
|
140
|
+
} catch {
|
|
141
|
+
// Connection refused / timeout — not up yet
|
|
142
|
+
}
|
|
143
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Best-effort broadcast of an upgrade lifecycle event to connected clients
|
|
151
|
+
* via the gateway's upgrade-broadcast proxy. Uses guardian token auth.
|
|
152
|
+
* Failures are logged but never block the upgrade flow.
|
|
153
|
+
*/
|
|
154
|
+
export async function broadcastUpgradeEvent(
|
|
155
|
+
gatewayUrl: string,
|
|
156
|
+
assistantId: string,
|
|
157
|
+
event: Record<string, unknown>,
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
try {
|
|
160
|
+
const token = loadGuardianToken(assistantId);
|
|
161
|
+
const headers: Record<string, string> = {
|
|
162
|
+
"Content-Type": "application/json",
|
|
163
|
+
};
|
|
164
|
+
if (token?.accessToken) {
|
|
165
|
+
headers["Authorization"] = `Bearer ${token.accessToken}`;
|
|
166
|
+
}
|
|
167
|
+
await fetch(`${gatewayUrl}/v1/admin/upgrade-broadcast`, {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers,
|
|
170
|
+
body: JSON.stringify(event),
|
|
171
|
+
signal: AbortSignal.timeout(3000),
|
|
172
|
+
});
|
|
173
|
+
} catch {
|
|
174
|
+
// Best-effort — gateway/daemon may already be shutting down or not yet ready
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Roll back DB and workspace migrations to a target state via the gateway.
|
|
180
|
+
* Best-effort — failures are logged but never block the rollback flow.
|
|
181
|
+
*/
|
|
182
|
+
export async function rollbackMigrations(
|
|
183
|
+
gatewayUrl: string,
|
|
184
|
+
assistantId: string,
|
|
185
|
+
targetDbVersion?: number,
|
|
186
|
+
targetWorkspaceMigrationId?: string,
|
|
187
|
+
rollbackToRegistryCeiling?: boolean,
|
|
188
|
+
): Promise<boolean> {
|
|
189
|
+
if (
|
|
190
|
+
!rollbackToRegistryCeiling &&
|
|
191
|
+
targetDbVersion === undefined &&
|
|
192
|
+
targetWorkspaceMigrationId === undefined
|
|
193
|
+
) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const token = loadGuardianToken(assistantId);
|
|
198
|
+
const headers: Record<string, string> = {
|
|
199
|
+
"Content-Type": "application/json",
|
|
200
|
+
};
|
|
201
|
+
if (token?.accessToken) {
|
|
202
|
+
headers["Authorization"] = `Bearer ${token.accessToken}`;
|
|
203
|
+
}
|
|
204
|
+
const body: Record<string, unknown> = {};
|
|
205
|
+
if (targetDbVersion !== undefined) body.targetDbVersion = targetDbVersion;
|
|
206
|
+
if (targetWorkspaceMigrationId !== undefined)
|
|
207
|
+
body.targetWorkspaceMigrationId = targetWorkspaceMigrationId;
|
|
208
|
+
if (rollbackToRegistryCeiling) body.rollbackToRegistryCeiling = true;
|
|
209
|
+
|
|
210
|
+
const resp = await fetch(`${gatewayUrl}/v1/admin/rollback-migrations`, {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers,
|
|
213
|
+
body: JSON.stringify(body),
|
|
214
|
+
signal: AbortSignal.timeout(120_000),
|
|
215
|
+
});
|
|
216
|
+
if (!resp.ok) {
|
|
217
|
+
const text = await resp.text();
|
|
218
|
+
console.warn(`⚠️ Migration rollback failed (${resp.status}): ${text}`);
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
const result = (await resp.json()) as {
|
|
222
|
+
rolledBack?: { db?: string[]; workspace?: string[] };
|
|
223
|
+
};
|
|
224
|
+
const dbCount = result.rolledBack?.db?.length ?? 0;
|
|
225
|
+
const wsCount = result.rolledBack?.workspace?.length ?? 0;
|
|
226
|
+
if (dbCount > 0 || wsCount > 0) {
|
|
227
|
+
console.log(
|
|
228
|
+
` Rolled back ${dbCount} DB migration(s) and ${wsCount} workspace migration(s)`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
} catch (err) {
|
|
233
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
234
|
+
console.warn(`⚠️ Migration rollback failed: ${msg}`);
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a version string into { major, minor, patch } components.
|
|
3
|
+
* Handles optional `v` prefix (e.g., "v1.2.3" or "1.2.3").
|
|
4
|
+
* Returns null if the string cannot be parsed as semver.
|
|
5
|
+
*/
|
|
6
|
+
export function parseVersion(
|
|
7
|
+
version: string,
|
|
8
|
+
): { major: number; minor: number; patch: number } | null {
|
|
9
|
+
const stripped = version.replace(/^[vV]/, "");
|
|
10
|
+
const segments = stripped.split(".");
|
|
11
|
+
|
|
12
|
+
if (segments.length < 2) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const major = parseInt(segments[0], 10);
|
|
17
|
+
const minor = parseInt(segments[1], 10);
|
|
18
|
+
const patch = segments.length >= 3 ? parseInt(segments[2], 10) : 0;
|
|
19
|
+
|
|
20
|
+
if (isNaN(major) || isNaN(minor) || isNaN(patch)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { major, minor, patch };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check whether two version strings are compatible.
|
|
29
|
+
* Compatibility requires matching major AND minor versions.
|
|
30
|
+
* Patch differences are allowed.
|
|
31
|
+
* Returns false if either version cannot be parsed.
|
|
32
|
+
*/
|
|
33
|
+
export function isVersionCompatible(
|
|
34
|
+
clientVersion: string,
|
|
35
|
+
serviceGroupVersion: string,
|
|
36
|
+
): boolean {
|
|
37
|
+
const a = parseVersion(clientVersion);
|
|
38
|
+
const b = parseVersion(serviceGroupVersion);
|
|
39
|
+
|
|
40
|
+
if (a === null || b === null) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return a.major === b.major && a.minor === b.minor;
|
|
45
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { exec } from "./step-runner";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Best-effort git commit in a workspace directory.
|
|
5
|
+
*
|
|
6
|
+
* Stages all changes and creates an `--allow-empty` commit so the
|
|
7
|
+
* history records every upgrade/rollback even when no files changed.
|
|
8
|
+
*
|
|
9
|
+
* Safety measures (mirroring WorkspaceGitService in the assistant package):
|
|
10
|
+
* - Deterministic committer identity (`vellum-cli`)
|
|
11
|
+
* - Hooks disabled (`core.hooksPath=/dev/null`, `--no-verify`)
|
|
12
|
+
*
|
|
13
|
+
* Callers should wrap this in try/catch — failures must never block
|
|
14
|
+
* the upgrade or rollback flow.
|
|
15
|
+
*/
|
|
16
|
+
export async function commitWorkspaceState(
|
|
17
|
+
workspaceDir: string,
|
|
18
|
+
message: string,
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
const opts = { cwd: workspaceDir };
|
|
21
|
+
await exec("git", ["add", "-A"], opts);
|
|
22
|
+
await exec(
|
|
23
|
+
"git",
|
|
24
|
+
[
|
|
25
|
+
"-c",
|
|
26
|
+
`user.name=${process.env.CLI_GIT_USER_NAME || "vellum-cli"}`,
|
|
27
|
+
"-c",
|
|
28
|
+
`user.email=${process.env.CLI_GIT_USER_EMAIL || "cli@vellum.ai"}`,
|
|
29
|
+
"-c",
|
|
30
|
+
"core.hooksPath=/dev/null",
|
|
31
|
+
"commit",
|
|
32
|
+
"--no-verify",
|
|
33
|
+
"--allow-empty",
|
|
34
|
+
"-m",
|
|
35
|
+
message,
|
|
36
|
+
],
|
|
37
|
+
opts,
|
|
38
|
+
);
|
|
39
|
+
}
|