@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
@@ -10,6 +10,7 @@ import {
10
10
  import {
11
11
  formatAssistantReference,
12
12
  loadAllAssistants,
13
+ readRootProcesses,
13
14
  resolveTargetAssistant,
14
15
  type AssistantEntry,
15
16
  } from "../lib/assistant-config";
@@ -19,9 +20,11 @@ import {
19
20
  getDefaultWorkspaceDir,
20
21
  isLocalContainerEntry,
21
22
  parseGatewayPortFromEntryUrls,
23
+ saveShellMode,
22
24
  } from "../lib/ingress-config.js";
23
25
  import {
24
26
  ensureTunnelEdge,
27
+ formatEdgeMode,
25
28
  getNginxVersion,
26
29
  type TunnelEdge,
27
30
  } from "../lib/nginx-ingress.js";
@@ -37,6 +40,8 @@ import {
37
40
  hasWebhookIntegrationsConfigured,
38
41
  providerSupportsReservedDomain,
39
42
  runProviderTunnel,
43
+ stopTunnelProcess,
44
+ summarizeStartupFailure,
40
45
  } from "../lib/tunnel.js";
41
46
 
42
47
  // The accepted `--provider` values ARE the shared registry, so validation
@@ -48,9 +53,10 @@ interface TunnelArgs {
48
53
  domain: string | null;
49
54
  clearDomain: boolean;
50
55
  foreground: boolean;
56
+ shell: "web" | "app" | null;
51
57
  }
52
58
 
53
- const FLAGS_WITH_VALUES = ["--provider", "--domain"] as const;
59
+ const FLAGS_WITH_VALUES = ["--provider", "--domain", "--shell"] as const;
54
60
 
55
61
  function parseArgs(): TunnelArgs {
56
62
  const args = process.argv.slice(3);
@@ -58,18 +64,17 @@ function parseArgs(): TunnelArgs {
58
64
  let domain: string | null = null;
59
65
  let clearDomain = false;
60
66
  let foreground = false;
67
+ let shell: "web" | "app" | null = null;
61
68
 
62
69
  for (let i = 0; i < args.length; i++) {
63
70
  const arg = args[i];
64
71
  if (arg === "--help" || arg === "-h") {
65
72
  console.log(
66
- "Usage: cadet tunnel [<name>] --provider <provider> [options]",
73
+ "Usage: cadet tunnel [<name>] --provider <provider> [options]\n cadet tunnel stop [<name>]",
67
74
  );
68
75
  console.log("");
69
76
  console.log("Front a locally running assistant with an HTTPS tunnel.");
70
- console.log(
71
- "The tunnel URL is saved to the workspace config as the ingress base URL.",
72
- );
77
+ console.log("The tunnel URL is saved to the workspace config as the ingress base URL.");
73
78
  console.log("");
74
79
  console.log(
75
80
  "How far that URL reaches depends on the provider; omit --provider for",
@@ -111,6 +116,19 @@ function parseArgs(): TunnelArgs {
111
116
  console.log(
112
117
  " tunnel without one. Cannot be combined with --domain.",
113
118
  );
119
+ console.log(
120
+ " --shell <web|app> Which client bundle the edge serves as the outer shell:",
121
+ );
122
+ console.log(
123
+ " web (default) serves the web SPA directly; app serves the",
124
+ );
125
+ console.log(
126
+ " app bundle as the outer shell and iframes the web UI from",
127
+ );
128
+ console.log(
129
+ " /web/assistant/. Saved to the workspace config so `cadet wake`",
130
+ );
131
+ console.log(" restores it; changing it restarts the nginx edge.");
114
132
  console.log(
115
133
  ` --foreground Stream the tunnel's logs to this terminal. By default the tunnel`,
116
134
  );
@@ -121,7 +139,7 @@ function parseArgs(): TunnelArgs {
121
139
  ` URL, and logs stream to tunnel.log in the CLI log directory. A background`,
122
140
  );
123
141
  console.log(
124
- " tunnel stops with `kill <pid>`, except when it adopted an already-running",
142
+ " tunnel stops with `cadet tunnel stop`, except when it adopted an already-running",
125
143
  );
126
144
  console.log(
127
145
  " tunnel (started outside this command): killing the supervisor then",
@@ -188,6 +206,20 @@ function parseArgs(): TunnelArgs {
188
206
  i++;
189
207
  } else if (arg === "--clear-domain") {
190
208
  clearDomain = true;
209
+ } else if (arg === "--shell") {
210
+ const next = args[i + 1];
211
+ if (!next || next.startsWith("-")) {
212
+ console.error("Error: --shell requires a value: web or app.");
213
+ process.exit(1);
214
+ }
215
+ if (next !== "web" && next !== "app") {
216
+ console.error(
217
+ `Error: unknown shell '${next}'. Valid shells: web, app.`,
218
+ );
219
+ process.exit(1);
220
+ }
221
+ shell = next;
222
+ i++;
191
223
  } else if (arg === "--foreground") {
192
224
  foreground = true;
193
225
  } else if (arg.startsWith("-")) {
@@ -227,7 +259,7 @@ function parseArgs(): TunnelArgs {
227
259
  process.exit(1);
228
260
  }
229
261
 
230
- return { assistantName, provider, domain, clearDomain, foreground };
262
+ return { assistantName, provider, domain, clearDomain, foreground, shell };
231
263
  }
232
264
 
233
265
  /** A tunnelable assistant plus the gateway port and workspace the edge fronts. */
@@ -394,9 +426,17 @@ async function spawnDetachedTunnel(): Promise<void> {
394
426
  });
395
427
 
396
428
  if (exitCode !== undefined) {
429
+ let logText = "";
430
+ try {
431
+ logText = readFileSync(logPath, "utf-8");
432
+ } catch {
433
+ // Log file not readable — the summary falls back to none.
434
+ }
435
+ const reason = summarizeStartupFailure(logText);
397
436
  console.error(
398
- `Error: tunnel process exited during startup${exitCode !== null ? ` (exit code ${exitCode})` : ""}. Logs: ${logPath}`,
437
+ `Error (${exitCode ?? "unknown"}): tunnel process exited during startup${reason ? `: ${reason}` : "."}`,
399
438
  );
439
+ console.error(`Logs: ${logPath}`);
400
440
  process.exit(1);
401
441
  }
402
442
 
@@ -411,13 +451,41 @@ async function spawnDetachedTunnel(): Promise<void> {
411
451
  if (adopted) {
412
452
  console.log(adoptedTunnelStopNotice(child.pid));
413
453
  } else {
414
- console.log(`Stop with: kill ${child.pid}`);
454
+ console.log("Stop with: cadet tunnel stop");
415
455
  }
416
456
  }
417
457
 
418
458
  export const tunnel = Object.assign(
419
459
  async function tunnel(): Promise<void> {
420
- const { assistantName, provider, domain, clearDomain, foreground } =
460
+ if (process.argv[3] === "stop") {
461
+ const args = process.argv.slice(4);
462
+ if (args.includes("--help") || args.includes("-h")) {
463
+ console.log("Usage: cadet tunnel stop [<name>]");
464
+ console.log(
465
+ "Stop the recorded Cadet-managed tunnel, keeping nginx and the saved domain.",
466
+ );
467
+ return;
468
+ }
469
+ if (args.some((arg) => arg.startsWith("-")))
470
+ throw new Error("Usage: cadet tunnel stop [<name>]");
471
+ const { entry, workspaceDir } = resolveLocalTunnelTarget(
472
+ args.join(" ") || null,
473
+ );
474
+ if (!readRootProcesses().tunnel) {
475
+ console.log(
476
+ "No Cadet-managed tunnel is recorded. Externally started tunnels must be stopped directly.",
477
+ );
478
+ return;
479
+ }
480
+ const stopped = await stopTunnelProcess(entry.assistantId, workspaceDir);
481
+ console.log(
482
+ stopped
483
+ ? "Tunnel stopped. nginx and the saved domain are unchanged."
484
+ : "No running tunnel found; cleared stale tunnel state.",
485
+ );
486
+ return;
487
+ }
488
+ const { assistantName, provider, domain, clearDomain, foreground, shell } =
421
489
  parseArgs();
422
490
 
423
491
  if (!foreground) {
@@ -435,6 +503,15 @@ export const tunnel = Object.assign(
435
503
  const preserveIngressUrl =
436
504
  provider === "tailscale" && guardTailnetOnlyWebhookIngress(workspaceDir);
437
505
 
506
+ // Persist the shell mode like the reserved domain: parsed once, saved to
507
+ // the CLI lockfile beside the nginx edge record it configures, and read
508
+ // back by every later edge restore (wake/upgrade), so the choice outlives
509
+ // the command that made it.
510
+ if (shell) {
511
+ saveShellMode(shell);
512
+ console.log(`Shell mode saved: ${shell}.`);
513
+ }
514
+
438
515
  if (clearDomain) {
439
516
  clearReservedDomain();
440
517
  console.log("Cleared the saved reserved domain.");
@@ -475,7 +552,10 @@ export const tunnel = Object.assign(
475
552
  }
476
553
 
477
554
  console.log(
478
- `${edge.started ? "Started" : "Reusing"} the nginx edge on 127.0.0.1:${edge.port}.`,
555
+ `${edge.started ? "Started" : "Reusing"} the nginx edge on 127.0.0.1:${edge.port} (${formatEdgeMode(
556
+ edge.includesWebApp,
557
+ edge.shellMode,
558
+ )}).`,
479
559
  );
480
560
 
481
561
  const baseTunnelOpts = {
@@ -22,6 +22,7 @@ import {
22
22
  pullImage,
23
23
  startContainers,
24
24
  stopContainers,
25
+ sleepContainers,
25
26
  } from "../lib/docker";
26
27
  import {
27
28
  fetchLatestStableVersion,
@@ -67,6 +68,7 @@ import {
67
68
  stripVersionPrefix,
68
69
  versionsEqual,
69
70
  } from "../lib/version-compat.js";
71
+ import { fetchCurrentVersion } from "../lib/http-client.js";
70
72
  import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
71
73
  import {
72
74
  generateLocalSigningKey,
@@ -76,7 +78,10 @@ import {
76
78
  startLocalDaemon,
77
79
  stopLocalProcesses,
78
80
  } from "../lib/local.js";
79
- import { restoreTunnelEdgeAndAutoTunnel } from "../lib/tunnel.js";
81
+ import {
82
+ restoreContainerTunnelEdge,
83
+ restoreTunnelEdgeAndAutoTunnel,
84
+ } from "../lib/tunnel.js";
80
85
  import {
81
86
  leaseGuardianToken,
82
87
  resetGuardianBootstrap,
@@ -114,7 +119,7 @@ function parseArgs(): UpgradeArgs {
114
119
  console.log("Usage: cadet upgrade [<name>] [options]");
115
120
  console.log("");
116
121
  console.log("Upgrade an assistant to a newer version.");
117
- console.log("To roll back to a previous version, use `cadet rollback`.");
122
+ console.log("To roll back to a previous version, use `cadet restore` with a backup.");
118
123
  console.log("");
119
124
  console.log("Arguments:");
120
125
  console.log(
@@ -291,7 +296,7 @@ async function upgradeDocker(
291
296
  }
292
297
 
293
298
  // Reject downgrades — `cadet upgrade` only handles forward version changes.
294
- // Users should use `cadet rollback --version <version>` for downgrades.
299
+ // Downgrades require a versioned backup restore.
295
300
  if (!currentVersion && versionTag) {
296
301
  console.warn(
297
302
  "āš ļø Could not determine current version from health endpoint — skipping version-direction check.\n",
@@ -300,7 +305,7 @@ async function upgradeDocker(
300
305
  if (currentVersion && versionTag) {
301
306
  const cmp = compareVersions(versionTag, currentVersion);
302
307
  if (cmp !== null && cmp < 0) {
303
- const msg = `Cannot upgrade to an older version (${versionTag} < ${currentVersion}). Use \`cadet rollback --version ${versionTag}\` instead.`;
308
+ const msg = `Cannot upgrade to an older version (${versionTag} < ${currentVersion}). Use \`cadet restore --from <backup-path> --version ${versionTag}\` instead.`;
304
309
  console.error(msg);
305
310
  emitCliError("VERSION_DIRECTION", msg);
306
311
  process.exit(1);
@@ -342,22 +347,6 @@ async function upgradeDocker(
342
347
  );
343
348
  }
344
349
 
345
- // Persist rollback state to lockfile BEFORE any destructive changes.
346
- // This enables the `cadet rollback` command to restore the previous version.
347
- if (entry.containerInfo) {
348
- const rollbackEntry: AssistantEntry = {
349
- ...entry,
350
- previousContainerInfo: { ...entry.containerInfo },
351
- previousVersion: currentVersion,
352
- previousDbMigrationVersion: preMigrationState.dbVersion,
353
- previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
354
- };
355
- saveAssistantEntry(rollbackEntry);
356
- if (currentVersion) {
357
- console.log(` Saved rollback state: ${currentVersion}\n`);
358
- }
359
- }
360
-
361
350
  // Record version transition start in workspace git history
362
351
  await commitWorkspaceViaGateway(
363
352
  entry.runtimeUrl,
@@ -437,7 +426,7 @@ async function upgradeDocker(
437
426
  const assistantPort =
438
427
  entry.containerInfo?.assistantPort ?? ASSISTANT_INTERNAL_PORT;
439
428
 
440
- // Create pre-upgrade backup (best-effort, daemon must be running)
429
+ // Capture complete persistent roots before replacing any services.
441
430
  await broadcastUpgradeEvent(
442
431
  entry.runtimeUrl,
443
432
  entry.assistantId,
@@ -453,20 +442,7 @@ async function upgradeDocker(
453
442
  // Clean up old pre-upgrade backups, keep last 3
454
443
  pruneOldBackups(entry.assistantId, 3);
455
444
  } else {
456
- console.warn("āš ļø Pre-upgrade backup failed, continuing with upgrade\n");
457
- }
458
-
459
- // Persist the backup path so `cadet rollback` can restore the exact backup
460
- // created for this upgrade attempt — never a stale backup from a prior cycle.
461
- // Re-read the entry to pick up the rollback state saved earlier.
462
- {
463
- const current = findAssistantByName(entry.assistantId);
464
- if (current) {
465
- saveAssistantEntry({
466
- ...current,
467
- preUpgradeBackupPath: backupPath ?? undefined,
468
- });
469
- }
445
+ throw new Error("Pre-upgrade root backup failed; upgrade aborted before replacing services.");
470
446
  }
471
447
 
472
448
  await broadcastUpgradeEvent(
@@ -476,6 +452,7 @@ async function upgradeDocker(
476
452
  );
477
453
 
478
454
  console.log("šŸ›‘ Stopping existing containers...");
455
+ await sleepContainers(res);
479
456
  await stopContainers(res);
480
457
  console.log("āœ… Containers stopped\n");
481
458
 
@@ -509,13 +486,10 @@ async function upgradeDocker(
509
486
  cesImage: imageTags["credential-executor"],
510
487
  assistantPort,
511
488
  },
512
- previousContainerInfo: entry.containerInfo,
513
- previousDbMigrationVersion: preMigrationState.dbVersion,
514
- previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
515
- // Preserve the backup path so `cadet rollback` can restore it later
516
- preUpgradeBackupPath: backupPath ?? undefined,
517
489
  };
518
490
  saveAssistantEntry(updatedEntry);
491
+ // Refresh the host-served web bundle before clients reload on completion.
492
+ await restoreContainerTunnelEdge(updatedEntry);
519
493
 
520
494
  // Notify clients on the new service group that the upgrade succeeded.
521
495
  await broadcastUpgradeEvent(
@@ -581,6 +555,7 @@ async function upgradeDocker(
581
555
  );
582
556
  }
583
557
 
558
+ await sleepContainers(res);
584
559
  await stopContainers(res);
585
560
 
586
561
  await startContainers(
@@ -647,14 +622,12 @@ async function upgradeDocker(
647
622
  entry.runtimeUrl,
648
623
  entry.assistantId,
649
624
  backupPath,
650
- { kind: "docker", assistantId: entry.assistantId },
625
+ {},
651
626
  );
652
627
  if (restored) {
653
628
  console.log(" āœ… Data restored successfully\n");
654
629
  } else {
655
- console.warn(
656
- " āš ļø Data restore failed (rollback continues without data restoration)\n",
657
- );
630
+ throw new Error(`Root restore failed; rollback recovery is incomplete. Backup: ${backupPath}`);
658
631
  }
659
632
  } else {
660
633
  console.log(
@@ -678,11 +651,6 @@ async function upgradeDocker(
678
651
  previousImageRefs["credential-executor"],
679
652
  assistantPort,
680
653
  },
681
- previousContainerInfo: undefined,
682
- previousDbMigrationVersion: undefined,
683
- previousWorkspaceMigrationId: undefined,
684
- // Clear the backup path — the upgrade that created it just failed
685
- preUpgradeBackupPath: undefined,
686
654
  };
687
655
  saveAssistantEntry(rolledBackEntry);
688
656
 
@@ -810,40 +778,6 @@ async function ensureGuardianTokenAfterLocalUpgrade(
810
778
  }
811
779
  }
812
780
 
813
- async function fetchLocalUpgradeState(
814
- entry: AssistantEntry,
815
- adminUrl: string,
816
- ): Promise<{
817
- currentVersion?: string;
818
- preMigrationState: {
819
- dbVersion?: number;
820
- lastWorkspaceMigrationId?: string;
821
- };
822
- }> {
823
- try {
824
- const healthResp = await loopbackSafeFetch(
825
- `${adminUrl}/healthz?include=migrations`,
826
- {
827
- signal: AbortSignal.timeout(5000),
828
- },
829
- );
830
- if (healthResp.ok) {
831
- const health = (await healthResp.json()) as {
832
- version?: string;
833
- migrations?: { dbVersion?: number; lastWorkspaceMigrationId?: string };
834
- };
835
- return {
836
- currentVersion: health.version,
837
- preMigrationState: health.migrations ?? {},
838
- };
839
- }
840
- } catch {
841
- // Best-effort — sleeping assistants and unhealthy gateways can still be restarted.
842
- }
843
-
844
- return { preMigrationState: {} };
845
- }
846
-
847
781
  async function upgradeLocal(
848
782
  entry: AssistantEntry,
849
783
  version: string | null,
@@ -856,6 +790,7 @@ async function upgradeLocal(
856
790
  process.exit(1);
857
791
  }
858
792
 
793
+ const originalResources = { ...entry.resources };
859
794
  const signingKey = resolveExistingLocalSigningKey(entry.resources);
860
795
  if (entry.resources.signingKey !== signingKey) {
861
796
  entry.resources.signingKey = signingKey;
@@ -864,10 +799,7 @@ async function upgradeLocal(
864
799
 
865
800
  const targetVersion = await targetVersionFromCli(version);
866
801
  const adminUrl = localAdminUrl(entry);
867
- const { currentVersion, preMigrationState } = await fetchLocalUpgradeState(
868
- entry,
869
- adminUrl,
870
- );
802
+ const currentVersion = await fetchCurrentVersion(adminUrl);
871
803
 
872
804
  if (!currentVersion && targetVersion) {
873
805
  console.warn(
@@ -877,7 +809,7 @@ async function upgradeLocal(
877
809
  if (currentVersion && targetVersion) {
878
810
  const cmp = compareVersions(targetVersion, currentVersion);
879
811
  if (cmp !== null && cmp < 0) {
880
- const msg = `Cannot upgrade to an older version (${targetVersion} < ${currentVersion}). Use \`cadet rollback --version ${targetVersion}\` instead.`;
812
+ const msg = `Cannot upgrade to an older version (${targetVersion} < ${currentVersion}). Use \`cadet restore --from <backup-path> --version ${targetVersion}\` instead.`;
881
813
  console.error(msg);
882
814
  emitCliError("VERSION_DIRECTION", msg);
883
815
  process.exit(1);
@@ -933,7 +865,7 @@ async function upgradeLocal(
933
865
  console.log(` Backup saved: ${backupPath}\n`);
934
866
  pruneOldBackups(entry.assistantId, 3);
935
867
  } else {
936
- console.warn("āš ļø Pre-upgrade backup failed, continuing with upgrade\n");
868
+ throw new Error("Pre-upgrade root backup failed; upgrade aborted before replacing services.");
937
869
  }
938
870
 
939
871
  let bootstrapSecret = entry.guardianBootstrapSecret;
@@ -944,20 +876,8 @@ async function upgradeLocal(
944
876
  entryChanged = true;
945
877
  }
946
878
 
947
- if (
948
- entryChanged ||
949
- backupPath ||
950
- currentVersion ||
951
- preMigrationState.dbVersion !== undefined ||
952
- preMigrationState.lastWorkspaceMigrationId !== undefined
953
- ) {
954
- saveAssistantEntry({
955
- ...entry,
956
- previousVersion: currentVersion,
957
- previousDbMigrationVersion: preMigrationState.dbVersion,
958
- previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
959
- preUpgradeBackupPath: backupPath ?? undefined,
960
- });
879
+ if (entryChanged) {
880
+ saveAssistantEntry(entry);
961
881
  }
962
882
 
963
883
  await broadcastUpgradeEvent(
@@ -977,64 +897,69 @@ async function upgradeLocal(
977
897
  runtimeVersion: runtimeInstall.version,
978
898
  runtimeInstallDir: runtimeInstall.installDir,
979
899
  };
980
- saveAssistantEntry({
981
- ...entry,
982
- previousVersion: currentVersion,
983
- previousDbMigrationVersion: preMigrationState.dbVersion,
984
- previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
985
- preUpgradeBackupPath: backupPath ?? undefined,
986
- });
900
+ saveAssistantEntry(entry);
987
901
  console.log(` Runtime installed: ${runtimeInstall.installDir}\n`);
988
902
 
989
- console.log("šŸ›‘ Stopping local assistant processes...");
990
- await stopLocalProcesses(entry.resources);
991
- console.log("āœ… Local assistant processes stopped\n");
992
-
993
- console.log("šŸš€ Starting upgraded local assistant...");
994
- const previousAppVersion = process.env.APP_VERSION;
995
- process.env.APP_VERSION = stripVersionPrefix(targetVersion);
996
903
  try {
997
- // Bring CES, daemon, and gateway up in parallel, the way the Docker
998
- // topology starts its sibling processes together. startCes always
999
- // launches the CES sibling.
1000
- await Promise.all([
1001
- startCes(false, entry.resources),
1002
- startLocalDaemon(false, entry.resources, { signingKey }),
1003
- startGateway(false, entry.resources, { signingKey, bootstrapSecret }),
1004
- ]);
1005
- } finally {
1006
- if (previousAppVersion === undefined) {
1007
- delete process.env.APP_VERSION;
1008
- } else {
1009
- process.env.APP_VERSION = previousAppVersion;
1010
- }
1011
- }
1012
- await ensureGuardianTokenAfterLocalUpgrade(
1013
- entry,
1014
- entry.resources.gatewayPort,
1015
- bootstrapSecret,
1016
- );
1017
- console.log("āœ… Local assistant processes started\n");
904
+ console.log("šŸ›‘ Stopping local assistant processes...");
905
+ await stopLocalProcesses(entry.resources);
906
+ console.log("āœ… Local assistant processes stopped\n");
1018
907
 
1019
- const workspaceDir = join(entry.resources.instanceDir, "workspace");
1020
- await restoreTunnelEdgeAndAutoTunnel(
1021
- entry.assistantId,
1022
- entry.resources.gatewayPort,
1023
- workspaceDir,
1024
- );
908
+ console.log("šŸš€ Starting upgraded local assistant...");
909
+ const previousAppVersion = process.env.APP_VERSION;
910
+ process.env.APP_VERSION = stripVersionPrefix(targetVersion);
911
+ try {
912
+ // Bring CES, daemon, and gateway up in parallel, the way the Docker
913
+ // topology starts its sibling processes together. startCes always
914
+ // launches the CES sibling.
915
+ await Promise.all([
916
+ startCes(false, entry.resources),
917
+ startLocalDaemon(false, entry.resources, { signingKey }),
918
+ startGateway(false, entry.resources, { signingKey, bootstrapSecret }),
919
+ ]);
920
+ } finally {
921
+ if (previousAppVersion === undefined) {
922
+ delete process.env.APP_VERSION;
923
+ } else {
924
+ process.env.APP_VERSION = previousAppVersion;
925
+ }
926
+ }
927
+ await ensureGuardianTokenAfterLocalUpgrade(
928
+ entry,
929
+ entry.resources.gatewayPort,
930
+ bootstrapSecret,
931
+ );
932
+ console.log("āœ… Local assistant processes started\n");
1025
933
 
1026
- console.log("Waiting for assistant to become ready...");
1027
- const ready = await waitForReady(adminUrl);
1028
- if (!ready) {
1029
- await broadcastUpgradeEvent(
1030
- adminUrl,
934
+ const workspaceDir = join(entry.resources.instanceDir, "workspace");
935
+ await restoreTunnelEdgeAndAutoTunnel(
1031
936
  entry.assistantId,
1032
- buildCompleteEvent(currentVersion ?? "unknown", false),
937
+ entry.resources.gatewayPort,
938
+ workspaceDir,
1033
939
  );
1034
- const msg = `Upgrade to ${targetVersion} failed: local assistant did not become ready.`;
1035
- console.error(`\nāŒ ${msg}`);
1036
- emitCliError("READINESS_TIMEOUT", msg);
1037
- process.exit(1);
940
+
941
+ console.log("Waiting for assistant to become ready...");
942
+ const ready = await waitForReady(adminUrl);
943
+ if (!ready) {
944
+ await broadcastUpgradeEvent(
945
+ adminUrl,
946
+ entry.assistantId,
947
+ buildCompleteEvent(currentVersion ?? "unknown", false),
948
+ );
949
+ const msg = `Upgrade to ${targetVersion} failed: local assistant did not become ready.`;
950
+ console.error(`\nāŒ ${msg}`);
951
+ throw new Error(msg);
952
+ }
953
+ } catch (error) {
954
+ console.error("Local upgrade failed; restoring the previous runtime and service roots...");
955
+ await stopLocalProcesses(entry.resources);
956
+ entry.resources = originalResources;
957
+ saveAssistantEntry(entry);
958
+ const restored = await restoreBackup(adminUrl, entry.assistantId, backupPath, { resume: false });
959
+ if (!restored) throw new Error(`Local recovery failed; services remain stopped. Backup: ${backupPath}`, { cause: error });
960
+ await Promise.all([startCes(false, entry.resources), startLocalDaemon(false, entry.resources, { signingKey }), startGateway(false, entry.resources, { signingKey, bootstrapSecret })]);
961
+ if (!await waitForReady(adminUrl)) throw new Error(`Previous local runtime did not recover. Backup: ${backupPath}`, { cause: error });
962
+ throw error;
1038
963
  }
1039
964
 
1040
965
  await broadcastUpgradeEvent(
@@ -1223,7 +1148,7 @@ async function upgradePlatform(
1223
1148
  }
1224
1149
 
1225
1150
  if (resolution.isDowngrade && resolution.target && currentVersion) {
1226
- const msg = `Cannot upgrade to an older version (${resolution.target} < ${currentVersion}). Use \`cadet rollback --version ${resolution.target}\` instead.`;
1151
+ const msg = `Cannot upgrade to an older version (${resolution.target} < ${currentVersion}). Use \`cadet restore --from <backup-path> --version ${resolution.target}\` instead.`;
1227
1152
  console.error(msg);
1228
1153
  emitCliError("VERSION_DIRECTION", msg);
1229
1154
  process.exit(1);
@@ -1384,6 +1309,8 @@ async function upgradePrepare(
1384
1309
  description: `Pre-upgrade snapshot before ${currentVersion} → ${targetVersion}`,
1385
1310
  });
1386
1311
 
1312
+ if (!backupPath) throw new Error("Pre-upgrade root backup failed; update preparation aborted.");
1313
+
1387
1314
  // 5. Prune old backups (keep 3)
1388
1315
  if (backupPath) {
1389
1316
  pruneOldBackups(entry.assistantId, 3);
@@ -1455,11 +1382,11 @@ async function resolveLatestStableTag(): Promise<string> {
1455
1382
  const latestVersion = await fetchLatestStableVersion();
1456
1383
  if (!latestVersion) {
1457
1384
  console.error(
1458
- "Error: Could not determine the latest stable release from the Cadet Cloud API.",
1385
+ "Error: Could not determine the latest stable release from Cadet Cloud or GitHub Releases.",
1459
1386
  );
1460
1387
  emitCliError(
1461
1388
  "UNKNOWN",
1462
- "Could not determine the latest stable release from the cloud API",
1389
+ "Could not determine the latest stable release from Cadet Cloud or GitHub Releases",
1463
1390
  );
1464
1391
  process.exit(1);
1465
1392
  }
@@ -201,7 +201,7 @@ class WakeExit extends Error {}
201
201
  * refusals so the caller decides between exiting (single path) and
202
202
  * continuing to the next entry (`--all`).
203
203
  */
204
- async function wakeAssistant(
204
+ export async function wakeAssistant(
205
205
  entry: AssistantEntry,
206
206
  opts: WakeAssistantOptions,
207
207
  ): Promise<void> {
package/src/index.ts CHANGED
@@ -31,7 +31,6 @@ import { ps } from "./commands/ps";
31
31
  import { restore } from "./commands/restore";
32
32
  import { retire } from "./commands/retire";
33
33
  import { restart } from "./commands/restart";
34
- import { rollback } from "./commands/rollback";
35
34
  import { setup } from "./commands/setup";
36
35
  import { sleep } from "./commands/sleep";
37
36
  import { debug } from "./commands/debug";
@@ -39,7 +38,6 @@ import { teleport } from "./commands/teleport";
39
38
  import { terminal } from "./commands/terminal";
40
39
  import { tunnel } from "./commands/tunnel";
41
40
  import { uninstall } from "./commands/uninstall";
42
- import { unpair } from "./commands/unpair";
43
41
  import { upgrade } from "./commands/upgrade";
44
42
  import { use } from "./commands/use";
45
43
  import { voice } from "./commands/voice";
@@ -73,14 +71,12 @@ const commands = {
73
71
  restore,
74
72
  retire,
75
73
  restart,
76
- rollback,
77
74
  setup,
78
75
  sleep,
79
76
  teleport,
80
77
  terminal,
81
78
  tunnel,
82
79
  uninstall,
83
- unpair,
84
80
  upgrade,
85
81
  use,
86
82
  voice,