@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
@@ -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";
@@ -37,6 +38,8 @@ import {
37
38
  hasWebhookIntegrationsConfigured,
38
39
  providerSupportsReservedDomain,
39
40
  runProviderTunnel,
41
+ stopTunnelProcess,
42
+ summarizeStartupFailure,
40
43
  } from "../lib/tunnel.js";
41
44
 
42
45
  // The accepted `--provider` values ARE the shared registry, so validation
@@ -63,7 +66,7 @@ function parseArgs(): TunnelArgs {
63
66
  const arg = args[i];
64
67
  if (arg === "--help" || arg === "-h") {
65
68
  console.log(
66
- "Usage: cadet tunnel [<name>] --provider <provider> [options]",
69
+ "Usage: cadet tunnel [<name>] --provider <provider> [options]\n cadet tunnel stop [<name>]",
67
70
  );
68
71
  console.log("");
69
72
  console.log("Front a locally running assistant with an HTTPS tunnel.");
@@ -121,7 +124,7 @@ function parseArgs(): TunnelArgs {
121
124
  ` URL, and logs stream to tunnel.log in the CLI log directory. A background`,
122
125
  );
123
126
  console.log(
124
- " tunnel stops with `kill <pid>`, except when it adopted an already-running",
127
+ " tunnel stops with `cadet tunnel stop`, except when it adopted an already-running",
125
128
  );
126
129
  console.log(
127
130
  " tunnel (started outside this command): killing the supervisor then",
@@ -394,9 +397,17 @@ async function spawnDetachedTunnel(): Promise<void> {
394
397
  });
395
398
 
396
399
  if (exitCode !== undefined) {
400
+ let logText = "";
401
+ try {
402
+ logText = readFileSync(logPath, "utf-8");
403
+ } catch {
404
+ // Log file not readable — the summary falls back to none.
405
+ }
406
+ const reason = summarizeStartupFailure(logText);
397
407
  console.error(
398
- `Error: tunnel process exited during startup${exitCode !== null ? ` (exit code ${exitCode})` : ""}. Logs: ${logPath}`,
408
+ `Error (${exitCode ?? "unknown"}): tunnel process exited during startup${reason ? `: ${reason}` : "."}`,
399
409
  );
410
+ console.error(`Logs: ${logPath}`);
400
411
  process.exit(1);
401
412
  }
402
413
 
@@ -411,12 +422,40 @@ async function spawnDetachedTunnel(): Promise<void> {
411
422
  if (adopted) {
412
423
  console.log(adoptedTunnelStopNotice(child.pid));
413
424
  } else {
414
- console.log(`Stop with: kill ${child.pid}`);
425
+ console.log("Stop with: cadet tunnel stop");
415
426
  }
416
427
  }
417
428
 
418
429
  export const tunnel = Object.assign(
419
430
  async function tunnel(): Promise<void> {
431
+ if (process.argv[3] === "stop") {
432
+ const args = process.argv.slice(4);
433
+ if (args.includes("--help") || args.includes("-h")) {
434
+ console.log("Usage: cadet tunnel stop [<name>]");
435
+ console.log(
436
+ "Stop the recorded Cadet-managed tunnel, keeping nginx and the saved domain.",
437
+ );
438
+ return;
439
+ }
440
+ if (args.some((arg) => arg.startsWith("-")))
441
+ throw new Error("Usage: cadet tunnel stop [<name>]");
442
+ const { entry, workspaceDir } = resolveLocalTunnelTarget(
443
+ args.join(" ") || null,
444
+ );
445
+ if (!readRootProcesses().tunnel) {
446
+ console.log(
447
+ "No Cadet-managed tunnel is recorded. Externally started tunnels must be stopped directly.",
448
+ );
449
+ return;
450
+ }
451
+ const stopped = await stopTunnelProcess(entry.assistantId, workspaceDir);
452
+ console.log(
453
+ stopped
454
+ ? "Tunnel stopped. nginx and the saved domain are unchanged."
455
+ : "No running tunnel found; cleared stale tunnel state.",
456
+ );
457
+ return;
458
+ }
420
459
  const { assistantName, provider, domain, clearDomain, foreground } =
421
460
  parseArgs();
422
461
 
@@ -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,
@@ -0,0 +1,29 @@
1
+ import { afterAll, afterEach, expect, spyOn, test } from "bun:test";
2
+ import { fetchCurrentVersion } from "../http-client.js";
3
+
4
+ const request = spyOn(globalThis, "fetch");
5
+ afterEach(() => request.mockReset());
6
+ afterAll(() => request.mockRestore());
7
+
8
+ test("reads the gateway's version through the public health endpoint", async () => {
9
+ request.mockResolvedValueOnce(
10
+ Response.json({ version: "1.2.3", status: "ok" }),
11
+ );
12
+ expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBe("1.2.3");
13
+ expect(request.mock.calls[0][0]).toBe("http://127.0.0.1:7830/healthz");
14
+ });
15
+
16
+ test("an unverified gateway response never supplies a release version", async () => {
17
+ for (const value of [{}, null, { version: "" }, { version: 123 }]) {
18
+ request.mockResolvedValueOnce(Response.json(value));
19
+ expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBeUndefined();
20
+ }
21
+ request.mockResolvedValueOnce(new Response("not JSON"));
22
+ expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBeUndefined();
23
+ request.mockResolvedValueOnce(
24
+ Response.json({ version: "1.2.3" }, { status: 503 }),
25
+ );
26
+ expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBeUndefined();
27
+ request.mockRejectedValueOnce(new Error("offline"));
28
+ expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBeUndefined();
29
+ });
@@ -1,10 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import type { AssistantEntry } from "../assistant-config.js";
4
- import {
5
- resolveRuntimeMigrationUrl,
6
- resolveRuntimeUrl,
7
- } from "../runtime-url.js";
4
+ import { resolveRuntimeUrl } from "../runtime-url.js";
8
5
 
9
6
  function makeEntry(
10
7
  overrides: Partial<AssistantEntry> & {
@@ -20,75 +17,6 @@ function makeEntry(
20
17
  };
21
18
  }
22
19
 
23
- describe("resolveRuntimeMigrationUrl", () => {
24
- test("local cloud uses gateway-loopback /v1/migrations/<subpath>", () => {
25
- const entry = makeEntry({
26
- cloud: "local",
27
- runtimeUrl: "http://localhost:7821",
28
- assistantId: "ast-local-1",
29
- });
30
- expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
31
- "http://localhost:7821/v1/migrations/export-to-gcs",
32
- );
33
- expect(resolveRuntimeMigrationUrl(entry, "import-from-gcs")).toBe(
34
- "http://localhost:7821/v1/migrations/import-from-gcs",
35
- );
36
- expect(resolveRuntimeMigrationUrl(entry, "jobs/job-abc")).toBe(
37
- "http://localhost:7821/v1/migrations/jobs/job-abc",
38
- );
39
- });
40
-
41
- test("docker cloud uses gateway-loopback /v1/migrations/<subpath>", () => {
42
- const entry = makeEntry({
43
- cloud: "docker",
44
- runtimeUrl: "http://localhost:7831",
45
- assistantId: "ast-docker-1",
46
- });
47
- expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
48
- "http://localhost:7831/v1/migrations/export-to-gcs",
49
- );
50
- });
51
-
52
- test("cadet (cloud-managed) cloud uses wildcard-proxy /v1/assistants/<id>/migrations/<subpath>", () => {
53
- const entry = makeEntry({
54
- cloud: "cadet",
55
- runtimeUrl: "https://cloud.mycadet.ai",
56
- assistantId: "11111111-2222-3333-4444-555555555555",
57
- });
58
- expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
59
- "https://cloud.mycadet.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/export-to-gcs",
60
- );
61
- expect(resolveRuntimeMigrationUrl(entry, "import-from-gcs")).toBe(
62
- "https://cloud.mycadet.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/import-from-gcs",
63
- );
64
- expect(resolveRuntimeMigrationUrl(entry, "jobs/job-xyz")).toBe(
65
- "https://cloud.mycadet.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/jobs/job-xyz",
66
- );
67
- });
68
-
69
- test("dev platform URL still routes through the wildcard prefix", () => {
70
- const entry = makeEntry({
71
- cloud: "cadet",
72
- runtimeUrl: "https://dev-cloud.mycadet.ai",
73
- assistantId: "ast-dev-1",
74
- });
75
- expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
76
- "https://dev-cloud.mycadet.ai/v1/assistants/ast-dev-1/migrations/export-to-gcs",
77
- );
78
- });
79
-
80
- test("a non-cadet, non-local cloud (e.g. gcp) uses the local-shape URL", () => {
81
- const entry = makeEntry({
82
- cloud: "gcp",
83
- runtimeUrl: "http://10.0.0.5:7821",
84
- assistantId: "ast-gcp-1",
85
- });
86
- expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
87
- "http://10.0.0.5:7821/v1/migrations/export-to-gcs",
88
- );
89
- });
90
- });
91
-
92
20
  describe("resolveRuntimeUrl", () => {
93
21
  test("local cloud uses gateway-loopback /v1/<subpath>", () => {
94
22
  const entry = makeEntry({
@@ -112,9 +40,9 @@ describe("resolveRuntimeUrl", () => {
112
40
  );
113
41
  });
114
42
 
115
- test("cadet cloud uses wildcard-proxy /v1/assistants/<id>/<subpath>", () => {
43
+ test("cloud home uses wildcard-proxy /v1/assistants/<id>/<subpath>", () => {
116
44
  const entry = makeEntry({
117
- cloud: "cadet",
45
+ cloud: "cloud",
118
46
  runtimeUrl: "https://cloud.mycadet.ai",
119
47
  assistantId: "11111111-2222-3333-4444-555555555555",
120
48
  });