@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.
Files changed (86) hide show
  1. package/README.md +13 -0
  2. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  3. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  4. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  5. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  6. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  7. package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  8. package/package.json +3 -2
  9. package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
  10. package/src/__tests__/backup-ops-matching.test.ts +15 -13
  11. package/src/__tests__/backup-ops.test.ts +105 -0
  12. package/src/__tests__/backup.test.ts +114 -673
  13. package/src/__tests__/cloud-releases.test.ts +82 -0
  14. package/src/__tests__/commands-mock.ts +101 -0
  15. package/src/__tests__/debug.test.ts +17 -91
  16. package/src/__tests__/devices.test.ts +2 -2
  17. package/src/__tests__/gateway-token.test.ts +267 -0
  18. package/src/__tests__/host-teleport-docker.test.ts +371 -0
  19. package/src/__tests__/host-teleport.test.ts +405 -0
  20. package/src/__tests__/nginx-ingress.test.ts +182 -23
  21. package/src/__tests__/orphan-detection.test.ts +1 -1
  22. package/src/__tests__/paired-lifecycle.test.ts +2 -2
  23. package/src/__tests__/ps-cloud-status.test.ts +6 -6
  24. package/src/__tests__/root-backup-docker.test.ts +294 -0
  25. package/src/__tests__/root-backup.test.ts +527 -0
  26. package/src/__tests__/root-restore-command.test.ts +147 -0
  27. package/src/__tests__/root-signing-key.test.ts +50 -0
  28. package/src/__tests__/setup.test.ts +3 -3
  29. package/src/__tests__/sleep.test.ts +11 -0
  30. package/src/__tests__/teleport.test.ts +68 -2996
  31. package/src/__tests__/tunnel.test.ts +1 -1
  32. package/src/__tests__/upgrade-local.test.ts +21 -11
  33. package/src/__tests__/upgrade-replay-env.test.ts +2 -2
  34. package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
  35. package/src/__tests__/windows-lifecycle.test.ts +25 -0
  36. package/src/commands/backup.ts +44 -399
  37. package/src/commands/client.ts +3 -3
  38. package/src/commands/completions.ts +0 -2
  39. package/src/commands/debug.ts +75 -131
  40. package/src/commands/devices.ts +51 -17
  41. package/src/commands/gateway/token.ts +77 -23
  42. package/src/commands/pair.ts +2 -2
  43. package/src/commands/restore.ts +77 -444
  44. package/src/commands/retire.ts +2 -3
  45. package/src/commands/sleep.ts +15 -2
  46. package/src/commands/teleport.ts +47 -1559
  47. package/src/commands/upgrade.ts +78 -156
  48. package/src/commands/wake.ts +1 -1
  49. package/src/index.ts +0 -4
  50. package/src/lib/__tests__/http-client-version.test.ts +29 -0
  51. package/src/lib/__tests__/runtime-url.test.ts +3 -75
  52. package/src/lib/__tests__/web-dist.test.ts +27 -1
  53. package/src/lib/assistant-client.ts +1 -1
  54. package/src/lib/assistant-config.ts +0 -18
  55. package/src/lib/backup-ops.ts +37 -251
  56. package/src/lib/cloud-client.ts +10 -13
  57. package/src/lib/cloud-login.ts +2 -1
  58. package/src/lib/cloud-releases.ts +54 -10
  59. package/src/lib/cloud-values.ts +1 -1
  60. package/src/lib/confirm-action.ts +2 -2
  61. package/src/lib/docker.ts +59 -22
  62. package/src/lib/github-repository.ts +2 -0
  63. package/src/lib/guardian-token.ts +1 -23
  64. package/src/lib/hatch-local.ts +17 -1
  65. package/src/lib/host-teleport.ts +506 -0
  66. package/src/lib/http-client.ts +25 -0
  67. package/src/lib/live-voice/tunnel.test.ts +1 -1
  68. package/src/lib/live-voice/tunnel.ts +1 -1
  69. package/src/lib/local.ts +4 -2
  70. package/src/lib/nginx-ingress.ts +134 -18
  71. package/src/lib/process.ts +13 -0
  72. package/src/lib/root-backup.ts +917 -0
  73. package/src/lib/root-signing-key.ts +31 -0
  74. package/src/lib/runtime-url.ts +0 -29
  75. package/src/lib/teleport-backup.ts +3 -16
  76. package/src/lib/upgrade-lifecycle.ts +63 -141
  77. package/src/lib/web-dist.ts +23 -1
  78. package/src/__tests__/bundle-staging.test.ts +0 -268
  79. package/src/__tests__/unpair.test.ts +0 -160
  80. package/src/commands/rollback.ts +0 -479
  81. package/src/commands/unpair.ts +0 -122
  82. package/src/lib/__tests__/job-polling.test.ts +0 -278
  83. package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
  84. package/src/lib/bundle-staging.ts +0 -220
  85. package/src/lib/job-polling.ts +0 -195
  86. package/src/lib/local-runtime-client.ts +0 -374
