@vellumai/cli 0.7.1 → 0.7.2

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.
@@ -1,4 +1,8 @@
1
1
  import { randomBytes } from "crypto";
2
+ import { spawnSync } from "child_process";
3
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
4
+ import { homedir } from "os";
5
+ import { join } from "path";
2
6
 
3
7
  import type { AssistantEntry } from "./assistant-config.js";
4
8
  import { saveAssistantEntry } from "./assistant-config.js";
@@ -18,6 +22,62 @@ import { resolveImageRefs } from "./platform-releases.js";
18
22
  import { exec, execOutput } from "./step-runner.js";
19
23
  import { compareVersions } from "./version-compat.js";
20
24
 
25
+ // ---------------------------------------------------------------------------
26
+ // Failure log capture
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /** XDG-compliant directory for upgrade failure logs */
30
+ function getUpgradeLogsDir(): string {
31
+ const stateHome =
32
+ process.env.XDG_STATE_HOME?.trim() || join(homedir(), ".local", "state");
33
+ return join(stateHome, "vellum", "upgrade-logs");
34
+ }
35
+
36
+ /**
37
+ * Capture stdout/stderr from all three containers after a readiness failure
38
+ * and write them to an XDG state directory. Returns the directory path so
39
+ * the caller can print it for the user.
40
+ *
41
+ * Runs best-effort — never throws.
42
+ */
43
+ export async function captureUpgradeFailureLogs(
44
+ res: ReturnType<typeof dockerResourceNames>,
45
+ label: string,
46
+ ): Promise<string | null> {
47
+ const isoTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
48
+ const logDir = join(getUpgradeLogsDir(), `${label}-${isoTimestamp}`);
49
+ try {
50
+ mkdirSync(logDir, { recursive: true });
51
+
52
+ const containers: [string, string][] = [
53
+ [res.assistantContainer, "assistant.log"],
54
+ [res.gatewayContainer, "gateway.log"],
55
+ [res.cesContainer, "credential-executor.log"],
56
+ ];
57
+
58
+ for (const [container, filename] of containers) {
59
+ try {
60
+ // Capture stdout + stderr together so container logs written to either
61
+ // stream (docker logs writes container stdout→stdout, stderr→stderr)
62
+ // are preserved in a single file. spawnSync avoids the execOutput
63
+ // limitation of returning only stdout on success.
64
+ const result = spawnSync("docker", ["logs", "--tail", "500", container], {
65
+ encoding: "utf8",
66
+ maxBuffer: 10 * 1024 * 1024, // 10 MB
67
+ });
68
+ const output = [result.stdout, result.stderr].filter(Boolean).join("");
69
+ if (output) writeFileSync(join(logDir, filename), output);
70
+ } catch {
71
+ // Container may not exist or may have already been removed
72
+ }
73
+ }
74
+
75
+ return existsSync(logDir) ? logDir : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
21
81
  // ---------------------------------------------------------------------------
22
82
  // Shared constants & builders for upgrade / rollback lifecycle events
23
83
  // ---------------------------------------------------------------------------
@@ -734,6 +794,11 @@ export async function performDockerRollback(
734
794
  // Failure path — attempt auto-rollback to original version
735
795
  console.error(`\n❌ Containers failed to become ready within the timeout.`);
736
796
 
797
+ const logDir = await captureUpgradeFailureLogs(res, `${instanceName}-rollback-failure`);
798
+ if (logDir) {
799
+ console.log(`📋 Container logs saved to: ${logDir}`);
800
+ }
801
+
737
802
  if (currentImageRefs) {
738
803
  await broadcastUpgradeEvent(
739
804
  entry.runtimeUrl,
@@ -1,212 +0,0 @@
1
- import { createHash } from "crypto";
2
- import { readFileSync } from "fs";
3
- import jsQR from "jsqr";
4
- import { hostname, userInfo } from "os";
5
- import { PNG } from "pngjs";
6
-
7
- import { saveAssistantEntry } from "../lib/assistant-config";
8
- import type { AssistantEntry } from "../lib/assistant-config";
9
- import type { Species } from "../lib/constants";
10
- import { saveGuardianToken } from "../lib/guardian-token";
11
- import type { GuardianTokenData } from "../lib/guardian-token";
12
- import { generateInstanceName } from "../lib/random-name";
13
-
14
- interface QRPairingPayload {
15
- type: string;
16
- v: number;
17
- id?: string;
18
- g: string;
19
- pairingRequestId: string;
20
- pairingSecret: string;
21
- }
22
-
23
- interface PairingResponse {
24
- status: "approved" | "pending";
25
- bearerToken?: string;
26
- gatewayUrl?: string;
27
- }
28
-
29
- function decodeQRCodeFromPng(pngPath: string): string {
30
- const fileData = readFileSync(pngPath);
31
- const png = PNG.sync.read(fileData);
32
- const code = jsQR(new Uint8ClampedArray(png.data), png.width, png.height);
33
- if (!code) {
34
- throw new Error("Could not decode QR code from the provided PNG image.");
35
- }
36
- return code.data;
37
- }
38
-
39
- function safeUserInfoUsername(): string {
40
- try {
41
- return userInfo().username;
42
- } catch {
43
- return "";
44
- }
45
- }
46
-
47
- function getDeviceId(): string {
48
- const raw = hostname() + safeUserInfoUsername();
49
- return createHash("sha256").update(raw).digest("hex");
50
- }
51
-
52
- const PAIRING_POLL_INTERVAL_MS = 2000;
53
- const PAIRING_POLL_TIMEOUT_MS = 120_000;
54
-
55
- async function pollForApproval(
56
- gatewayUrl: string,
57
- pairingRequestId: string,
58
- pairingSecret: string,
59
- ): Promise<PairingResponse> {
60
- const startTime = Date.now();
61
-
62
- while (Date.now() - startTime < PAIRING_POLL_TIMEOUT_MS) {
63
- const statusUrl = `${gatewayUrl}/pairing/status?id=${encodeURIComponent(pairingRequestId)}&secret=${encodeURIComponent(pairingSecret)}`;
64
- const statusRes = await fetch(statusUrl);
65
-
66
- if (!statusRes.ok) {
67
- const body = await statusRes.text().catch(() => "");
68
- throw new Error(
69
- `Failed to check pairing status: HTTP ${statusRes.status}: ${body || statusRes.statusText}`,
70
- );
71
- }
72
-
73
- const statusBody = (await statusRes.json()) as PairingResponse;
74
-
75
- if (statusBody.status === "approved") {
76
- return statusBody;
77
- }
78
-
79
- await new Promise((resolve) =>
80
- setTimeout(resolve, PAIRING_POLL_INTERVAL_MS),
81
- );
82
- }
83
-
84
- throw new Error("Pairing timed out waiting for approval.");
85
- }
86
-
87
- export async function pair(): Promise<void> {
88
- const args = process.argv.slice(3);
89
-
90
- if (args.includes("--help") || args.includes("-h")) {
91
- console.log("Usage: vellum pair <path-to-qrcode.png>");
92
- console.log("");
93
- console.log(
94
- "Pair with a remote assistant by scanning the QR code PNG generated during setup.",
95
- );
96
- process.exit(0);
97
- }
98
-
99
- const qrCodePath = args[0] || process.env.VELLUM_CUSTOM_QR_CODE_PATH;
100
-
101
- if (!qrCodePath) {
102
- console.error("Usage: vellum pair <path-to-qrcode.png>");
103
- console.error("");
104
- console.error(
105
- "Pair with a remote assistant by scanning the QR code PNG generated during setup.",
106
- );
107
- process.exit(1);
108
- }
109
-
110
- const species: Species = "vellum";
111
-
112
- try {
113
- console.log("Reading QR code from provided image...");
114
- const qrData = decodeQRCodeFromPng(qrCodePath);
115
-
116
- let payload: QRPairingPayload;
117
- try {
118
- payload = JSON.parse(qrData) as QRPairingPayload;
119
- } catch {
120
- throw new Error("QR code does not contain valid pairing data.");
121
- }
122
-
123
- if (
124
- payload.type !== "vellum-daemon" ||
125
- !payload.g ||
126
- !payload.pairingRequestId ||
127
- !payload.pairingSecret
128
- ) {
129
- throw new Error("QR code does not contain valid Vellum pairing data.");
130
- }
131
-
132
- const instanceName = generateInstanceName(species);
133
- const runtimeUrl = payload.g;
134
- const deviceId = getDeviceId();
135
- const deviceName = hostname();
136
-
137
- console.log(`Pairing with remote assistant at ${runtimeUrl}...`);
138
-
139
- const requestUrl = `${runtimeUrl}/pairing/request`;
140
- const requestRes = await fetch(requestUrl, {
141
- method: "POST",
142
- headers: { "Content-Type": "application/json" },
143
- body: JSON.stringify({
144
- pairingRequestId: payload.pairingRequestId,
145
- pairingSecret: payload.pairingSecret,
146
- deviceId,
147
- deviceName,
148
- }),
149
- });
150
-
151
- if (!requestRes.ok) {
152
- const body = await requestRes.text().catch(() => "");
153
- throw new Error(
154
- `Failed to initiate pairing: HTTP ${requestRes.status}: ${body || requestRes.statusText}`,
155
- );
156
- }
157
-
158
- const requestBody = (await requestRes.json()) as PairingResponse;
159
-
160
- let bearerToken: string | undefined;
161
-
162
- if (requestBody.status === "approved") {
163
- bearerToken = requestBody.bearerToken;
164
- } else if (requestBody.status === "pending") {
165
- console.log("Waiting for pairing approval...");
166
- const approvedResponse = await pollForApproval(
167
- runtimeUrl,
168
- payload.pairingRequestId,
169
- payload.pairingSecret,
170
- );
171
- bearerToken = approvedResponse.bearerToken;
172
- } else {
173
- throw new Error(
174
- `Unexpected pairing response status: ${requestBody.status}`,
175
- );
176
- }
177
-
178
- const customEntry: AssistantEntry = {
179
- assistantId: instanceName,
180
- runtimeUrl,
181
- cloud: "custom",
182
- species,
183
- hatchedAt: new Date().toISOString(),
184
- };
185
- saveAssistantEntry(customEntry);
186
-
187
- if (bearerToken) {
188
- const tokenData: GuardianTokenData = {
189
- guardianPrincipalId: "",
190
- accessToken: bearerToken,
191
- accessTokenExpiresAt: "",
192
- refreshToken: "",
193
- refreshTokenExpiresAt: "",
194
- refreshAfter: "",
195
- isNew: true,
196
- deviceId: getDeviceId(),
197
- leasedAt: new Date().toISOString(),
198
- };
199
- saveGuardianToken(instanceName, tokenData);
200
- }
201
-
202
- console.log("");
203
- console.log("Successfully paired with remote assistant!");
204
- console.log("Instance details:");
205
- console.log(` Name: ${instanceName}`);
206
- console.log(` Runtime URL: ${runtimeUrl}`);
207
- console.log("");
208
- } catch (error) {
209
- console.error("Error:", error instanceof Error ? error.message : error);
210
- process.exit(1);
211
- }
212
- }