@mycadet/cli 0.12.10 → 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 (105) 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/ingress.ts +3 -3
  4. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  5. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  6. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  7. package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  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-login-poll.test.ts +69 -0
  14. package/src/__tests__/cloud-releases.test.ts +82 -0
  15. package/src/__tests__/commands-mock.ts +101 -0
  16. package/src/__tests__/debug.test.ts +17 -91
  17. package/src/__tests__/devices.test.ts +64 -2
  18. package/src/__tests__/ensure-binary.test.ts +52 -0
  19. package/src/__tests__/gateway-token.test.ts +267 -0
  20. package/src/__tests__/hatch-next-steps.test.ts +38 -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__/multi-local.test.ts +3 -3
  24. package/src/__tests__/nginx-ingress.test.ts +233 -36
  25. package/src/__tests__/orphan-detection.test.ts +1 -1
  26. package/src/__tests__/paired-lifecycle.test.ts +2 -2
  27. package/src/__tests__/ps-cloud-status.test.ts +8 -7
  28. package/src/__tests__/root-backup-docker.test.ts +294 -0
  29. package/src/__tests__/root-backup.test.ts +527 -0
  30. package/src/__tests__/root-restore-command.test.ts +147 -0
  31. package/src/__tests__/root-signing-key.test.ts +50 -0
  32. package/src/__tests__/setup.test.ts +17 -7
  33. package/src/__tests__/sleep.test.ts +11 -0
  34. package/src/__tests__/teleport.test.ts +68 -2996
  35. package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
  36. package/src/__tests__/tunnel-stop.test.ts +226 -0
  37. package/src/__tests__/tunnel.test.ts +13 -3
  38. package/src/__tests__/upgrade-local.test.ts +21 -11
  39. package/src/__tests__/upgrade-replay-env.test.ts +2 -2
  40. package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
  41. package/src/__tests__/windows-lifecycle.test.ts +25 -0
  42. package/src/commands/backup.ts +44 -399
  43. package/src/commands/client.ts +3 -3
  44. package/src/commands/completions.ts +0 -2
  45. package/src/commands/debug.ts +75 -131
  46. package/src/commands/devices.ts +117 -47
  47. package/src/commands/gateway/token.ts +77 -23
  48. package/src/commands/hatch.ts +10 -52
  49. package/src/commands/pair.ts +9 -3
  50. package/src/commands/ps.ts +4 -2
  51. package/src/commands/restore.ts +77 -444
  52. package/src/commands/retire.ts +2 -3
  53. package/src/commands/sleep.ts +15 -2
  54. package/src/commands/teleport.ts +47 -1559
  55. package/src/commands/tunnel.ts +43 -4
  56. package/src/commands/upgrade.ts +84 -157
  57. package/src/commands/wake.ts +1 -1
  58. package/src/index.ts +0 -4
  59. package/src/lib/__tests__/http-client-version.test.ts +29 -0
  60. package/src/lib/__tests__/runtime-url.test.ts +3 -75
  61. package/src/lib/__tests__/web-dist.test.ts +27 -1
  62. package/src/lib/assistant-client.ts +1 -1
  63. package/src/lib/assistant-config.ts +20 -23
  64. package/src/lib/backup-ops.ts +37 -251
  65. package/src/lib/cloud-client.ts +10 -13
  66. package/src/lib/cloud-login-poll.ts +75 -0
  67. package/src/lib/cloud-login.ts +5 -2
  68. package/src/lib/cloud-releases.ts +54 -10
  69. package/src/lib/cloud-values.ts +1 -1
  70. package/src/lib/confirm-action.ts +2 -2
  71. package/src/lib/docker.ts +65 -34
  72. package/src/lib/ensure-binary.ts +65 -6
  73. package/src/lib/github-repository.ts +2 -0
  74. package/src/lib/guardian-token.ts +1 -23
  75. package/src/lib/hatch-local.ts +18 -52
  76. package/src/lib/hatch-next-steps.ts +15 -2
  77. package/src/lib/host-teleport.ts +506 -0
  78. package/src/lib/http-client.ts +25 -0
  79. package/src/lib/live-voice/tunnel.test.ts +1 -1
  80. package/src/lib/live-voice/tunnel.ts +3 -3
  81. package/src/lib/local.ts +10 -8
  82. package/src/lib/nginx-ingress.ts +254 -36
  83. package/src/lib/process.ts +13 -0
  84. package/src/lib/root-backup.ts +917 -0
  85. package/src/lib/root-signing-key.ts +31 -0
  86. package/src/lib/runtime-url.ts +0 -29
  87. package/src/lib/teleport-backup.ts +3 -16
  88. package/src/lib/tunnel.ts +86 -34
  89. package/src/lib/tunnels/cadet.ts +12 -1
  90. package/src/lib/tunnels/cloudflare.ts +36 -1
  91. package/src/lib/tunnels/ngrok.ts +16 -2
  92. package/src/lib/tunnels/process-lifecycle.ts +26 -0
  93. package/src/lib/tunnels/tailscale.ts +79 -1
  94. package/src/lib/upgrade-lifecycle.ts +63 -141
  95. package/src/lib/web-dist.ts +23 -1
  96. package/src/lib/xdg-log.ts +11 -0
  97. package/src/__tests__/bundle-staging.test.ts +0 -268
  98. package/src/__tests__/unpair.test.ts +0 -160
  99. package/src/commands/rollback.ts +0 -479
  100. package/src/commands/unpair.ts +0 -122
  101. package/src/lib/__tests__/job-polling.test.ts +0 -278
  102. package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
  103. package/src/lib/bundle-staging.ts +0 -220
  104. package/src/lib/job-polling.ts +0 -195
  105. package/src/lib/local-runtime-client.ts +0 -374