@@ -1,21 +1,8 @@
1
- import {
2
- existsSync,
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
- importStagedBundle,
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
- runtimeUrl: string,
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
- let accessToken = await getGuardianAccessToken(runtimeUrl, assistantId);
68
- if (!accessToken) {
69
- console.warn(
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
- const outputPath = join(
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}-${isoTimestamp}.vbundle`,
36
+ `${prefix}-${new Date().toISOString().replace(/[:.]/g, "-")}.tar.gz`,
130
37
  );
131
-
132
- mkdirSync(dirname(outputPath), { recursive: true });
133
- writeFileSync(outputPath, data);
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
- `Warning: restore failed — ${result.message ?? result.reason ?? "unknown reason"}`,
42
+ `Backup failed: ${error instanceof Error ? error.message : String(error)}`,
203
43
  );
204
- return false;
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
- runtimeUrl: string,
50
+ _runtimeUrl: string,
216
51
  assistantId: string,
217
52
  backupPath: string,
218
- staging?: RestoreStagingTarget | null,
53
+ options: { resume?: boolean } = {},
219
54
  ): Promise<boolean> {
220
55
  try {
221
- if (!existsSync(backupPath)) {
222
- console.warn(`Warning: backup file not found: ${backupPath}`);
223
- return false;
224
- }
225
-
226
- const initialToken = await getGuardianAccessToken(runtimeUrl, assistantId);
227
- if (!initialToken) {
228
- console.warn(
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>.vbundle`. */
288
- export const CLI_BACKUP_KINDS = ["pre-upgrade", "pre-teleport"] as const;
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}\\.vbundle$`,
96
+ `^${escapeRegExp(assistantId)}-(?:${kinds.join("|")})-${BACKUP_TIMESTAMP_PATTERN}\\.tar\\.gz$`,
311
97
  );
312
98
  }
313
99
 
314
100
  /**
315
- * Modification times of the `.vbundle` backups this CLI has written for
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
  */
@@ -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 other modules (e.g. local-runtime-client) can implement the
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
- .parse(await response.json().catch(() => ({})));
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
- .parse(await response.json().catch(() => ({})));
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(120_000),
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
 
@@ -28,7 +28,8 @@ import {
28
28
  readCloudToken,
29
29
  saveCloudToken,
30
30
  } from "./cloud-client.js";
31
- import { fetchAssistantIngressUrl, fetchCurrentVersion } from "./upgrade-lifecycle.js";
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
- export interface ReleaseListItem {
26
- version: string;
27
- is_stable?: boolean;
28
- assistant_image_ref?: string | null;
29
- gateway_image_ref?: string | null;
30
- credential_executor_image_ref?: string | null;
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) return null;
60
- return (await response.json()) as ReleaseListItem[];
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 for a channel from the Cadet Cloud API.
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
- return releases?.[0]?.version ?? null;
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
  /**
@@ -1,3 +1,3 @@
1
1
  export function isCloudHome(cloud: unknown): boolean {
2
- return cloud === "cloud" || cloud === "cadet";
2
+ return cloud === "cloud";
3
3
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Shared interactive confirmation for destructive CLI commands (retire, unpair,
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
- log(`🚀 Starting ${service} container...`);
836
- await exec("docker", runArgs[service]());
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 exec("docker", ["stop", container]);
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 = randomBytes(32).toString("hex");
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
- await waitForGatewayAndLease({
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,
@@ -0,0 +1,2 @@
1
+ /** Source repository for published Cadet releases. */
2
+ export const CADET_GITHUB_REPOSITORY = "vargasjr-dev/mycadet-temp";