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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +13 -0
  2. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  3. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
  4. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  5. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  6. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
  7. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  8. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  9. package/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
  10. package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  11. package/package.json +8 -3
  12. package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
  13. package/src/__tests__/backup-ops-matching.test.ts +15 -13
  14. package/src/__tests__/backup-ops.test.ts +105 -0
  15. package/src/__tests__/backup.test.ts +114 -673
  16. package/src/__tests__/cloud-releases.test.ts +82 -0
  17. package/src/__tests__/commands-mock.ts +101 -0
  18. package/src/__tests__/debug.test.ts +17 -91
  19. package/src/__tests__/devices.test.ts +2 -2
  20. package/src/__tests__/gateway-token.test.ts +267 -0
  21. package/src/__tests__/host-teleport-docker.test.ts +371 -0
  22. package/src/__tests__/host-teleport.test.ts +405 -0
  23. package/src/__tests__/nginx-ingress.test.ts +536 -23
  24. package/src/__tests__/orphan-detection.test.ts +1 -1
  25. package/src/__tests__/paired-lifecycle.test.ts +2 -2
  26. package/src/__tests__/ps-cloud-status.test.ts +6 -6
  27. package/src/__tests__/root-backup-docker.test.ts +294 -0
  28. package/src/__tests__/root-backup.test.ts +527 -0
  29. package/src/__tests__/root-restore-command.test.ts +147 -0
  30. package/src/__tests__/root-signing-key.test.ts +50 -0
  31. package/src/__tests__/setup.test.ts +3 -3
  32. package/src/__tests__/shell-mode.test.ts +83 -0
  33. package/src/__tests__/sleep.test.ts +11 -0
  34. package/src/__tests__/teleport.test.ts +68 -2996
  35. package/src/__tests__/tunnel.test.ts +10 -2
  36. package/src/__tests__/upgrade-local.test.ts +21 -11
  37. package/src/__tests__/upgrade-replay-env.test.ts +2 -2
  38. package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
  39. package/src/__tests__/wake.test.ts +4 -1
  40. package/src/__tests__/windows-lifecycle.test.ts +25 -0
  41. package/src/commands/backup.ts +44 -399
  42. package/src/commands/client.ts +3 -3
  43. package/src/commands/completions.ts +0 -2
  44. package/src/commands/debug.ts +75 -131
  45. package/src/commands/devices.ts +51 -17
  46. package/src/commands/gateway/token.ts +77 -23
  47. package/src/commands/pair.ts +2 -2
  48. package/src/commands/restore.ts +77 -444
  49. package/src/commands/retire.ts +2 -3
  50. package/src/commands/sleep.ts +15 -2
  51. package/src/commands/teleport.ts +47 -1559
  52. package/src/commands/tunnel.ts +48 -7
  53. package/src/commands/upgrade.ts +78 -156
  54. package/src/commands/wake.ts +1 -1
  55. package/src/index.ts +0 -4
  56. package/src/lib/__tests__/http-client-version.test.ts +29 -0
  57. package/src/lib/__tests__/runtime-url.test.ts +3 -75
  58. package/src/lib/__tests__/web-dist.test.ts +27 -1
  59. package/src/lib/assistant-client.ts +1 -1
  60. package/src/lib/assistant-config.ts +23 -18
  61. package/src/lib/backup-ops.ts +37 -251
  62. package/src/lib/cloud-client.ts +10 -13
  63. package/src/lib/cloud-login.ts +2 -1
  64. package/src/lib/cloud-releases.ts +54 -10
  65. package/src/lib/cloud-values.ts +1 -1
  66. package/src/lib/confirm-action.ts +2 -2
  67. package/src/lib/docker.ts +59 -22
  68. package/src/lib/github-repository.ts +2 -0
  69. package/src/lib/guardian-token.ts +1 -23
  70. package/src/lib/hatch-local.ts +17 -1
  71. package/src/lib/host-teleport.ts +506 -0
  72. package/src/lib/http-client.ts +25 -0
  73. package/src/lib/ingress-config.ts +31 -0
  74. package/src/lib/live-voice/tunnel.test.ts +1 -1
  75. package/src/lib/live-voice/tunnel.ts +1 -1
  76. package/src/lib/local.ts +4 -2
  77. package/src/lib/nginx-ingress.ts +454 -79
  78. package/src/lib/process.ts +13 -0
  79. package/src/lib/root-backup.ts +917 -0
  80. package/src/lib/root-signing-key.ts +31 -0
  81. package/src/lib/runtime-url.ts +0 -29
  82. package/src/lib/teleport-backup.ts +3 -16
  83. package/src/lib/tunnel.ts +1 -0
  84. package/src/lib/upgrade-lifecycle.ts +63 -141
  85. package/src/lib/web-dist.ts +79 -1
  86. package/src/__tests__/bundle-staging.test.ts +0 -268
  87. package/src/__tests__/unpair.test.ts +0 -160
  88. package/src/commands/rollback.ts +0 -479
  89. package/src/commands/unpair.ts +0 -122
  90. package/src/lib/__tests__/job-polling.test.ts +0 -278
  91. package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
  92. package/src/lib/bundle-staging.ts +0 -220
  93. package/src/lib/job-polling.ts +0 -195
  94. package/src/lib/local-runtime-client.ts +0 -374
