@mycadet/cli 0.12.11 → 0.13.0-dev.202610171617.9ea363e

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 (94) 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/channels.ts +0 -1
  4. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  5. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  6. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
  7. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  8. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  9. package/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
  10. package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  11. package/package.json +8 -3
  12. package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
  13. package/src/__tests__/backup-ops-matching.test.ts +15 -13
  14. package/src/__tests__/backup-ops.test.ts +105 -0
  15. package/src/__tests__/backup.test.ts +114 -673
  16. package/src/__tests__/cloud-releases.test.ts +82 -0
  17. package/src/__tests__/commands-mock.ts +101 -0
  18. package/src/__tests__/debug.test.ts +17 -91
  19. package/src/__tests__/devices.test.ts +2 -2
  20. package/src/__tests__/gateway-token.test.ts +267 -0
  21. package/src/__tests__/host-teleport-docker.test.ts +371 -0
  22. package/src/__tests__/host-teleport.test.ts +405 -0
  23. package/src/__tests__/nginx-ingress.test.ts +536 -23
  24. package/src/__tests__/orphan-detection.test.ts +1 -1
  25. package/src/__tests__/paired-lifecycle.test.ts +2 -2
  26. package/src/__tests__/ps-cloud-status.test.ts +6 -6
  27. package/src/__tests__/root-backup-docker.test.ts +294 -0
  28. package/src/__tests__/root-backup.test.ts +527 -0
  29. package/src/__tests__/root-restore-command.test.ts +147 -0
  30. package/src/__tests__/root-signing-key.test.ts +50 -0
  31. package/src/__tests__/setup.test.ts +3 -3
  32. package/src/__tests__/shell-mode.test.ts +83 -0
  33. package/src/__tests__/sleep.test.ts +11 -0
  34. package/src/__tests__/teleport.test.ts +68 -2996
  35. package/src/__tests__/tunnel.test.ts +10 -2
  36. package/src/__tests__/upgrade-local.test.ts +21 -11
  37. package/src/__tests__/upgrade-replay-env.test.ts +2 -2
  38. package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
  39. package/src/__tests__/wake.test.ts +4 -1
  40. package/src/__tests__/windows-lifecycle.test.ts +25 -0
  41. package/src/commands/backup.ts +44 -399
  42. package/src/commands/client.ts +3 -3
  43. package/src/commands/completions.ts +0 -2
  44. package/src/commands/debug.ts +75 -131
  45. package/src/commands/devices.ts +51 -17
  46. package/src/commands/gateway/token.ts +77 -23
  47. package/src/commands/pair.ts +2 -2
  48. package/src/commands/restore.ts +77 -444
  49. package/src/commands/retire.ts +2 -3
  50. package/src/commands/sleep.ts +15 -2
  51. package/src/commands/teleport.ts +47 -1559
  52. package/src/commands/tunnel.ts +48 -7
  53. package/src/commands/upgrade.ts +78 -156
  54. package/src/commands/wake.ts +1 -1
  55. package/src/index.ts +0 -4
  56. package/src/lib/__tests__/http-client-version.test.ts +29 -0
  57. package/src/lib/__tests__/runtime-url.test.ts +3 -75
  58. package/src/lib/__tests__/web-dist.test.ts +27 -1
  59. package/src/lib/assistant-client.ts +1 -1
  60. package/src/lib/assistant-config.ts +23 -18
  61. package/src/lib/backup-ops.ts +37 -251
  62. package/src/lib/cloud-client.ts +10 -13
  63. package/src/lib/cloud-login.ts +2 -1
  64. package/src/lib/cloud-releases.ts +54 -10
  65. package/src/lib/cloud-values.ts +1 -1
  66. package/src/lib/confirm-action.ts +2 -2
  67. package/src/lib/docker.ts +59 -22
  68. package/src/lib/github-repository.ts +2 -0
  69. package/src/lib/guardian-token.ts +1 -23
  70. package/src/lib/hatch-local.ts +17 -1
  71. package/src/lib/host-teleport.ts +506 -0
  72. package/src/lib/http-client.ts +25 -0
  73. package/src/lib/ingress-config.ts +31 -0
  74. package/src/lib/live-voice/tunnel.test.ts +1 -1
  75. package/src/lib/live-voice/tunnel.ts +1 -1
  76. package/src/lib/local.ts +4 -2
  77. package/src/lib/nginx-ingress.ts +454 -79
  78. package/src/lib/process.ts +13 -0
  79. package/src/lib/root-backup.ts +917 -0
  80. package/src/lib/root-signing-key.ts +31 -0
  81. package/src/lib/runtime-url.ts +0 -29
  82. package/src/lib/teleport-backup.ts +3 -16
  83. package/src/lib/tunnel.ts +1 -0
  84. package/src/lib/upgrade-lifecycle.ts +63 -141
  85. package/src/lib/web-dist.ts +79 -1
  86. package/src/__tests__/bundle-staging.test.ts +0 -268
  87. package/src/__tests__/unpair.test.ts +0 -160
  88. package/src/commands/rollback.ts +0 -479
  89. package/src/commands/unpair.ts +0 -122
  90. package/src/lib/__tests__/job-polling.test.ts +0 -278
  91. package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
  92. package/src/lib/bundle-staging.ts +0 -220
  93. package/src/lib/job-polling.ts +0 -195
  94. package/src/lib/local-runtime-client.ts +0 -374