@@ -3,6 +3,7 @@ import {
3
3
  existsSync,
4
4
  mkdirSync,
5
5
  readFileSync,
6
+ statSync,
6
7
  renameSync,
7
8
  unlinkSync,
8
9
  } from "fs";
@@ -14,18 +15,6 @@ import {
14
15
  saveAssistantEntry,
15
16
  } from "../lib/assistant-config.js";
16
17
  import type { AssistantEntry } from "../lib/assistant-config.js";
17
- import {
18
- bundleFileSizeBytes,
19
- formatBundleSizeMb,
20
- importStagedBundle,
21
- preflightStagedBundle,
22
- stageBundleForRestore,
23
- stagingTargetFromEntry,
24
- } from "../lib/bundle-staging.js";
25
- import {
26
- loadGuardianToken,
27
- leaseGuardianToken,
28
- } from "../lib/guardian-token.js";
29
18
  import {
30
19
  generateLocalSigningKey,
31
20
  startCes,
@@ -36,57 +25,44 @@ import { getArchivePath, getMetadataPath } from "../lib/retire-archive.js";
36
25
  import { exec } from "../lib/step-runner.js";
37
26
  import {
38
27
  readCloudToken,
39
- rollbackCloudAssistant,
40
28
  cloudRequestSignedUrl,
41
29
  platformUploadToSignedUrl,
42
30
  platformImportPreflightFromGcs,
43
31
  platformImportBundleFromGcs,
44
32
  platformPollJobStatus,
45
33
  } from "../lib/cloud-client.js";
46
- import { performDockerRollback } from "../lib/upgrade-lifecycle.js";
47
- import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
34
+ import { restoreRootBackup, validateRootArchive } from "../lib/root-backup.js";
48
35
 
49
36
  function printUsage(): void {
50
- console.log(
51
- "Usage: cadet restore <name> [--from <path>] [--version <version>] [--dry-run]",
52
- );
37
+ console.log("Usage: cadet restore <name> [--from <path>] [--dry-run]");
53
38
  console.log("");
54
- console.log("Restore data from a .vbundle backup into an assistant.");
55
- console.log(
56
- "Without --from, recovers a retired assistant from its archive in",
57
- );
58
39
  console.log(
59
- "$XDG_DATA_HOME/cadet/retired/ and starts it again.",
40
+ "Restore all persistent roots from a tar archive into an assistant.",
60
41
  );
61
42
  console.log(
62
- "With --version, also rolls back to the specified version first.",
43
+ "Without --from, recovers a retired assistant from its archive in",
63
44
  );
45
+ console.log("$XDG_DATA_HOME/cadet/retired/ and starts it again.");
64
46
  console.log("");
65
47
  console.log("Arguments:");
66
48
  console.log(" <name> Name of the assistant to restore into");
67
49
  console.log("");
68
50
  console.log("Options:");
69
- console.log(" --from <path> Path to the .vbundle file (required)");
70
- console.log(
71
- " --version <version> Roll back to this version before importing data",
72
- );
51
+ console.log(" --from <path> Path to the .tar.gz file (required)");
73
52
  console.log(
74
53
  " --dry-run Show what would change without applying (data-only)",
75
54
  );
76
55
  console.log("");
77
56
  console.log("Examples:");
78
57
  console.log(" cadet restore my-retired-assistant");
79
- console.log(" cadet restore my-assistant --from backup.vbundle");
80
- console.log(
81
- " cadet restore my-assistant --from backup.vbundle --version v1.2.3",
82
- );
83
- console.log(" cadet restore my-assistant --from backup.vbundle --dry-run");
58
+ console.log(" cadet restore my-assistant --from backup.tar.gz");
59
+ console.log(" cadet restore my-cloud-assistant --from backup.tar.gz");
60
+ console.log(" cadet restore my-assistant --from backup.tar.gz --dry-run");
84
61
  }
85
62
 
86
63
  function parseArgs(argv: string[]): {
87
64
  name: string | undefined;
88
65
  fromPath: string | undefined;
89
- version: string | undefined;
90
66
  dryRun: boolean;
91
67
  help: boolean;
92
68
  } {
@@ -96,14 +72,12 @@ function parseArgs(argv: string[]): {
96
72
  return {
97
73
  name: undefined,
98
74
  fromPath: undefined,
99
- version: undefined,
100
75
  dryRun: false,
101
76
  help: true,
102
77
  };
103
78
  }
104
79
 
105
80
  let fromPath: string | undefined;
106
- let version: string | undefined;
107
81
  const dryRun = args.includes("--dry-run");
108
82
  const positionals: string[] = [];
109
83
 
@@ -111,54 +85,16 @@ function parseArgs(argv: string[]): {
111
85
  if (args[i] === "--from" && args[i + 1]) {
112
86
  fromPath = args[i + 1];
113
87
  i++; // skip the value
114
- } else if (args[i] === "--version") {
115
- const next = args[i + 1];
116
- if (!next || next.startsWith("-")) {
117
- console.error("Error: --version requires a value");
118
- process.exit(1);
119
- }
120
- version = next;
121
- i++; // skip the value
122
88
  } else if (args[i] === "--dry-run") {
123
89
  // already handled above
124
- } else if (!args[i].startsWith("-")) {
90
+ } else if (args[i].startsWith("-")) {
91
+ throw new Error(`Unknown restore option: ${args[i]}`);
92
+ } else {
125
93
  positionals.push(args[i]);
126
94
  }
127
95
  }
128
96
 
129
- return { name: positionals[0], fromPath, version, dryRun, help: false };
130
- }
131
-
132
- async function getAccessToken(
133
- runtimeUrl: string,
134
- assistantId: string,
135
- displayName: string,
136
- bootstrapSecret?: string,
137
- ): Promise<string> {
138
- const tokenData = loadGuardianToken(assistantId);
139
-
140
- if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
141
- return tokenData.accessToken;
142
- }
143
-
144
- try {
145
- const freshToken = await leaseGuardianToken(
146
- runtimeUrl,
147
- assistantId,
148
- bootstrapSecret,
149
- );
150
- return freshToken.accessToken;
151
- } catch (err) {
152
- const msg = err instanceof Error ? err.message : String(err);
153
- if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
154
- console.error(
155
- `Error: Could not connect to assistant '${displayName}'. Is it running?`,
156
- );
157
- console.error(`Try: cadet wake ${displayName}`);
158
- process.exit(1);
159
- }
160
- throw err;
161
- }
97
+ return { name: positionals[0], fromPath, dryRun, help: false };
162
98
  }
