@mycadet/cli 0.12.10 → 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 (111) 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 +10 -10
  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 +3 -3
  5. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  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 +3 -3
  8. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  9. package/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
  10. package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  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-login-poll.test.ts +69 -0
  17. package/src/__tests__/cloud-releases.test.ts +82 -0
  18. package/src/__tests__/commands-mock.ts +101 -0
  19. package/src/__tests__/debug.test.ts +17 -91
  20. package/src/__tests__/devices.test.ts +64 -2
  21. package/src/__tests__/ensure-binary.test.ts +52 -0
  22. package/src/__tests__/gateway-token.test.ts +267 -0
  23. package/src/__tests__/hatch-next-steps.test.ts +38 -0
  24. package/src/__tests__/host-teleport-docker.test.ts +371 -0
  25. package/src/__tests__/host-teleport.test.ts +405 -0
  26. package/src/__tests__/multi-local.test.ts +3 -3
  27. package/src/__tests__/nginx-ingress.test.ts +587 -36
  28. package/src/__tests__/orphan-detection.test.ts +1 -1
  29. package/src/__tests__/paired-lifecycle.test.ts +2 -2
  30. package/src/__tests__/ps-cloud-status.test.ts +8 -7
  31. package/src/__tests__/root-backup-docker.test.ts +294 -0
  32. package/src/__tests__/root-backup.test.ts +527 -0
  33. package/src/__tests__/root-restore-command.test.ts +147 -0
  34. package/src/__tests__/root-signing-key.test.ts +50 -0
  35. package/src/__tests__/setup.test.ts +17 -7
  36. package/src/__tests__/shell-mode.test.ts +83 -0
  37. package/src/__tests__/sleep.test.ts +11 -0
  38. package/src/__tests__/teleport.test.ts +68 -2996
  39. package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
  40. package/src/__tests__/tunnel-stop.test.ts +226 -0
  41. package/src/__tests__/tunnel.test.ts +22 -4
  42. package/src/__tests__/upgrade-local.test.ts +21 -11
  43. package/src/__tests__/upgrade-replay-env.test.ts +2 -2
  44. package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
  45. package/src/__tests__/wake.test.ts +4 -1
  46. package/src/__tests__/windows-lifecycle.test.ts +25 -0
  47. package/src/commands/backup.ts +44 -399
  48. package/src/commands/client.ts +3 -3
  49. package/src/commands/completions.ts +0 -2
  50. package/src/commands/debug.ts +75 -131
  51. package/src/commands/devices.ts +117 -47
  52. package/src/commands/gateway/token.ts +77 -23
  53. package/src/commands/hatch.ts +10 -52
  54. package/src/commands/pair.ts +9 -3
  55. package/src/commands/ps.ts +4 -2
  56. package/src/commands/restore.ts +77 -444
  57. package/src/commands/retire.ts +2 -3
  58. package/src/commands/sleep.ts +15 -2
  59. package/src/commands/teleport.ts +47 -1559
  60. package/src/commands/tunnel.ts +91 -11
  61. package/src/commands/upgrade.ts +84 -157
  62. package/src/commands/wake.ts +1 -1
  63. package/src/index.ts +0 -4
  64. package/src/lib/__tests__/http-client-version.test.ts +29 -0
  65. package/src/lib/__tests__/runtime-url.test.ts +3 -75
  66. package/src/lib/__tests__/web-dist.test.ts +27 -1
  67. package/src/lib/assistant-client.ts +1 -1
  68. package/src/lib/assistant-config.ts +43 -23
  69. package/src/lib/backup-ops.ts +37 -251
  70. package/src/lib/cloud-client.ts +10 -13
  71. package/src/lib/cloud-login-poll.ts +75 -0
  72. package/src/lib/cloud-login.ts +5 -2
  73. package/src/lib/cloud-releases.ts +54 -10
  74. package/src/lib/cloud-values.ts +1 -1
  75. package/src/lib/confirm-action.ts +2 -2
  76. package/src/lib/docker.ts +65 -34
  77. package/src/lib/ensure-binary.ts +65 -6
  78. package/src/lib/github-repository.ts +2 -0
  79. package/src/lib/guardian-token.ts +1 -23
  80. package/src/lib/hatch-local.ts +18 -52
  81. package/src/lib/hatch-next-steps.ts +15 -2
  82. package/src/lib/host-teleport.ts +506 -0
  83. package/src/lib/http-client.ts +25 -0
  84. package/src/lib/ingress-config.ts +31 -0
  85. package/src/lib/live-voice/tunnel.test.ts +1 -1
  86. package/src/lib/live-voice/tunnel.ts +3 -3
  87. package/src/lib/local.ts +10 -8
  88. package/src/lib/nginx-ingress.ts +572 -95
  89. package/src/lib/process.ts +13 -0
  90. package/src/lib/root-backup.ts +917 -0
  91. package/src/lib/root-signing-key.ts +31 -0
  92. package/src/lib/runtime-url.ts +0 -29
  93. package/src/lib/teleport-backup.ts +3 -16
  94. package/src/lib/tunnel.ts +87 -34
  95. package/src/lib/tunnels/cadet.ts +12 -1
  96. package/src/lib/tunnels/cloudflare.ts +36 -1
  97. package/src/lib/tunnels/ngrok.ts +16 -2
  98. package/src/lib/tunnels/process-lifecycle.ts +26 -0
  99. package/src/lib/tunnels/tailscale.ts +79 -1
  100. package/src/lib/upgrade-lifecycle.ts +63 -141
  101. package/src/lib/web-dist.ts +79 -1
  102. package/src/lib/xdg-log.ts +11 -0
  103. package/src/__tests__/bundle-staging.test.ts +0 -268
  104. package/src/__tests__/unpair.test.ts +0 -160
  105. package/src/commands/rollback.ts +0 -479
  106. package/src/commands/unpair.ts +0 -122
  107. package/src/lib/__tests__/job-polling.test.ts +0 -278
  108. package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
  109. package/src/lib/bundle-staging.ts +0 -220
  110. package/src/lib/job-polling.ts +0 -195
  111. package/src/lib/local-runtime-client.ts +0 -374