@@ -1,10 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import type { AssistantEntry } from "../assistant-config.js";
4
- import {
5
- resolveRuntimeMigrationUrl,
6
- resolveRuntimeUrl,
7
- } from "../runtime-url.js";
4
+ import { resolveRuntimeUrl } from "../runtime-url.js";
8
5
 
9
6
  function makeEntry(
10
7
  overrides: Partial<AssistantEntry> & {
@@ -20,75 +17,6 @@ function makeEntry(
20
17
  };
21
18
  }
22
19
 
23
- describe("resolveRuntimeMigrationUrl", () => {
24
- test("local cloud uses gateway-loopback /v1/migrations/<subpath>", () => {
25
- const entry = makeEntry({
26
- cloud: "local",
27
- runtimeUrl: "http://localhost:7821",
28
- assistantId: "ast-local-1",
29
- });
30
- expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
31
- "http://localhost:7821/v1/migrations/export-to-gcs",
32
- );
33
- expect(resolveRuntimeMigrationUrl(entry, "import-from-gcs")).toBe(
34
- "http://localhost:7821/v1/migrations/import-from-gcs",
35
- );
36
- expect(resolveRuntimeMigrationUrl(entry, "jobs/job-abc")).toBe(
37
- "http://localhost:7821/v1/migrations/jobs/job-abc",
38
- );
39
- });
40
-
41
- test("docker cloud uses gateway-loopback /v1/migrations/<subpath>", () => {
42
- const entry = makeEntry({
43
- cloud: "docker",
44
- runtimeUrl: "http://localhost:7831",
45
- assistantId: "ast-docker-1",
46
- });
47
- expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
48
- "http://localhost:7831/v1/migrations/export-to-gcs",
49
- );
50
- });
51
-
52
- test("cadet (cloud-managed) cloud uses wildcard-proxy /v1/assistants/<id>/migrations/<subpath>", () => {
53
- const entry = makeEntry({
54
- cloud: "cadet",
55
- runtimeUrl: "https://cloud.mycadet.ai",
56
- assistantId: "11111111-2222-3333-4444-555555555555",
57
- });
58
- expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
59
- "https://cloud.mycadet.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/export-to-gcs",
60
- );
61
- expect(resolveRuntimeMigrationUrl(entry, "import-from-gcs")).toBe(
62
- "https://cloud.mycadet.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/import-from-gcs",
63
- );
64
- expect(resolveRuntimeMigrationUrl(entry, "jobs/job-xyz")).toBe(
65
- "https://cloud.mycadet.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/jobs/job-xyz",
66
- );
67
- });
68
-
69
- test("dev platform URL still routes through the wildcard prefix", () => {
70
- const entry = makeEntry({
71
- cloud: "cadet",
72
- runtimeUrl: "https://dev-cloud.mycadet.ai",
73
- assistantId: "ast-dev-1",
74
- });
75
- expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
76
- "https://dev-cloud.mycadet.ai/v1/assistants/ast-dev-1/migrations/export-to-gcs",
77
- );
78
- });
79
-
80
- test("a non-cadet, non-local cloud (e.g. gcp) uses the local-shape URL", () => {
81
- const entry = makeEntry({
82
- cloud: "gcp",
83
- runtimeUrl: "http://10.0.0.5:7821",
84
- assistantId: "ast-gcp-1",
85
- });
86
- expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
87
- "http://10.0.0.5:7821/v1/migrations/export-to-gcs",
88
- );
89
- });
90
- });
91
-
92
20
  describe("resolveRuntimeUrl", () => {
93
21
  test("local cloud uses gateway-loopback /v1/<subpath>", () => {
94
22
  const entry = makeEntry({
@@ -112,9 +40,9 @@ describe("resolveRuntimeUrl", () => {
112
40
  );
113
41
  });
114
42
 
115
- test("cadet cloud uses wildcard-proxy /v1/assistants/<id>/<subpath>", () => {
43
+ test("cloud home uses wildcard-proxy /v1/assistants/<id>/<subpath>", () => {
116
44
  const entry = makeEntry({
117
- cloud: "cadet",
45
+ cloud: "cloud",
118
46
  runtimeUrl: "https://cloud.mycadet.ai",
119
47
  assistantId: "11111111-2222-3333-4444-555555555555",
120
48
  });
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
- import { findWebDistDir } from "../web-dist.js";
6
+ import { findWebDistDir, readWebReleaseVersion } from "../web-dist.js";
7
7
 
8
8
  const tempDirs: string[] = [];
9
9
 
@@ -84,3 +84,29 @@ test("preserves installed package and source checkout resolution", () => {
84
84
  }),
85
85
  ).toBe(sourceDist);
86
86
  });
87
+
88
+ test("reads the built UI release, including a normalized version prefix", () => {
89
+ const root = makeTempDir();
90
+ writeFileSync(
91
+ join(root, "release.json"),
92
+ JSON.stringify({ version: "v1.2.3-dev.5" }),
93
+ );
94
+ expect(readWebReleaseVersion(root)).toBe("1.2.3-dev.5");
95
+ });
96
+
97
+ test("missing or malformed release metadata cannot stand in for a release", () => {
98
+ const root = makeTempDir();
99
+ expect(readWebReleaseVersion(root)).toBeUndefined();
100
+ for (const value of [
101
+ "not JSON",
102
+ "null",
103
+ "{}",
104
+ '{"version":null}',
105
+ '{"version":"latest"}',
106
+ '{"version":""}',
107
+ '{"version":"bundle-hash"}',
108
+ ]) {
109
+ writeFileSync(join(root, "release.json"), value);
110
+ expect(readWebReleaseVersion(root)).toBeUndefined();
111
+ }
112
+ });
@@ -334,7 +334,7 @@ export class AssistantClient {
334
334
 
335
335
  // A stale cached Cadet-Organization-Id is the usual cause of a 401 on the
336
336
  // platform path (e.g. the user switched orgs elsewhere). Drop it and retry
337
- // once. Mirrors localRuntimeIdentity in local-runtime-client.ts.
337
+ // once.
338
338
  if (response.status === 401 && this.authMode === "platform" && this.token) {
339
339
  invalidateOrgIdCache(this.token, this.runtimeUrl);
340
340
  this.platformHeaders = undefined;
@@ -103,16 +103,11 @@ export type AssistantEntry = Omit<
103
103
  /** Loopback URL for same-machine health checks (e.g. `http://127.0.0.1:7831`).
104
104
  * Avoids mDNS resolution issues when the machine checks its own gateway. */
105
105
  localUrl?: string;
106
- /** Public https ingress URL recorded by `cadet tunnel` providers for this
107
- * instance. The advertised-URL default for remote-web pairing. */
108
- ingressUrl?: string;
109
106
  bearerToken?: string;
110
107
  /** True when this entry was registered via `cadet connect import` (a remote
111
108
  * pairing). Set alongside `cloud: "paired"`; also backs the re-import /
112
109
  * overwrite guard in connect import. */
113
110
  paired?: boolean;
114
- instanceId?: string;
115
- namespace?: string;
116
111
  project?: string;
117
112
  region?: string;
118
113
  sshUser?: string;
@@ -123,19 +118,6 @@ export type AssistantEntry = Omit<
123
118
  guardianBootstrapSecret?: string;
124
119
  /** Docker image metadata for rollback. Only present for docker topology entries. */
125
120
  containerInfo?: ContainerInfo;
126
- /** Docker image metadata from before the last upgrade. Enables rollback to the prior version. */
127
- previousContainerInfo?: ContainerInfo;
128
- /** Path to the .vbundle backup created for the most recent upgrade. Used by rollback to restore
129
- * only the backup from the specific upgrade being rolled back — never a stale backup from a
130
- * previous upgrade cycle. */
131
- preUpgradeBackupPath?: string;
132
- /** Running version of the service group at the time of the last upgrade, as reported by
133
- * the health endpoint. Used by saved-state rollback for logging / broadcast events. */
134
- previousVersion?: string;
135
- /** Pre-upgrade DB migration version — used by rollback to know how far back to revert. */
136
- previousDbMigrationVersion?: number;
137
- /** Pre-upgrade workspace migration ID — used by rollback to know how far back to revert. */
138
- previousWorkspaceMigrationId?: string;
139
121
  /** Client-minted uuid (web's temp-client-id pattern) the CLI addresses this
140
122
  * assistant's chat conversation by. Minted once on first `cadet client`
141
123
  * launch and persisted here so every session shares one conversation. */
@@ -154,6 +136,8 @@ interface LockfileData {
154
136
  cloudBaseUrl?: string;
155
137
  tunnel?: RootProcessRecord;
156
138
  app?: RootProcessRecord;
139
+ /** The nginx edge's shell selection; see readEdgeShellSelection. */
140
+ edgeShell?: unknown;
157
141
  [key: string]: unknown;
158
142
  }
159
143
 
@@ -282,6 +266,27 @@ export function readRootProcesses(): {
282
266
  return { tunnel: data.tunnel, app: data.app };
283
267
  }
284
268
 
269
+ /**
270
+ * The nginx edge's shell selection, persisted at the lockfile root beside the
271
+ * `app` record that tracks the edge's process. The edge is a CLI-managed
272
+ * server rather than a per-cadet setting, and that record is lifecycle-scoped
273
+ * (cleared when the edge stops), so the selection lives on its own root key
274
+ * to survive restarts. Read raw: the lockfile is user-editable disk state,
275
+ * so the shape check lives with the readers.
276
+ */
277
+ export function readEdgeShellSelection(): unknown {
278
+ return readLockfile().edgeShell;
279
+ }
280
+
281
+ /** Persist the nginx edge's shell selection under the lockfile lock. */
282
+ export function saveEdgeShellSelection(shell: "web" | "app"): void {
283
+ withCliLockfileLock(() => {
284
+ const data = readLockfile();
285
+ data.edgeShell = shell;
286
+ writeLockfileUnlocked(data);
287
+ });
288
+ }
289
+
285
290
  /**
286
291
  * Try to extract a port number from a URL string (e.g. `http://localhost:7830`).
287
292
  * Returns undefined if the URL is malformed or has no explicit port.
@@ -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,