@@ -20,9 +20,11 @@ import {
20
20
  getDefaultWorkspaceDir,
21
21
  isLocalContainerEntry,
22
22
  parseGatewayPortFromEntryUrls,
23
+ saveShellMode,
23
24
  } from "../lib/ingress-config.js";
24
25
  import {
25
26
  ensureTunnelEdge,
27
+ formatEdgeMode,
26
28
  getNginxVersion,
27
29
  type TunnelEdge,
28
30
  } from "../lib/nginx-ingress.js";
@@ -51,9 +53,10 @@ interface TunnelArgs {
51
53
  domain: string | null;
52
54
  clearDomain: boolean;
53
55
  foreground: boolean;
56
+ shell: "web" | "app" | null;
54
57
  }
55
58
 
56
- const FLAGS_WITH_VALUES = ["--provider", "--domain"] as const;
59
+ const FLAGS_WITH_VALUES = ["--provider", "--domain", "--shell"] as const;
57
60
 
58
61
  function parseArgs(): TunnelArgs {
59
62
  const args = process.argv.slice(3);
@@ -61,6 +64,7 @@ function parseArgs(): TunnelArgs {
61
64
  let domain: string | null = null;
62
65
  let clearDomain = false;
63
66
  let foreground = false;
67
+ let shell: "web" | "app" | null = null;
64
68
 
65
69
  for (let i = 0; i < args.length; i++) {
66
70
  const arg = args[i];
@@ -70,9 +74,7 @@ function parseArgs(): TunnelArgs {
70
74
  );
71
75
  console.log("");
72
76
  console.log("Front a locally running assistant with an HTTPS tunnel.");
73
- console.log(
74
- "The tunnel URL is saved to the workspace config as the ingress base URL.",
75
- );
77
+ console.log("The tunnel URL is saved to the workspace config as the ingress base URL.");
76
78
  console.log("");
77
79
  console.log(
78
80
  "How far that URL reaches depends on the provider; omit --provider for",
@@ -114,6 +116,19 @@ function parseArgs(): TunnelArgs {
114
116
  console.log(
115
117
  " tunnel without one. Cannot be combined with --domain.",
116
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.");
117
132
  console.log(
118
133
  ` --foreground Stream the tunnel's logs to this terminal. By default the tunnel`,
119
134
  );
@@ -191,6 +206,20 @@ function parseArgs(): TunnelArgs {
191
206
  i++;
192
207
  } else if (arg === "--clear-domain") {
193
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++;
194
223
  } else if (arg === "--foreground") {
195
224
  foreground = true;
196
225
  } else if (arg.startsWith("-")) {
@@ -230,7 +259,7 @@ function parseArgs(): TunnelArgs {
230
259
  process.exit(1);
231
260
  }
232
261
 
233
- return { assistantName, provider, domain, clearDomain, foreground };
262
+ return { assistantName, provider, domain, clearDomain, foreground, shell };
234
263
  }
235
264
 
236
265
  /** A tunnelable assistant plus the gateway port and workspace the edge fronts. */
@@ -456,7 +485,7 @@ export const tunnel = Object.assign(
456
485
  );
457
486
  return;
458
487
  }
459
- const { assistantName, provider, domain, clearDomain, foreground } =
488
+ const { assistantName, provider, domain, clearDomain, foreground, shell } =
460
489
  parseArgs();
461
490
 
462
491
  if (!foreground) {
@@ -474,6 +503,15 @@ export const tunnel = Object.assign(
474
503
  const preserveIngressUrl =
475
504
  provider === "tailscale" && guardTailnetOnlyWebhookIngress(workspaceDir);
476
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
+
477
515
  if (clearDomain) {
478
516
  clearReservedDomain();
479
517
  console.log("Cleared the saved reserved domain.");
@@ -514,7 +552,10 @@ export const tunnel = Object.assign(
514
552
  }
515
553
 
516
554
  console.log(
517
- `${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
+ )}).`,
518
559
  );
519
560
 
520
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,
@@ -117,7 +119,7 @@ function parseArgs(): UpgradeArgs {
117
119
  console.log("Usage: cadet upgrade [<name>] [options]");
118
120
  console.log("");
119
121
  console.log("Upgrade an assistant to a newer version.");
120
- 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.");
121
123
  console.log("");
122
124
  console.log("Arguments:");
123
125
  console.log(
@@ -294,7 +296,7 @@ async function upgradeDocker(
294
296
  }
295
297
 
296
298
  // Reject downgrades — `cadet upgrade` only handles forward version changes.
297
- // Users should use `cadet rollback --version <version>` for downgrades.
299
+ // Downgrades require a versioned backup restore.
298
300
  if (!currentVersion && versionTag) {
299
301
  console.warn(
300
302
  "⚠️ Could not determine current version from health endpoint — skipping version-direction check.\n",
@@ -303,7 +305,7 @@ async function upgradeDocker(
303
305
  if (currentVersion && versionTag) {
304
306
  const cmp = compareVersions(versionTag, currentVersion);
305
307
  if (cmp !== null && cmp < 0) {
306
- 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.`;
307
309
  console.error(msg);
308
310
  emitCliError("VERSION_DIRECTION", msg);
309
311
  process.exit(1);
@@ -345,22 +347,6 @@ async function upgradeDocker(
345
347
  );
346
348
  }
347
349
 
348
- // Persist rollback state to lockfile BEFORE any destructive changes.
349
- // This enables the `cadet rollback` command to restore the previous version.
350
- if (entry.containerInfo) {
351
- const rollbackEntry: AssistantEntry = {
352
- ...entry,
353
- previousContainerInfo: { ...entry.containerInfo },
354
- previousVersion: currentVersion,
355
- previousDbMigrationVersion: preMigrationState.dbVersion,
356
- previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
357
- };
358
- saveAssistantEntry(rollbackEntry);
359
- if (currentVersion) {
360
- console.log(` Saved rollback state: ${currentVersion}\n`);
361
- }
362
- }
363
-
364
350
  // Record version transition start in workspace git history
365
351
  await commitWorkspaceViaGateway(
366
352
  entry.runtimeUrl,
@@ -440,7 +426,7 @@ async function upgradeDocker(
440
426
  const assistantPort =
441
427
  entry.containerInfo?.assistantPort ?? ASSISTANT_INTERNAL_PORT;
442
428
 
443
- // Create pre-upgrade backup (best-effort, daemon must be running)
429
+ // Capture complete persistent roots before replacing any services.
444
430
  await broadcastUpgradeEvent(
445
431
  entry.runtimeUrl,
446
432
  entry.assistantId,
@@ -456,20 +442,7 @@ async function upgradeDocker(
456
442
  // Clean up old pre-upgrade backups, keep last 3
457
443
  pruneOldBackups(entry.assistantId, 3);
458
444
  } else {
459
- console.warn("⚠️ Pre-upgrade backup failed, continuing with upgrade\n");
460
- }
461
-
462
- // Persist the backup path so `cadet rollback` can restore the exact backup
463
- // created for this upgrade attempt — never a stale backup from a prior cycle.
464
- // Re-read the entry to pick up the rollback state saved earlier.
465
- {
466
- const current = findAssistantByName(entry.assistantId);
467
- if (current) {
468
- saveAssistantEntry({
469
- ...current,
470
- preUpgradeBackupPath: backupPath ?? undefined,
471
- });
472
- }
445
+ throw new Error("Pre-upgrade root backup failed; upgrade aborted before replacing services.");
473
446
  }
474
447
 
475
448
  await broadcastUpgradeEvent(
@@ -479,6 +452,7 @@ async function upgradeDocker(
479
452
  );
480
453
 
481
454
  console.log("🛑 Stopping existing containers...");
455
+ await sleepContainers(res);
482
456
  await stopContainers(res);
483
457
  console.log("✅ Containers stopped\n");
484
458
 
@@ -512,11 +486,6 @@ async function upgradeDocker(
512
486
  cesImage: imageTags["credential-executor"],
513
487
  assistantPort,
514
488
  },
515
- previousContainerInfo: entry.containerInfo,
516
- previousDbMigrationVersion: preMigrationState.dbVersion,
517
- previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
518
- // Preserve the backup path so `cadet rollback` can restore it later
519
- preUpgradeBackupPath: backupPath ?? undefined,
520
489
  };
521
490
  saveAssistantEntry(updatedEntry);
522
491
  // Refresh the host-served web bundle before clients reload on completion.
@@ -586,6 +555,7 @@ async function upgradeDocker(
586
555
  );
587
556
  }
588
557
 
558
+ await sleepContainers(res);
589
559
  await stopContainers(res);
590
560
 
591
561
  await startContainers(
@@ -652,14 +622,12 @@ async function upgradeDocker(
652
622
  entry.runtimeUrl,
653
623
  entry.assistantId,
654
624
  backupPath,
655
- { kind: "docker", assistantId: entry.assistantId },
625
+ {},
656
626
  );
657
627
  if (restored) {
658
628
  console.log(" ✅ Data restored successfully\n");
659
629
  } else {
660
- console.warn(
661
- " ⚠️ Data restore failed (rollback continues without data restoration)\n",
662
- );
630
+ throw new Error(`Root restore failed; rollback recovery is incomplete. Backup: ${backupPath}`);
663
631
  }
664
632
  } else {
665
633
  console.log(
@@ -683,11 +651,6 @@ async function upgradeDocker(
683
651
  previousImageRefs["credential-executor"],
684
652
  assistantPort,
685
653
  },
686
- previousContainerInfo: undefined,
687
- previousDbMigrationVersion: undefined,
688
- previousWorkspaceMigrationId: undefined,
689
- // Clear the backup path — the upgrade that created it just failed
690
- preUpgradeBackupPath: undefined,
691
654
  };
692
655
  saveAssistantEntry(rolledBackEntry);
693
656
 
@@ -815,40 +778,6 @@ async function ensureGuardianTokenAfterLocalUpgrade(
815
778
  }
816
779
  }
817
780
 
818
- async function fetchLocalUpgradeState(
819
- entry: AssistantEntry,
820
- adminUrl: string,
821
- ): Promise<{
822
- currentVersion?: string;
823
- preMigrationState: {
824
- dbVersion?: number;
825
- lastWorkspaceMigrationId?: string;
826
- };
827
- }> {
828
- try {
829
- const healthResp = await loopbackSafeFetch(
830
- `${adminUrl}/healthz?include=migrations`,
831
- {
832
- signal: AbortSignal.timeout(5000),
833
- },
834
- );
835
- if (healthResp.ok) {
836
- const health = (await healthResp.json()) as {
837
- version?: string;
838
- migrations?: { dbVersion?: number; lastWorkspaceMigrationId?: string };
839
- };
840
- return {
841
- currentVersion: health.version,
842
- preMigrationState: health.migrations ?? {},
843
- };
844
- }
845
- } catch {
846
- // Best-effort — sleeping assistants and unhealthy gateways can still be restarted.
847
- }
848
-
849
- return { preMigrationState: {} };
850
- }
851
-
852
781
  async function upgradeLocal(
853
782
  entry: AssistantEntry,
854
783
  version: string | null,
@@ -861,6 +790,7 @@ async function upgradeLocal(
861
790
  process.exit(1);
862
791
  }
863
792
 
793
+ const originalResources = { ...entry.resources };
864
794
  const signingKey = resolveExistingLocalSigningKey(entry.resources);
865
795
  if (entry.resources.signingKey !== signingKey) {
866
796
  entry.resources.signingKey = signingKey;
@@ -869,10 +799,7 @@ async function upgradeLocal(
869
799
 
870
800
  const targetVersion = await targetVersionFromCli(version);
871
801
  const adminUrl = localAdminUrl(entry);
872
- const { currentVersion, preMigrationState } = await fetchLocalUpgradeState(
873
- entry,
874
- adminUrl,
875
- );
802
+ const currentVersion = await fetchCurrentVersion(adminUrl);
876
803
 
877
804
  if (!currentVersion && targetVersion) {
878
805
  console.warn(
@@ -882,7 +809,7 @@ async function upgradeLocal(
882
809
  if (currentVersion && targetVersion) {
883
810
  const cmp = compareVersions(targetVersion, currentVersion);
884
811
  if (cmp !== null && cmp < 0) {
885
- 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.`;
886
813
  console.error(msg);
887
814
  emitCliError("VERSION_DIRECTION", msg);
888
815
  process.exit(1);
@@ -938,7 +865,7 @@ async function upgradeLocal(
938
865
  console.log(` Backup saved: ${backupPath}\n`);
939
866
  pruneOldBackups(entry.assistantId, 3);
940
867
  } else {
941
- console.warn("⚠️ Pre-upgrade backup failed, continuing with upgrade\n");
868
+ throw new Error("Pre-upgrade root backup failed; upgrade aborted before replacing services.");
942
869
  }
943
870
 
944
871
  let bootstrapSecret = entry.guardianBootstrapSecret;
@@ -949,20 +876,8 @@ async function upgradeLocal(
949
876
  entryChanged = true;
950
877
  }
951
878
 
952
- if (
953
- entryChanged ||
954
- backupPath ||
955
- currentVersion ||
956
- preMigrationState.dbVersion !== undefined ||
957
- preMigrationState.lastWorkspaceMigrationId !== undefined
958
- ) {
959
- saveAssistantEntry({
960
- ...entry,
961
- previousVersion: currentVersion,
962
- previousDbMigrationVersion: preMigrationState.dbVersion,
963
- previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
964
- preUpgradeBackupPath: backupPath ?? undefined,
965
- });
879
+ if (entryChanged) {
880
+ saveAssistantEntry(entry);
966
881
  }
967
882
 
968
883
  await broadcastUpgradeEvent(
@@ -982,64 +897,69 @@ async function upgradeLocal(
982
897
  runtimeVersion: runtimeInstall.version,
983
898
  runtimeInstallDir: runtimeInstall.installDir,
984
899
  };
985
- saveAssistantEntry({
986
- ...entry,
987
- previousVersion: currentVersion,
988
- previousDbMigrationVersion: preMigrationState.dbVersion,
989
- previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
990
- preUpgradeBackupPath: backupPath ?? undefined,
991
- });
900
+ saveAssistantEntry(entry);
992
901
  console.log(` Runtime installed: ${runtimeInstall.installDir}\n`);
993
902
 
994
- console.log("🛑 Stopping local assistant processes...");
995
- await stopLocalProcesses(entry.resources);
996
- console.log("✅ Local assistant processes stopped\n");
997
-
998
- console.log("🚀 Starting upgraded local assistant...");
999
- const previousAppVersion = process.env.APP_VERSION;
1000
- process.env.APP_VERSION = stripVersionPrefix(targetVersion);
1001
903
  try {
1002
- // Bring CES, daemon, and gateway up in parallel, the way the Docker
1003
- // topology starts its sibling processes together. startCes always
1004
- // launches the CES sibling.
1005
- await Promise.all([
1006
- startCes(false, entry.resources),
1007
- startLocalDaemon(false, entry.resources, { signingKey }),
1008
- startGateway(false, entry.resources, { signingKey, bootstrapSecret }),
1009
- ]);
1010
- } finally {
1011
- if (previousAppVersion === undefined) {
1012
- delete process.env.APP_VERSION;
1013
- } else {
1014
- process.env.APP_VERSION = previousAppVersion;
1015
- }
1016
- }
1017
- await ensureGuardianTokenAfterLocalUpgrade(
1018
- entry,
1019
- entry.resources.gatewayPort,
1020
- bootstrapSecret,
1021
- );
1022
- 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");
1023
907
 
1024
- const workspaceDir = join(entry.resources.instanceDir, "workspace");
1025
- await restoreTunnelEdgeAndAutoTunnel(
1026
- entry.assistantId,
1027
- entry.resources.gatewayPort,
1028
- workspaceDir,
1029
- );
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");
1030
933
 
1031
- console.log("Waiting for assistant to become ready...");
1032
- const ready = await waitForReady(adminUrl);
1033
- if (!ready) {
1034
- await broadcastUpgradeEvent(
1035
- adminUrl,
934
+ const workspaceDir = join(entry.resources.instanceDir, "workspace");
935
+ await restoreTunnelEdgeAndAutoTunnel(
1036
936
  entry.assistantId,
1037
- buildCompleteEvent(currentVersion ?? "unknown", false),
937
+ entry.resources.gatewayPort,
938
+ workspaceDir,
1038
939
  );
1039
- const msg = `Upgrade to ${targetVersion} failed: local assistant did not become ready.`;
1040
- console.error(`\n❌ ${msg}`);
1041
- emitCliError("READINESS_TIMEOUT", msg);
1042
- 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;
1043
963
  }
1044
964
 
1045
965
  await broadcastUpgradeEvent(
@@ -1228,7 +1148,7 @@ async function upgradePlatform(
1228
1148
  }
1229
1149
 
1230
1150
  if (resolution.isDowngrade && resolution.target && currentVersion) {
1231
- 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.`;
1232
1152
  console.error(msg);
1233
1153
  emitCliError("VERSION_DIRECTION", msg);
1234
1154
  process.exit(1);
@@ -1389,6 +1309,8 @@ async function upgradePrepare(
1389
1309
  description: `Pre-upgrade snapshot before ${currentVersion} → ${targetVersion}`,
1390
1310
  });
1391
1311
 
1312
+ if (!backupPath) throw new Error("Pre-upgrade root backup failed; update preparation aborted.");
1313
+
1392
1314
  // 5. Prune old backups (keep 3)
1393
1315
  if (backupPath) {
1394
1316
  pruneOldBackups(entry.assistantId, 3);
@@ -1460,11 +1382,11 @@ async function resolveLatestStableTag(): Promise<string> {
1460
1382
  const latestVersion = await fetchLatestStableVersion();
1461
1383
  if (!latestVersion) {
1462
1384
  console.error(
1463
- "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.",
1464
1386
  );
1465
1387
  emitCliError(
1466
1388
  "UNKNOWN",
1467
- "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",
1468
1390
  );
1469
1391
  process.exit(1);
1470
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,
@@ -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
+ });