@@ -0,0 +1,29 @@
1
+ import { afterAll, afterEach, expect, spyOn, test } from "bun:test";
2
+ import { fetchCurrentVersion } from "../http-client.js";
3
+
4
+ const request = spyOn(globalThis, "fetch");
5
+ afterEach(() => request.mockReset());
6
+ afterAll(() => request.mockRestore());
7
+
8
+ test("reads the gateway's version through the public health endpoint", async () => {
9
+ request.mockResolvedValueOnce(
10
+ Response.json({ version: "1.2.3", status: "ok" }),
11
+ );
12
+ expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBe("1.2.3");
13
+ expect(request.mock.calls[0][0]).toBe("http://127.0.0.1:7830/healthz");
14
+ });
15
+
16
+ test("an unverified gateway response never supplies a release version", async () => {
17
+ for (const value of [{}, null, { version: "" }, { version: 123 }]) {
18
+ request.mockResolvedValueOnce(Response.json(value));
19
+ expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBeUndefined();
20
+ }
21
+ request.mockResolvedValueOnce(new Response("not JSON"));
22
+ expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBeUndefined();
23
+ request.mockResolvedValueOnce(
24
+ Response.json({ version: "1.2.3" }, { status: 503 }),
25
+ );
26
+ expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBeUndefined();
27
+ request.mockRejectedValueOnce(new Error("offline"));
28
+ expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBeUndefined();
29
+ });
@@ -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
 
@@ -170,6 +154,10 @@ export interface RootProcessRecord {
170
154
  /** The assistant the process fronts, so `cadet ps` can nest the row. */
171
155
  assistantId?: string;
172
156
  startedAt: string;
157
+ /** Listening port for the nginx edge. */
158
+ port?: number;
159
+ /** Managed process temporarily displaced by a pairing-only tunnel. */
160
+ previous?: RootProcessRecord;
173
161
  }
174
162
 
175
163
  export type RootProcessKind = "tunnel" | "app";
@@ -247,12 +235,21 @@ export function stampRootProcess(
247
235
  });
248
236
  }
249
237
 
250
- /** Drop the recorded root process entry, e.g. once the process is stopped. */
251
- export function clearRootProcess(kind: RootProcessKind): void {
238
+ /** Release a recorded process, preserving a replacement or restoring a displaced record. */
239
+ export function clearRootProcess(
240
+ kind: RootProcessKind,
241
+ expectedPid?: number,
242
+ ): void {
252
243
  withCliLockfileLock(() => {
253
244
  const data = readLockfile();
254
- if (data[kind] === undefined) return;
255
- delete data[kind];
245
+ const record = data[kind];
246
+ if (
247
+ record === undefined ||
248
+ (expectedPid !== undefined && record.pid !== expectedPid)
249
+ )
250
+ return;
251
+ if (record.previous) data[kind] = record.previous;
252
+ else delete data[kind];
256
253
  writeLockfileUnlocked(data);
257
254
  });
258
255
  }
@@ -269,6 +266,27 @@ export function readRootProcesses(): {
269
266
  return { tunnel: data.tunnel, app: data.app };
270
267
  }
271
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
+
272
290
  /**
273
291
  * Try to extract a port number from a URL string (e.g. `http://localhost:7830`).
274
292
  * Returns undefined if the URL is malformed or has no explicit port.
@@ -442,7 +460,9 @@ export function loadLatestAssistant(): AssistantEntry | null {
442
460
  }
443
461
 
444
462
  export function findAssistantByName(name: string): AssistantEntry | null {
445
- return loadAllAssistants().find((entry) => entry.assistantId === name) ?? null;
463
+ return (
464
+ loadAllAssistants().find((entry) => entry.assistantId === name) ?? null
465
+ );
446
466
  }
447
467
 
448
468
  export function getAssistantDisplayName(entry: AssistantEntry): string {
@@ -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
  */