@mycadet/cli 0.12.11 → 0.13.0
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/README.md +13 -0
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/package.json +3 -2
- package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
- package/src/__tests__/backup-ops-matching.test.ts +15 -13
- package/src/__tests__/backup-ops.test.ts +105 -0
- package/src/__tests__/backup.test.ts +114 -673
- package/src/__tests__/cloud-releases.test.ts +82 -0
- package/src/__tests__/commands-mock.ts +101 -0
- package/src/__tests__/debug.test.ts +17 -91
- package/src/__tests__/devices.test.ts +2 -2
- package/src/__tests__/gateway-token.test.ts +267 -0
- package/src/__tests__/host-teleport-docker.test.ts +371 -0
- package/src/__tests__/host-teleport.test.ts +405 -0
- package/src/__tests__/nginx-ingress.test.ts +182 -23
- package/src/__tests__/orphan-detection.test.ts +1 -1
- package/src/__tests__/paired-lifecycle.test.ts +2 -2
- package/src/__tests__/ps-cloud-status.test.ts +6 -6
- package/src/__tests__/root-backup-docker.test.ts +294 -0
- package/src/__tests__/root-backup.test.ts +527 -0
- package/src/__tests__/root-restore-command.test.ts +147 -0
- package/src/__tests__/root-signing-key.test.ts +50 -0
- package/src/__tests__/setup.test.ts +3 -3
- package/src/__tests__/sleep.test.ts +11 -0
- package/src/__tests__/teleport.test.ts +68 -2996
- package/src/__tests__/tunnel.test.ts +1 -1
- package/src/__tests__/upgrade-local.test.ts +21 -11
- package/src/__tests__/upgrade-replay-env.test.ts +2 -2
- package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
- package/src/__tests__/windows-lifecycle.test.ts +25 -0
- package/src/commands/backup.ts +44 -399
- package/src/commands/client.ts +3 -3
- package/src/commands/completions.ts +0 -2
- package/src/commands/debug.ts +75 -131
- package/src/commands/devices.ts +51 -17
- package/src/commands/gateway/token.ts +77 -23
- package/src/commands/pair.ts +2 -2
- package/src/commands/restore.ts +77 -444
- package/src/commands/retire.ts +2 -3
- package/src/commands/sleep.ts +15 -2
- package/src/commands/teleport.ts +47 -1559
- package/src/commands/upgrade.ts +78 -156
- package/src/commands/wake.ts +1 -1
- package/src/index.ts +0 -4
- package/src/lib/__tests__/http-client-version.test.ts +29 -0
- package/src/lib/__tests__/runtime-url.test.ts +3 -75
- package/src/lib/__tests__/web-dist.test.ts +27 -1
- package/src/lib/assistant-client.ts +1 -1
- package/src/lib/assistant-config.ts +0 -18
- package/src/lib/backup-ops.ts +37 -251
- package/src/lib/cloud-client.ts +10 -13
- package/src/lib/cloud-login.ts +2 -1
- package/src/lib/cloud-releases.ts +54 -10
- package/src/lib/cloud-values.ts +1 -1
- package/src/lib/confirm-action.ts +2 -2
- package/src/lib/docker.ts +59 -22
- package/src/lib/github-repository.ts +2 -0
- package/src/lib/guardian-token.ts +1 -23
- package/src/lib/hatch-local.ts +17 -1
- package/src/lib/host-teleport.ts +506 -0
- package/src/lib/http-client.ts +25 -0
- package/src/lib/live-voice/tunnel.test.ts +1 -1
- package/src/lib/live-voice/tunnel.ts +1 -1
- package/src/lib/local.ts +4 -2
- package/src/lib/nginx-ingress.ts +134 -18
- package/src/lib/process.ts +13 -0
- package/src/lib/root-backup.ts +917 -0
- package/src/lib/root-signing-key.ts +31 -0
- package/src/lib/runtime-url.ts +0 -29
- package/src/lib/teleport-backup.ts +3 -16
- package/src/lib/upgrade-lifecycle.ts +63 -141
- package/src/lib/web-dist.ts +23 -1
- package/src/__tests__/bundle-staging.test.ts +0 -268
- package/src/__tests__/unpair.test.ts +0 -160
- package/src/commands/rollback.ts +0 -479
- package/src/commands/unpair.ts +0 -122
- package/src/lib/__tests__/job-polling.test.ts +0 -278
- package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
- package/src/lib/bundle-staging.ts +0 -220
- package/src/lib/job-polling.ts +0 -195
- package/src/lib/local-runtime-client.ts +0 -374
package/src/lib/backup-ops.ts
CHANGED
|
@@ -1,21 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
mkdirSync,
|
|
4
|
-
readdirSync,
|
|
5
|
-
readFileSync,
|
|
6
|
-
statSync,
|
|
7
|
-
unlinkSync,
|
|
8
|
-
writeFileSync,
|
|
9
|
-
} from "fs";
|
|
10
|
-
import { dirname, join } from "path";
|
|
1
|
+
import { existsSync, readdirSync, statSync, unlinkSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
11
3
|
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
stageBundleForRestore,
|
|
15
|
-
type RestoreStagingTarget,
|
|
16
|
-
} from "./bundle-staging.js";
|
|
17
|
-
import { loadGuardianToken, refreshGuardianToken } from "./guardian-token.js";
|
|
18
|
-
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
4
|
+
import { findAssistantByName } from "./assistant-config.js";
|
|
5
|
+
import { createRootBackup, restoreRootBackup } from "./root-backup.js";
|
|
19
6
|
import { getStateDir } from "./environments/paths.js";
|
|
20
7
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
21
8
|
|
|
@@ -31,261 +18,60 @@ export function formatSize(bytes: number): string {
|
|
|
31
18
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
32
19
|
}
|
|
33
20
|
|
|
34
|
-
/**
|
|
35
|
-
* Obtain a valid guardian access token.
|
|
36
|
-
*
|
|
37
|
-
* Resolution order:
|
|
38
|
-
* 1. Cached token that is not yet expired — use as-is.
|
|
39
|
-
* 2. Cached token with a valid refresh token — call /v1/guardian/refresh.
|
|
40
|
-
* 3. No usable token — return null so callers can skip the backup gracefully
|
|
41
|
-
* rather than hitting /v1/guardian/init (which 403s on bootstrapped instances).
|
|
42
|
-
*/
|
|
43
|
-
async function getGuardianAccessToken(
|
|
44
|
-
runtimeUrl: string,
|
|
45
|
-
assistantId: string,
|
|
46
|
-
): Promise<string | null> {
|
|
47
|
-
const tokenData = loadGuardianToken(assistantId);
|
|
48
|
-
if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
|
|
49
|
-
return tokenData.accessToken;
|
|
50
|
-
}
|
|
51
|
-
const refreshed = await refreshGuardianToken(runtimeUrl, assistantId);
|
|
52
|
-
return refreshed?.accessToken ?? null;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Create a .vbundle backup of a running assistant.
|
|
57
|
-
* Returns the path to the saved backup, or null if backup failed.
|
|
58
|
-
* Never throws — failures are logged as warnings.
|
|
59
|
-
*/
|
|
21
|
+
/** Internal safety backup callers resolve their registered host topology, never runtime auth. */
|
|
60
22
|
export async function createBackup(
|
|
61
|
-
|
|
23
|
+
_runtimeUrl: string,
|
|
62
24
|
assistantId: string,
|
|
63
25
|
options?: { prefix?: string; description?: string; timeoutMs?: number },
|
|
64
26
|
): Promise<string | null> {
|
|
65
|
-
const timeoutMs = options?.timeoutMs ?? 120_000;
|
|
66
27
|
try {
|
|
67
|
-
|
|
68
|
-
if (!
|
|
69
|
-
|
|
70
|
-
"Warning: backup skipped — no valid guardian token available",
|
|
71
|
-
);
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
let response = await loopbackSafeFetch(
|
|
76
|
-
`${runtimeUrl}/v1/migrations/export`,
|
|
77
|
-
{
|
|
78
|
-
method: "POST",
|
|
79
|
-
headers: {
|
|
80
|
-
Authorization: `Bearer ${accessToken}`,
|
|
81
|
-
"Content-Type": "application/json",
|
|
82
|
-
},
|
|
83
|
-
body: JSON.stringify({
|
|
84
|
-
description: options?.description ?? "CLI backup",
|
|
85
|
-
}),
|
|
86
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
87
|
-
},
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
// Retry once with a refreshed token on 401 — the cached token may be
|
|
91
|
-
// stale after a container restart that regenerated the gateway signing key.
|
|
92
|
-
if (response.status === 401) {
|
|
93
|
-
const refreshed = await refreshGuardianToken(runtimeUrl, assistantId);
|
|
94
|
-
if (!refreshed) {
|
|
95
|
-
console.warn(
|
|
96
|
-
`Warning: backup export failed (401) and token refresh failed`,
|
|
97
|
-
);
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
accessToken = refreshed.accessToken;
|
|
101
|
-
response = await loopbackSafeFetch(`${runtimeUrl}/v1/migrations/export`, {
|
|
102
|
-
method: "POST",
|
|
103
|
-
headers: {
|
|
104
|
-
Authorization: `Bearer ${accessToken}`,
|
|
105
|
-
"Content-Type": "application/json",
|
|
106
|
-
},
|
|
107
|
-
body: JSON.stringify({
|
|
108
|
-
description: options?.description ?? "CLI backup",
|
|
109
|
-
}),
|
|
110
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (!response.ok) {
|
|
115
|
-
const body = await response.text();
|
|
116
|
-
console.warn(
|
|
117
|
-
`Warning: backup export failed (${response.status}): ${body}`,
|
|
118
|
-
);
|
|
119
|
-
return null;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const arrayBuffer = await response.arrayBuffer();
|
|
123
|
-
const data = new Uint8Array(arrayBuffer);
|
|
124
|
-
|
|
125
|
-
const isoTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
28
|
+
const entry = findAssistantByName(assistantId);
|
|
29
|
+
if (!entry)
|
|
30
|
+
throw new Error(`No registered assistant found for '${assistantId}'.`);
|
|
126
31
|
const prefix = options?.prefix ?? assistantId;
|
|
127
|
-
|
|
32
|
+
if (prefix.includes("/") || prefix.includes("\\"))
|
|
33
|
+
throw new Error("Backup prefix must be a filename.");
|
|
34
|
+
const path = join(
|
|
128
35
|
getBackupsDir(),
|
|
129
|
-
`${prefix}-${
|
|
36
|
+
`${prefix}-${new Date().toISOString().replace(/[:.]/g, "-")}.tar.gz`,
|
|
130
37
|
);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
return outputPath;
|
|
136
|
-
} catch (err) {
|
|
137
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
138
|
-
console.warn(`Warning: backup failed: ${msg}`);
|
|
139
|
-
return null;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
async function postRestoreWithRetries(
|
|
144
|
-
postImport: () => Promise<Response>,
|
|
145
|
-
runtimeUrl: string,
|
|
146
|
-
assistantId: string,
|
|
147
|
-
onTokenRefresh: (token: string) => void,
|
|
148
|
-
): Promise<Response | null> {
|
|
149
|
-
let response = await postImport();
|
|
150
|
-
|
|
151
|
-
// Retry once with a refreshed token on 401 — the cached token may be
|
|
152
|
-
// stale after a container restart that regenerated the gateway signing key.
|
|
153
|
-
if (response.status === 401) {
|
|
154
|
-
const refreshed = await refreshGuardianToken(runtimeUrl, assistantId);
|
|
155
|
-
if (!refreshed) {
|
|
156
|
-
console.warn(`Warning: restore failed (401) and token refresh failed`);
|
|
157
|
-
return null;
|
|
158
|
-
}
|
|
159
|
-
onTokenRefresh(refreshed.accessToken);
|
|
160
|
-
response = await postImport();
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// A freshly-(re)started gateway 503s {status:"starting"} for a couple of
|
|
164
|
-
// seconds until its own assistant poll observes the migration state and
|
|
165
|
-
// opens the traffic gate — retry through that window rather than failing
|
|
166
|
-
// the recovery. The daemon's running-migrations 503 carries a `reason`
|
|
167
|
-
// field and is excluded: the import must not race an in-flight migration.
|
|
168
|
-
const startingGateDeadline = Date.now() + 15_000;
|
|
169
|
-
while (!response.ok) {
|
|
170
|
-
const body = await response.text();
|
|
171
|
-
let gatewayStarting = false;
|
|
172
|
-
try {
|
|
173
|
-
const parsed = JSON.parse(body) as {
|
|
174
|
-
status?: string;
|
|
175
|
-
reason?: string;
|
|
176
|
-
};
|
|
177
|
-
gatewayStarting =
|
|
178
|
-
parsed.status === "starting" && parsed.reason === undefined;
|
|
179
|
-
} catch {
|
|
180
|
-
// Non-JSON error body — not the starting gate.
|
|
181
|
-
}
|
|
182
|
-
if (gatewayStarting && Date.now() < startingGateDeadline) {
|
|
183
|
-
await new Promise((r) => setTimeout(r, 1_000));
|
|
184
|
-
response = await postImport();
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
console.warn(`Warning: restore failed (${response.status}): ${body}`);
|
|
188
|
-
return null;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return response;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
async function finishRestoreResponse(response: Response): Promise<boolean> {
|
|
195
|
-
const result = (await response.json()) as {
|
|
196
|
-
success: boolean;
|
|
197
|
-
message?: string;
|
|
198
|
-
reason?: string;
|
|
199
|
-
};
|
|
200
|
-
if (!result.success) {
|
|
38
|
+
await createRootBackup(entry, path);
|
|
39
|
+
return path;
|
|
40
|
+
} catch (error) {
|
|
201
41
|
console.warn(
|
|
202
|
-
`
|
|
42
|
+
`Backup failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
203
43
|
);
|
|
204
|
-
return
|
|
44
|
+
return null;
|
|
205
45
|
}
|
|
206
|
-
return true;
|
|
207
46
|
}
|
|
208
47
|
|
|
209
|
-
/**
|
|
210
|
-
* Restore a .vbundle backup into a running assistant.
|
|
211
|
-
* Returns true if restore succeeded, false otherwise.
|
|
212
|
-
* Never throws — failures are logged as warnings.
|
|
213
|
-
*/
|
|
48
|
+
/** Restore every persistent root; failed-state recovery can leave services stopped for its caller to restart. */
|
|
214
49
|
export async function restoreBackup(
|
|
215
|
-
|
|
50
|
+
_runtimeUrl: string,
|
|
216
51
|
assistantId: string,
|
|
217
52
|
backupPath: string,
|
|
218
|
-
|
|
53
|
+
options: { resume?: boolean } = {},
|
|
219
54
|
): Promise<boolean> {
|
|
220
55
|
try {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
"Warning: restore skipped — no valid guardian token available",
|
|
230
|
-
);
|
|
231
|
-
return false;
|
|
232
|
-
}
|
|
233
|
-
let token = initialToken;
|
|
234
|
-
|
|
235
|
-
if (staging) {
|
|
236
|
-
const staged = await stageBundleForRestore(staging, backupPath);
|
|
237
|
-
try {
|
|
238
|
-
const postImport = () =>
|
|
239
|
-
importStagedBundle(runtimeUrl, token, staged.relativePath);
|
|
240
|
-
const response = await postRestoreWithRetries(
|
|
241
|
-
postImport,
|
|
242
|
-
runtimeUrl,
|
|
243
|
-
assistantId,
|
|
244
|
-
(nextToken) => {
|
|
245
|
-
token = nextToken;
|
|
246
|
-
},
|
|
247
|
-
);
|
|
248
|
-
if (!response) {
|
|
249
|
-
return false;
|
|
250
|
-
}
|
|
251
|
-
return await finishRestoreResponse(response);
|
|
252
|
-
} finally {
|
|
253
|
-
await staged.cleanup();
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const bundleData = new Uint8Array(readFileSync(backupPath));
|
|
258
|
-
const postImport = () =>
|
|
259
|
-
loopbackSafeFetch(`${runtimeUrl}/v1/migrations/import`, {
|
|
260
|
-
method: "POST",
|
|
261
|
-
headers: {
|
|
262
|
-
Authorization: `Bearer ${token}`,
|
|
263
|
-
"Content-Type": "application/octet-stream",
|
|
264
|
-
},
|
|
265
|
-
body: bundleData,
|
|
266
|
-
signal: AbortSignal.timeout(120_000),
|
|
267
|
-
});
|
|
268
|
-
const response = await postRestoreWithRetries(
|
|
269
|
-
postImport,
|
|
270
|
-
runtimeUrl,
|
|
271
|
-
assistantId,
|
|
272
|
-
(nextToken) => {
|
|
273
|
-
token = nextToken;
|
|
274
|
-
},
|
|
56
|
+
const entry = findAssistantByName(assistantId);
|
|
57
|
+
if (!entry)
|
|
58
|
+
throw new Error(`No registered assistant found for '${assistantId}'.`);
|
|
59
|
+
await restoreRootBackup(entry, backupPath, options);
|
|
60
|
+
return true;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.warn(
|
|
63
|
+
`Restore failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
275
64
|
);
|
|
276
|
-
if (!response) {
|
|
277
|
-
return false;
|
|
278
|
-
}
|
|
279
|
-
return await finishRestoreResponse(response);
|
|
280
|
-
} catch (err) {
|
|
281
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
282
|
-
console.warn(`Warning: restore failed: ${msg}`);
|
|
283
65
|
return false;
|
|
284
66
|
}
|
|
285
67
|
}
|
|
286
68
|
|
|
287
|
-
/** Filename kinds this CLI writes: `<assistantId>-<kind>-<timestamp>.
|
|
288
|
-
export const CLI_BACKUP_KINDS = [
|
|
69
|
+
/** Filename kinds this CLI writes: `<assistantId>-<kind>-<timestamp>.tar.gz`. */
|
|
70
|
+
export const CLI_BACKUP_KINDS = [
|
|
71
|
+
"pre-upgrade",
|
|
72
|
+
"pre-rollback",
|
|
73
|
+
"pre-teleport",
|
|
74
|
+
] as const;
|
|
289
75
|
export type CliBackupKind = (typeof CLI_BACKUP_KINDS)[number];
|
|
290
76
|
|
|
291
77
|
/** `new Date().toISOString().replace(/[:.]/g, "-")`, as used in every CLI backup filename. */
|
|
@@ -307,12 +93,12 @@ export function assistantBackupFilenamePattern(
|
|
|
307
93
|
kinds: readonly CliBackupKind[] = CLI_BACKUP_KINDS,
|
|
308
94
|
): RegExp {
|
|
309
95
|
return new RegExp(
|
|
310
|
-
`^${escapeRegExp(assistantId)}-(?:${kinds.join("|")})-${BACKUP_TIMESTAMP_PATTERN}\\.
|
|
96
|
+
`^${escapeRegExp(assistantId)}-(?:${kinds.join("|")})-${BACKUP_TIMESTAMP_PATTERN}\\.tar\\.gz$`,
|
|
311
97
|
);
|
|
312
98
|
}
|
|
313
99
|
|
|
314
100
|
/**
|
|
315
|
-
* Modification times of the `.
|
|
101
|
+
* Modification times of the `.tar.gz` backups this CLI has written for
|
|
316
102
|
* `assistantId` (every kind in `CLI_BACKUP_KINDS`), as ISO timestamps.
|
|
317
103
|
* Missing directory yields `[]`.
|
|
318
104
|
*/
|
package/src/lib/cloud-client.ts
CHANGED
|
@@ -146,13 +146,10 @@ const ORG_ID_CACHE_TTL_MS = 60_000; // 60 seconds
|
|
|
146
146
|
* orgs in another tab). Clearing the entry forces the next `authHeaders`
|
|
147
147
|
* call to refetch the org ID from the cloud.
|
|
148
148
|
*
|
|
149
|
-
* Exported so
|
|
149
|
+
* Exported so cloud API consumers can implement the
|
|
150
150
|
* same retry pattern without needing direct access to the cache map.
|
|
151
151
|
*/
|
|
152
|
-
export function invalidateOrgIdCache(
|
|
153
|
-
token: string,
|
|
154
|
-
cloudUrl?: string,
|
|
155
|
-
): void {
|
|
152
|
+
export function invalidateOrgIdCache(token: string, cloudUrl?: string): void {
|
|
156
153
|
orgIdCache.delete(`${token}::${cloudUrl ?? ""}`);
|
|
157
154
|
}
|
|
158
155
|
|
|
@@ -658,9 +655,9 @@ export async function hatchAssistant(
|
|
|
658
655
|
}
|
|
659
656
|
|
|
660
657
|
if (response.status === 401 || response.status === 403) {
|
|
661
|
-
const detail = CloudErrorDetailSchema
|
|
662
|
-
.catch({})
|
|
663
|
-
|
|
658
|
+
const detail = CloudErrorDetailSchema.catch({}).parse(
|
|
659
|
+
await response.json().catch(() => ({})),
|
|
660
|
+
);
|
|
664
661
|
throw new Error(
|
|
665
662
|
detail.detail ??
|
|
666
663
|
"Invalid or expired token. Run `cadet login` to re-authenticate.",
|
|
@@ -671,9 +668,9 @@ export async function hatchAssistant(
|
|
|
671
668
|
throw new Error("Insufficient balance to hatch a new assistant.");
|
|
672
669
|
}
|
|
673
670
|
|
|
674
|
-
const errorBody = CloudErrorDetailSchema
|
|
675
|
-
.catch({})
|
|
676
|
-
|
|
671
|
+
const errorBody = CloudErrorDetailSchema.catch({}).parse(
|
|
672
|
+
await response.json().catch(() => ({})),
|
|
673
|
+
);
|
|
677
674
|
throw new Error(
|
|
678
675
|
errorBody.detail ??
|
|
679
676
|
`Platform API error: ${response.status} ${response.statusText}`,
|
|
@@ -987,7 +984,7 @@ export async function platformImportPreflightFromGcs(
|
|
|
987
984
|
{
|
|
988
985
|
method: "POST",
|
|
989
986
|
headers: await authHeaders(token, cloudUrl),
|
|
990
|
-
signal: AbortSignal.timeout(
|
|
987
|
+
signal: AbortSignal.timeout(30 * 60_000),
|
|
991
988
|
body: JSON.stringify({ bundle_key: bundleKey }),
|
|
992
989
|
},
|
|
993
990
|
);
|
|
@@ -1010,7 +1007,7 @@ export async function platformImportBundleFromGcs(
|
|
|
1010
1007
|
method: "POST",
|
|
1011
1008
|
headers: await authHeaders(token, cloudUrl),
|
|
1012
1009
|
body: JSON.stringify({ bundle_key: bundleKey }),
|
|
1013
|
-
signal: AbortSignal.timeout(60_000),
|
|
1010
|
+
signal: AbortSignal.timeout(30 * 60_000),
|
|
1014
1011
|
},
|
|
1015
1012
|
);
|
|
1016
1013
|
|
package/src/lib/cloud-login.ts
CHANGED
|
@@ -28,7 +28,8 @@ import {
|
|
|
28
28
|
readCloudToken,
|
|
29
29
|
saveCloudToken,
|
|
30
30
|
} from "./cloud-client.js";
|
|
31
|
-
import { fetchAssistantIngressUrl
|
|
31
|
+
import { fetchAssistantIngressUrl } from "./upgrade-lifecycle.js";
|
|
32
|
+
import { fetchCurrentVersion } from "./http-client.js";
|
|
32
33
|
import {
|
|
33
34
|
CALLBACK_PATH,
|
|
34
35
|
buildAuthorizeUrl,
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { CADET_GITHUB_REPOSITORY } from "./github-repository.js";
|
|
1
4
|
import { getCloudUrl } from "./cloud-client.js";
|
|
2
5
|
import { dockerhubImages } from "./docker.js";
|
|
3
6
|
import type { ServiceName } from "./docker.js";
|
|
@@ -22,12 +25,40 @@ function releasesOptsFor(channel: ReleaseChannel): { channel?: "preview" } {
|
|
|
22
25
|
return channel === "preview" ? { channel: "preview" } : {};
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
version: string
|
|
27
|
-
is_stable
|
|
28
|
-
assistant_image_ref
|
|
29
|
-
gateway_image_ref
|
|
30
|
-
credential_executor_image_ref
|
|
28
|
+
const ReleaseListItemSchema = z.object({
|
|
29
|
+
version: z.string().min(1),
|
|
30
|
+
is_stable: z.boolean().optional(),
|
|
31
|
+
assistant_image_ref: z.string().nullable().optional(),
|
|
32
|
+
gateway_image_ref: z.string().nullable().optional(),
|
|
33
|
+
credential_executor_image_ref: z.string().nullable().optional(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export type ReleaseListItem = z.infer<typeof ReleaseListItemSchema>;
|
|
37
|
+
|
|
38
|
+
const STABLE_VERSION = /^(?:v)?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
39
|
+
const GitHubReleaseSchema = z.object({
|
|
40
|
+
tag_name: z.string().regex(STABLE_VERSION),
|
|
41
|
+
draft: z.literal(false),
|
|
42
|
+
prerelease: z.literal(false),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
async function fetchLatestGitHubStableVersion(): Promise<string | null> {
|
|
46
|
+
try {
|
|
47
|
+
const response = await loopbackSafeFetch(
|
|
48
|
+
`https://api.github.com/repos/${CADET_GITHUB_REPOSITORY}/releases/latest`,
|
|
49
|
+
{
|
|
50
|
+
headers: { Accept: "application/vnd.github+json" },
|
|
51
|
+
signal: AbortSignal.timeout(10_000),
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const parsed = GitHubReleaseSchema.safeParse(await response.json());
|
|
58
|
+
return parsed.success ? parsed.data.tag_name.replace(/^v/, "") : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
31
62
|
}
|
|
32
63
|
|
|
33
64
|
/**
|
|
@@ -56,15 +87,21 @@ export async function fetchReleases(opts?: {
|
|
|
56
87
|
`${cloudUrl}/v1/releases/?${filter}&limit=${RELEASES_FETCH_LIMIT}`,
|
|
57
88
|
{ signal: AbortSignal.timeout(10_000) },
|
|
58
89
|
);
|
|
59
|
-
if (!response.ok)
|
|
60
|
-
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const parsed = z
|
|
94
|
+
.array(ReleaseListItemSchema)
|
|
95
|
+
.safeParse(await response.json());
|
|
96
|
+
return parsed.success ? parsed.data : null;
|
|
61
97
|
} catch {
|
|
62
98
|
return null;
|
|
63
99
|
}
|
|
64
100
|
}
|
|
65
101
|
|
|
66
102
|
/**
|
|
67
|
-
* Fetch the latest release version
|
|
103
|
+
* Fetch the latest release version from Cadet Cloud, falling back to GitHub
|
|
104
|
+
* for stable releases when the cloud has no usable result.
|
|
68
105
|
* Returns the version string (e.g. "0.7.0") or null if unavailable.
|
|
69
106
|
* The releases endpoint returns entries ordered newest-first, so the first
|
|
70
107
|
* entry is the latest for the requested channel.
|
|
@@ -73,7 +110,14 @@ export async function fetchLatestVersion(
|
|
|
73
110
|
channel: ReleaseChannel = "stable",
|
|
74
111
|
): Promise<string | null> {
|
|
75
112
|
const releases = await fetchReleases(releasesOptsFor(channel));
|
|
76
|
-
|
|
113
|
+
if (channel === "preview") {
|
|
114
|
+
return releases?.[0]?.version ?? null;
|
|
115
|
+
}
|
|
116
|
+
const latest = releases?.find(
|
|
117
|
+
(release) =>
|
|
118
|
+
release.is_stable !== false && STABLE_VERSION.test(release.version),
|
|
119
|
+
);
|
|
120
|
+
return latest?.version ?? fetchLatestGitHubStableVersion();
|
|
77
121
|
}
|
|
78
122
|
|
|
79
123
|
/**
|
package/src/lib/cloud-values.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared interactive confirmation for destructive CLI commands (retire,
|
|
3
|
-
* …). Per cli/AGENTS.md, a command that removes assistant state must print the
|
|
2
|
+
* Shared interactive confirmation for destructive CLI commands (retire,
|
|
3
|
+
* devices revoke, …). Per cli/AGENTS.md, a command that removes assistant state must print the
|
|
4
4
|
* resolved identity and require confirmation, with a `--yes` bypass for
|
|
5
5
|
* automation.
|
|
6
6
|
*/
|
package/src/lib/docker.ts
CHANGED
|
@@ -296,6 +296,8 @@ export interface HatchDockerParams {
|
|
|
296
296
|
/** Extra env vars forwarded into the assistant container. */
|
|
297
297
|
flagEnvVars?: Record<string, string>;
|
|
298
298
|
setupProviderCredentials?: boolean;
|
|
299
|
+
/** Whole-installation state installed into stopped containers before startup. */
|
|
300
|
+
initialArchive?: string;
|
|
299
301
|
/**
|
|
300
302
|
* Path to a local source tree to build images from before hatching. When
|
|
301
303
|
* provided, this path is used directly as the repo root and no file
|
|
@@ -812,6 +814,7 @@ export const SERVICE_START_ORDER: ServiceName[] = [
|
|
|
812
814
|
/** Start all three containers in dependency order. */
|
|
813
815
|
export async function startContainers(
|
|
814
816
|
opts: {
|
|
817
|
+
createOnly?: boolean;
|
|
815
818
|
signingKey?: string;
|
|
816
819
|
bootstrapSecret?: string;
|
|
817
820
|
cesServiceToken?: string;
|
|
@@ -832,8 +835,13 @@ export async function startContainers(
|
|
|
832
835
|
avatarDevicePath: resolveAvatarDevicePath(),
|
|
833
836
|
});
|
|
834
837
|
for (const service of SERVICE_START_ORDER) {
|
|
835
|
-
|
|
836
|
-
|
|
838
|
+
const args = runArgs[service]();
|
|
839
|
+
if (opts.createOnly) {
|
|
840
|
+
args[0] = "create";
|
|
841
|
+
args.splice(args.indexOf("-d"), 1);
|
|
842
|
+
}
|
|
843
|
+
log(`${opts.createOnly ? "Creating" : "Starting"} ${service} container...`);
|
|
844
|
+
await exec("docker", args);
|
|
837
845
|
}
|
|
838
846
|
}
|
|
839
847
|
|
|
@@ -849,14 +857,23 @@ export async function stopContainers(
|
|
|
849
857
|
/** Stop containers without removing them (preserves state for `docker start`). */
|
|
850
858
|
export async function sleepContainers(
|
|
851
859
|
res: ReturnType<typeof dockerResourceNames>,
|
|
860
|
+
options: { timeoutMs?: number } = {},
|
|
852
861
|
): Promise<void> {
|
|
853
862
|
for (const container of [
|
|
854
|
-
res.cesContainer,
|
|
855
|
-
res.gatewayContainer,
|
|
856
863
|
res.assistantContainer,
|
|
864
|
+
res.gatewayContainer,
|
|
865
|
+
res.cesContainer,
|
|
857
866
|
]) {
|
|
858
867
|
try {
|
|
859
|
-
await
|
|
868
|
+
const status = await execOutput("docker", ["inspect", "--format", "{{.State.Status}}", container]);
|
|
869
|
+
if (status === "exited" || status === "created") continue;
|
|
870
|
+
if (status !== "running") throw new Error(`${container} is ${status}; cannot stop gracefully.`);
|
|
871
|
+
await exec("docker", ["kill", "--signal=SIGTERM", container]);
|
|
872
|
+
const deadline = Date.now() + (options.timeoutMs ?? 60_000);
|
|
873
|
+
while (await execOutput("docker", ["inspect", "--format", "{{.State.Running}}", container]) === "true") {
|
|
874
|
+
if (Date.now() >= deadline) throw new Error(`${container} did not stop gracefully.`);
|
|
875
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
876
|
+
}
|
|
860
877
|
} catch (err) {
|
|
861
878
|
const msg =
|
|
862
879
|
err instanceof Error ? err.message.toLowerCase() : String(err);
|
|
@@ -1630,7 +1647,9 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
|
|
|
1630
1647
|
}
|
|
1631
1648
|
|
|
1632
1649
|
const cesServiceToken = randomBytes(32).toString("hex");
|
|
1633
|
-
const signingKey =
|
|
1650
|
+
const signingKey = params.initialArchive
|
|
1651
|
+
? undefined
|
|
1652
|
+
: randomBytes(32).toString("hex");
|
|
1634
1653
|
|
|
1635
1654
|
// When launched by a remote hatch startup script, the env var
|
|
1636
1655
|
// GUARDIAN_BOOTSTRAP_SECRET is already set with the laptop's secret.
|
|
@@ -1669,8 +1688,26 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
|
|
|
1669
1688
|
...flagEnvVars,
|
|
1670
1689
|
CADET_DEVICE_ID: hostDeviceId,
|
|
1671
1690
|
};
|
|
1691
|
+
const runtimeUrl = `http://localhost:${gatewayPort}`;
|
|
1692
|
+
const dockerEntry: AssistantEntry = {
|
|
1693
|
+
assistantId: instanceName,
|
|
1694
|
+
runtimeUrl,
|
|
1695
|
+
cloud: "docker",
|
|
1696
|
+
hatchedAt: new Date().toISOString(),
|
|
1697
|
+
guardianBootstrapSecret: ownSecret,
|
|
1698
|
+
containerInfo: {
|
|
1699
|
+
assistantImage: imageTags.assistant,
|
|
1700
|
+
gatewayImage: imageTags.gateway,
|
|
1701
|
+
cesImage: imageTags["credential-executor"],
|
|
1702
|
+
assistantPort,
|
|
1703
|
+
},
|
|
1704
|
+
};
|
|
1705
|
+
if (params.initialArchive) {
|
|
1706
|
+
saveAssistantEntry(dockerEntry);
|
|
1707
|
+
}
|
|
1672
1708
|
await startContainers(
|
|
1673
1709
|
{
|
|
1710
|
+
createOnly: !!params.initialArchive,
|
|
1674
1711
|
signingKey,
|
|
1675
1712
|
bootstrapSecret,
|
|
1676
1713
|
cesServiceToken,
|
|
@@ -1688,29 +1725,29 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
|
|
|
1688
1725
|
);
|
|
1689
1726
|
const containersUpAt = Date.now();
|
|
1690
1727
|
|
|
1691
|
-
const runtimeUrl = `http://localhost:${gatewayPort}`;
|
|
1692
|
-
const dockerEntry: AssistantEntry = {
|
|
1693
|
-
assistantId: instanceName,
|
|
1694
|
-
runtimeUrl,
|
|
1695
|
-
cloud: "docker",
|
|
1696
|
-
hatchedAt: new Date().toISOString(),
|
|
1697
|
-
guardianBootstrapSecret: ownSecret,
|
|
1698
|
-
containerInfo: {
|
|
1699
|
-
assistantImage: imageTags.assistant,
|
|
1700
|
-
gatewayImage: imageTags.gateway,
|
|
1701
|
-
cesImage: imageTags["credential-executor"],
|
|
1702
|
-
assistantPort,
|
|
1703
|
-
},
|
|
1704
|
-
};
|
|
1705
1728
|
emitProgress(5, 6, "Saving configuration...");
|
|
1706
1729
|
saveAssistantEntry(dockerEntry);
|
|
1707
1730
|
setActiveAssistant(instanceName);
|
|
1708
1731
|
|
|
1732
|
+
if (params.initialArchive) {
|
|
1733
|
+
const { initializeRootArchive, DockerRootStorage } =
|
|
1734
|
+
await import("./root-backup.js");
|
|
1735
|
+
await initializeRootArchive(dockerEntry, params.initialArchive);
|
|
1736
|
+
const storage = new DockerRootStorage(dockerEntry);
|
|
1737
|
+
storage.initialRunning = new Set(Object.values(storage.containers));
|
|
1738
|
+
await storage.resume();
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1709
1741
|
emitProgress(6, 6, "Waiting for services...");
|
|
1710
1742
|
// Hatch always detaches; only watch mode keeps the log stream attached.
|
|
1711
1743
|
const waitDetached = !watch;
|
|
1712
|
-
const { ready, guardianAccessToken, notReadyReason } =
|
|
1713
|
-
|
|
1744
|
+
const { ready, guardianAccessToken, notReadyReason } = params.initialArchive
|
|
1745
|
+
? {
|
|
1746
|
+
ready: true,
|
|
1747
|
+
guardianAccessToken: undefined,
|
|
1748
|
+
notReadyReason: undefined,
|
|
1749
|
+
}
|
|
1750
|
+
: await waitForGatewayAndLease({
|
|
1714
1751
|
bootstrapSecret: ownSecret,
|
|
1715
1752
|
containerName: res.assistantContainer,
|
|
1716
1753
|
detached: waitDetached,
|