163
99
 
164
100
  interface PreflightFileEntry {
@@ -204,14 +140,14 @@ interface ImportResponse {
204
140
  }
205
141
 
206
142
  // ---------------------------------------------------------------------------
207
- // Platform (Cadet-hosted) restore via Django migration import
143
+ // Cloud restore through the cloud API; the CLI never opens cloud storage.
208
144
  // ---------------------------------------------------------------------------
209
145
 
210
146
  async function restorePlatform(
211
147
  entry: AssistantEntry,
212
148
  name: string,
213
149
  bundleData: Buffer,
214
- opts: { version?: string; dryRun: boolean },
150
+ opts: { dryRun: boolean },
215
151
  ): Promise<void> {
216
152
  // Step 1 — Authenticate
217
153
  const token = readCloudToken();
@@ -220,15 +156,13 @@ async function restorePlatform(
220
156
  process.exit(1);
221
157
  }
222
158
 
223
- // Step 1.5 — Upload to GCS via signed URL.
224
- // We deliberately omit min/max runtime version here: restore uploads an
225
- // arbitrary .vbundle from disk (often produced by a different runtime
226
- // than the one we'd query right now), and the bundle's own manifest is
227
- // the authority on its compatibility band. The cloud skips the
228
- // version gate when these fields are absent and re-derives compat from
229
- // the manifest when it processes the import.
159
+ // Upload the root archive through the cloud API; it owns cloud restore.
230
160
  const { url: uploadUrl, bundleKey } = await cloudRequestSignedUrl(
231
- { operation: "upload" },
161
+ {
162
+ operation: "upload",
163
+ contentType: "application/gzip",
164
+ contentLength: bundleData.length,
165
+ },
232
166
  token,
233
167
  entry.runtimeUrl,
234
168
  );
@@ -237,13 +171,6 @@ async function restorePlatform(
237
171
 
238
172
  // Step 2 — Dry-run path
239
173
  if (opts.dryRun) {
240
- if (opts.version) {
241
- console.error(
242
- "Dry-run is not supported with --version. Use `cadet restore --from <path> --dry-run` for data-only preflight.",
243
- );
244
- process.exit(1);
245
- }
246
-
247
174
  console.log("Running preflight analysis...\n");
248
175
 
249
176
  let preflightResult: { statusCode: number; body: Record<string, unknown> };
@@ -255,7 +182,7 @@ async function restorePlatform(
255
182
  );
256
183
  } catch (err) {
257
184
  if (err instanceof Error && err.name === "TimeoutError") {
258
- console.error("Error: Preflight request timed out after 2 minutes.");
185
+ console.error("Error: Preflight request timed out after 30 minutes.");
259
186
  process.exit(1);
260
187
  }
261
188
  throw err;
@@ -354,34 +281,6 @@ async function restorePlatform(
354
281
  return;
355
282
  }
356
283
 
357
- // Step 3 — Version rollback (if --version set)
358
- if (opts.version) {
359
- console.log(
360
- `Rolling back to version ${opts.version} before restoring data...`,
361
- );
362
-
363
- try {
364
- await rollbackCloudAssistant(
365
- token,
366
- entry.assistantId,
367
- opts.version,
368
- entry.runtimeUrl,
369
- );
370
- } catch (err) {
371
- const msg = err instanceof Error ? err.message : String(err);
372
- if (msg.includes("401") || msg.includes("403")) {
373
- console.error("Authentication failed. Run 'cadet login' to refresh.");
374
- process.exit(1);
375
- }
376
- console.error(`Error: Rollback failed — ${msg}`);
377
- process.exit(1);
378
- }
379
-
380
- console.log(
381
- `Rolled back to ${opts.version}. Proceeding with data restore...`,
382
- );
383
- }
384
-
385
284
  // Step 4 — Data import
386
285
  console.log("Importing backup data...");
387
286
 
@@ -394,7 +293,7 @@ async function restorePlatform(
394
293
  );
395
294
  } catch (err) {
396
295
  if (err instanceof Error && err.name === "TimeoutError") {
397
- console.error("Error: Import request timed out after 5 minutes.");
296
+ console.error("Error: Import request timed out after 30 minutes.");
398
297
  process.exit(1);
399
298
  }
400
299
  throw err;
@@ -623,349 +522,83 @@ async function recoverFromArchive(name: string): Promise<void> {
623
522
 
624
523
  export const restore = Object.assign(
625
524
  async function restore(): Promise<void> {
626
- const { name, fromPath, version, dryRun, help } = parseArgs(process.argv);
627
-
628
- if (help) {
629
- printUsage();
630
- process.exit(0);
631
- }
632
-
633
- // --version requires --from
634
- if (version && !fromPath) {
635
- console.error(
636
- "A backup file is required for restore. Use --from <path> to specify the .vbundle file.",
637
- );
638
- process.exit(1);
639
- }
640
-
641
- // --dry-run is not supported with --version
642
- if (version && dryRun) {
643
- console.error(
644
- "Dry-run is not supported with --version. Use `cadet restore --from <path> --dry-run` for data-only preflight.",
645
- );
646
- process.exit(1);
647
- }
648
-
649
- if (!name) {
650
- console.error("Error: <name> is required.");
651
- console.error("");
652
- printUsage();
653
- process.exit(1);
654
- }
525
+ const { name, fromPath, dryRun, help } = parseArgs(process.argv);
655
526
 
656
- // Without --from: recover a retired assistant from its retire archive.
657
- if (!fromPath) {
658
- if (dryRun) {
659
- console.error(
660
- "Dry-run is only supported with --from. Recovery replays the retired assistant's archive directly.",
661
- );
662
- process.exit(1);
527
+ if (help) {
528
+ printUsage();
529
+ process.exit(0);
663
530
  }
664
- await recoverFromArchive(name);
665
- return;
666
- }
667
-
668
- // Look up the instance
669
- const entry = findAssistantByName(name);
670
- if (!entry) {
671
- console.error(`Error: No assistant found with name '${name}'.`);
672
- console.error("Run 'cadet ps' to see available assistants.");
673
- process.exit(1);
674
- }
675
-
676
- // Verify .vbundle file exists
677
- if (!existsSync(fromPath)) {
678
- console.error(`Error: File not found: ${fromPath}`);
679
- process.exit(1);
680
- }
681
-
682
- const sizeMB = formatBundleSizeMb(bundleFileSizeBytes(fromPath));
683
- console.log(`Reading ${fromPath} — ${sizeMB} MB...`);
684
-
685
- // Detect topology and route platform assistants through Django import
686
- const cloud =
687
- entry.cloud || (entry.project ? "gcp" : entry.sshUser ? "custom" : "local");
688
-
689
- if (cloud === "apple-container") {
690
- console.error(
691
- `Error: '${name}' uses the Apple Containers runtime. Restore is not yet supported for this topology.`,
692
- );
693
- process.exit(1);
694
- }
695
-
696
- if (isCloudHome(cloud)) {
697
- const bundleData = readFileSync(fromPath);
698
- await restorePlatform(entry, name, bundleData, { version, dryRun });
699
- return;
700
- }
701
-
702
- if (version && cloud !== "docker") {
703
- console.error(
704
- "Restore with --version is only supported for Docker and cloud assistants.",
705
- );
706
- process.exit(1);
707
- }
708
-
709
- // Obtain auth token (acquired before dry-run or before data import;
710
- // re-acquired after version rollback since containers restart).
711
- const accessToken = await getAccessToken(
712
- entry.runtimeUrl,
713
- entry.assistantId,
714
- name,
715
- entry.guardianBootstrapSecret,
716
- );
717
-
718
- const staging = stagingTargetFromEntry(entry);
719
- let stagedRelativePath: string | undefined;
720
- let bundleData: Buffer | undefined;
721
- let cleanupStaged: (() => Promise<void>) | undefined;
722
- if (staging) {
723
- const staged = await stageBundleForRestore(staging, fromPath);
724
- stagedRelativePath = staged.relativePath;
725
- cleanupStaged = staged.cleanup;
726
- } else {
727
- bundleData = readFileSync(fromPath);
728
- }
729
531
 
730
- try {
731
- await runLocalRestore({
732
- entry,
733
- name,
734
- version,
735
- dryRun,
736
- accessToken,
737
- stagedRelativePath,
738
- bundleData,
739
- });
740
- } finally {
741
- if (cleanupStaged) {
742
- await cleanupStaged();
532
+ if (!name) {
533
+ console.error("Error: <name> is required.");
534
+ console.error("");
535
+ printUsage();
536
+ process.exit(1);
743
537
  }
744
- }
745
- },
746
- { description: "Restore a .vbundle backup, or recover a retired assistant" },
747
- );
748
-
749
538
 
750
- async function runLocalRestore(opts: {
751
- entry: AssistantEntry;
752
- name: string;
753
- version: string | undefined;
754
- dryRun: boolean;
755
- accessToken: string;
756
- stagedRelativePath: string | undefined;
757
- bundleData: Buffer | undefined;
758
- }): Promise<void> {
759
- const { entry, name, version, dryRun, stagedRelativePath, bundleData } = opts;
760
- let accessToken = opts.accessToken;
761
-
762
- if (dryRun) {
763
- // Preflight check
764
- console.log("Running preflight analysis...\n");
765
-
766
- let response: Response;
767
- try {
768
- response = stagedRelativePath
769
- ? await preflightStagedBundle(
770
- entry.runtimeUrl,
771
- accessToken,
772
- stagedRelativePath,
773
- )
774
- : await loopbackSafeFetch(
775
- `${entry.runtimeUrl}/v1/migrations/import-preflight`,
776
- {
777
- method: "POST",
778
- headers: {
779
- Authorization: `Bearer ${accessToken}`,
780
- "Content-Type": "application/octet-stream",
781
- },
782
- body: bundleData ? new Uint8Array(bundleData) : undefined,
783
- signal: AbortSignal.timeout(120_000),
784
- },
785
- );
786
- } catch (err) {
787
- if (err instanceof Error && err.name === "TimeoutError") {
788
- console.error("Error: Preflight request timed out after 2 minutes.");
789
- process.exit(1);
790
- }
791
- const msg = err instanceof Error ? err.message : String(err);
792
- if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
539
+ // Without --from: recover a retired assistant from its retire archive.
540
+ if (!fromPath) {
541
+ if (dryRun) {
793
542
  console.error(
794
- `Error: Could not connect to assistant '${name}'. Is it running?`,
543
+ "Dry-run is only supported with --from. Recovery replays the retired assistant's archive directly.",
795
544
  );
796
- console.error(`Try: cadet wake ${name}`);
797
545
  process.exit(1);
798
546
  }
799
- throw err;
547
+ await recoverFromArchive(name);
548
+ return;
800
549
  }
801
550
 
802
- if (!response.ok) {
803
- const body = await response.text();
804
- console.error(
805
- `Error: Preflight check failed (${response.status}): ${body}`,
806
- );
551
+ // Look up the instance
552
+ const entry = findAssistantByName(name);
553
+ if (!entry) {
554
+ console.error(`Error: No assistant found with name '${name}'.`);
555
+ console.error("Run 'cadet ps' to see available assistants.");
807
556
  process.exit(1);
808
557
  }
809
558
 
810
- const result = (await response.json()) as PreflightResponse;
811
-
812
- if (!result.can_import) {
813
- if (result.validation?.errors?.length) {
814
- console.error("Import blocked by validation errors:");
815
- for (const err of result.validation.errors) {
816
- console.error(
817
- ` - ${err.message}${err.path ? ` (${err.path})` : ""}`,
818
- );
819
- }
820
- }
821
- if (result.conflicts?.length) {
822
- console.error("Import blocked by conflicts:");
823
- for (const conflict of result.conflicts) {
824
- console.error(
825
- ` - ${conflict.message}${conflict.path ? ` (${conflict.path})` : ""}`,
826
- );
827
- }
828
- }
559
+ // Verify .tar.gz file exists
560
+ if (!existsSync(fromPath)) {
561
+ console.error(`Error: File not found: ${fromPath}`);
829
562
  process.exit(1);
830
563
  }
831
564
 
832
- // Print summary table
833
- const summary = result.summary ?? {
834
- files_to_create: 0,
835
- files_to_overwrite: 0,
836
- files_unchanged: 0,
837
- total_files: 0,
838
- };
839
- console.log("Preflight analysis:");
840
- console.log(` Files to create: ${summary.files_to_create}`);
841
- console.log(` Files to overwrite: ${summary.files_to_overwrite}`);
842
- console.log(` Files unchanged: ${summary.files_unchanged}`);
843
- console.log(` Total: ${summary.total_files}`);
844
- console.log("");
845
-
846
- const conflicts = result.conflicts ?? [];
847
- console.log(
848
- `Conflicts: ${conflicts.length > 0 ? conflicts.map((c) => c.message).join(", ") : "none"}`,
849
- );
850
-
851
- // List individual files with their action
852
- if (result.files && result.files.length > 0) {
853
- console.log("");
854
- console.log("Files:");
855
- for (const file of result.files) {
856
- console.log(` [${file.action}] ${file.path}`);
857
- }
858
- }
859
- } else {
860
- // Version rollback (when --version is specified)
861
- if (version) {
862
- console.log(`Rolling back to version ${version}...`);
863
- await performDockerRollback(entry, { targetVersion: version });
864
- console.log("");
865
-
866
- // Re-acquire auth token since containers were restarted during rollback
867
- accessToken = await getAccessToken(
868
- entry.runtimeUrl,
869
- entry.assistantId,
870
- name,
871
- entry.guardianBootstrapSecret,
872
- );
873
- }
874
-
875
- // Data import
876
- console.log("Importing backup data...\n");
565
+ const sizeMB = (statSync(fromPath).size / (1024 * 1024)).toFixed(1);
566
+ console.log(`Reading ${fromPath} — ${sizeMB} MB...`);
877
567
 
878
- let result: ImportResponse;
879
- try {
880
- const response = stagedRelativePath
881
- ? await importStagedBundle(
882
- entry.runtimeUrl,
883
- accessToken,
884
- stagedRelativePath,
885
- )
886
- : await loopbackSafeFetch(`${entry.runtimeUrl}/v1/migrations/import`, {
887
- method: "POST",
888
- headers: {
889
- Authorization: `Bearer ${accessToken}`,
890
- "Content-Type": "application/octet-stream",
891
- },
892
- body: bundleData ? new Uint8Array(bundleData) : undefined,
893
- signal: AbortSignal.timeout(120_000),
894
- });
895
- if (!response.ok) {
896
- const body = await response.text();
897
- console.error(`Error: Import failed (${response.status}): ${body}`);
898
- process.exit(1);
899
- }
900
- result = (await response.json()) as ImportResponse;
901
- } catch (err) {
902
- if (err instanceof Error && err.name === "TimeoutError") {
903
- console.error("Error: Import request timed out after 2 minutes.");
904
- process.exit(1);
905
- }
906
- const msg = err instanceof Error ? err.message : String(err);
907
- if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
908
- console.error(
909
- `Error: Could not connect to assistant '${name}'. Is it running?`,
910
- );
911
- console.error(`Try: cadet wake ${name}`);
912
- process.exit(1);
913
- }
914
- throw err;
915
- }
568
+ // Cloud storage and lifecycle belong to the cloud API.
569
+ const cloud =
570
+ entry.cloud ||
571
+ (entry.project ? "gcp" : entry.sshUser ? "custom" : "local");
916
572
 
917
- if (!result.success) {
573
+ if (cloud === "apple-container") {
918
574
  console.error(
919
- `Error: Import failed — ${result.message ?? result.reason ?? "unknown reason"}`,
575
+ `Error: '${name}' uses the Apple Containers runtime. Restore is not yet supported for this topology.`,
920
576
  );
921
- for (const err of result.errors ?? []) {
922
- console.error(` - ${err.message}${err.path ? ` (${err.path})` : ""}`);
923
- }
924
577
  process.exit(1);
925
578
  }
926
579
 
927
- // Print import report
928
- const summary = result.summary ?? {
929
- total_files: 0,
930
- files_created: 0,
931
- files_overwritten: 0,
932
- files_skipped: 0,
933
- backups_created: 0,
934
- };
935
- console.log("✅ Restore complete.");
936
- console.log(` Files created: ${summary.files_created}`);
937
- console.log(` Files overwritten: ${summary.files_overwritten}`);
938
- console.log(` Files skipped: ${summary.files_skipped}`);
939
- console.log(` Backups created: ${summary.backups_created}`);
940
-
941
- // A daemon in the terminal failed-migrations state accepts this import
942
- // (the migration-repair exemption), but its readiness latch only clears
943
- // on restart — without one it keeps refusing DB-backed routes.
944
- try {
945
- const readyz = await loopbackSafeFetch(`${entry.runtimeUrl}/readyz`, {
946
- signal: AbortSignal.timeout(3_000),
947
- });
948
- const readyzBody = (await readyz.json().catch(() => null)) as {
949
- dbMigrations?: { state?: string };
950
- } | null;
951
- if (readyzBody?.dbMigrations?.state === "failed") {
952
- console.warn(
953
- "\n⚠️ The assistant is in a failed-migrations state; the restored data takes effect after a restart:",
954
- );
955
- console.warn(` cadet sleep ${name} && cadet wake ${name}`);
956
- }
957
- } catch {
958
- // Best-effort advisory — never fail a completed restore over it.
580
+ if (isCloudHome(cloud)) {
581
+ await validateRootArchive(fromPath);
582
+ const bundleData = readFileSync(fromPath);
583
+ await restorePlatform(entry, name, bundleData, { dryRun });
584
+ return;
959
585
  }
960
586
 
961
- // Print warnings if any
962
- const warnings = result.warnings ?? [];
963
- if (warnings.length > 0) {
964
- console.log("");
965
- console.log("Warnings:");
966
- for (const warning of warnings) {
967
- console.log(` ⚠️ ${warning}`);
968
- }
587
+ if (cloud !== "local" && cloud !== "docker") {
588
+ throw new Error(`Restore for '${cloud}' must run on its host.`);
969
589
  }
970
- }
971
- }
590
+ const recoveryPath = await restoreRootBackup(entry, fromPath, { dryRun });
591
+ if (dryRun)
592
+ console.log(
593
+ "Root archive is valid; restore would replace workspace, gateway security, CES security and CES data. No services were stopped.",
594
+ );
595
+ else {
596
+ console.log("Restore complete.");
597
+ console.log(`Previous state retained at ${recoveryPath}`);
598
+ }
599
+ },
600
+ {
601
+ description:
602
+ "Restore persistent roots from tar, or recover a retired assistant",
603
+ },
604
+ );
@@ -332,10 +332,9 @@ async function retireInner(): Promise<void> {
332
332
  if (cloud === "paired") {
333
333
  // A remote assistant paired from another machine. Retiring tears the
334
334
  // assistant down — that can only happen on its host machine, never from a
335
- // paired machine, which holds nothing but a pairing record. (Removing that
336
- // local record is `cadet unpair`'s job, not retire's.)
335
+ // paired machine, which holds nothing but a pairing record.
337
336
  console.error(
338
- `Error: '${assistantId}' is a remote assistant paired from another machine — it can't be retired from here. Retiring tears down the assistant, which can only be done on its host machine. To remove the local pairing record on this machine, run \`cadet unpair ${assistantId}\`.`,
337
+ `Error: '${assistantId}' is a remote assistant paired from another machine — it can't be retired from here. Retiring tears down the assistant, which can only be done on its host machine. To revoke this device's access, run \`cadet devices\` on the host that runs the assistant.`,
339
338
  );
340
339
  process.exit(1);
341
340
  }