@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
@@ -1,160 +0,0 @@
1
- /**
2
- * Tests for `cadet unpair <name>`: forget a paired (cloud:"paired") assistant
3
- * by removing its lockfile entry + guardian token. Refuses non-paired entries.
4
- */
5
- import {
6
- afterAll,
7
- afterEach,
8
- beforeEach,
9
- describe,
10
- expect,
11
- spyOn,
12
- test,
13
- } from "bun:test";
14
- import { mkdtempSync, rmSync } from "node:fs";
15
- import { tmpdir } from "node:os";
16
- import { join } from "node:path";
17
-
18
- const testDir = mkdtempSync(join(tmpdir(), "unpair-test-"));
19
- const ORIGINAL_LOCKFILE_DIR = process.env.CADET_LOCKFILE_DIR;
20
- const ORIGINAL_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
21
- const ORIGINAL_ARGV = [...process.argv];
22
-
23
- import { unpair } from "../commands/unpair.js";
24
- import {
25
- findAssistantByName,
26
- saveAssistantEntry,
27
- } from "../lib/assistant-config.js";
28
- import {
29
- deleteGuardianToken,
30
- loadGuardianToken,
31
- saveGuardianToken,
32
- } from "../lib/guardian-token.js";
33
-
34
- function seedToken(assistantId: string): void {
35
- saveGuardianToken(assistantId, {
36
- guardianPrincipalId: "imported",
37
- accessToken: "acc",
38
- accessTokenExpiresAt: Date.now() + 3_600_000,
39
- refreshToken: "ref",
40
- refreshTokenExpiresAt: Date.now() + 3_600_000,
41
- refreshAfter: "",
42
- isNew: false,
43
- deviceId: "dev",
44
- leasedAt: new Date().toISOString(),
45
- });
46
- }
47
-
48
- /** Run unpair with console.error + process.exit spied. */
49
- async function runUnpair(): Promise<{ exited: boolean; errors: string }> {
50
- const errors: string[] = [];
51
- const logSpy = spyOn(console, "log").mockImplementation(() => {});
52
- const errSpy = spyOn(console, "error").mockImplementation(
53
- (...a: unknown[]) => {
54
- errors.push(a.join(" "));
55
- },
56
- );
57
- const exitSpy = spyOn(process, "exit").mockImplementation(((c?: number) => {
58
- throw new Error(`exit:${c}`);
59
- }) as never);
60
- let exited = false;
61
- try {
62
- await unpair();
63
- } catch (e) {
64
- exited = (e as Error).message === "exit:1";
65
- } finally {
66
- logSpy.mockRestore();
67
- errSpy.mockRestore();
68
- exitSpy.mockRestore();
69
- }
70
- return { exited, errors: errors.join("\n") };
71
- }
72
-
73
- describe("cadet unpair", () => {
74
- beforeEach(() => {
75
- process.env.CADET_LOCKFILE_DIR = testDir;
76
- process.env.XDG_CONFIG_HOME = testDir;
77
- });
78
-
79
- afterEach(() => {
80
- process.argv = [...ORIGINAL_ARGV];
81
- if (ORIGINAL_LOCKFILE_DIR === undefined)
82
- delete process.env.CADET_LOCKFILE_DIR;
83
- else process.env.CADET_LOCKFILE_DIR = ORIGINAL_LOCKFILE_DIR;
84
- if (ORIGINAL_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME;
85
- else process.env.XDG_CONFIG_HOME = ORIGINAL_CONFIG_HOME;
86
- });
87
-
88
- afterAll(() => {
89
- rmSync(testDir, { recursive: true, force: true });
90
- });
91
-
92
- test("removes a paired entry's lockfile entry and guardian token", async () => {
93
- saveAssistantEntry({
94
- assistantId: "px",
95
- name: "Paired Box",
96
- runtimeUrl: "http://10.0.0.9:7830",
97
- cloud: "paired",
98
- paired: true,
99
- });
100
- seedToken("px");
101
- expect(findAssistantByName("px")).not.toBeNull();
102
- expect(loadGuardianToken("px")).not.toBeNull();
103
-
104
- // Non-interactive test env → use --yes to bypass the confirmation prompt.
105
- process.argv = ["bun", "cadet", "unpair", "px", "--yes"];
106
- const { exited } = await runUnpair();
107
-
108
- expect(exited).toBe(false);
109
- expect(findAssistantByName("px")).toBeNull();
110
- expect(loadGuardianToken("px")).toBeNull();
111
- });
112
-
113
- test("refuses to unpair without --yes in a non-interactive terminal", async () => {
114
- saveAssistantEntry({
115
- assistantId: "py",
116
- name: "Paired Two",
117
- runtimeUrl: "http://10.0.0.9:7830",
118
- cloud: "paired",
119
- paired: true,
120
- });
121
- seedToken("py");
122
-
123
- process.argv = ["bun", "cadet", "unpair", "py"]; // no --yes
124
- const { exited, errors } = await runUnpair();
125
-
126
- expect(exited).toBe(true);
127
- expect(errors).toContain("--yes");
128
- // Not removed — confirmation was required.
129
- expect(findAssistantByName("py")).not.toBeNull();
130
- expect(loadGuardianToken("py")).not.toBeNull();
131
- });
132
-
133
- test("refuses a non-paired (local) assistant and leaves it intact", async () => {
134
- saveAssistantEntry({
135
- assistantId: "desk",
136
- name: "Desk",
137
- runtimeUrl: "http://127.0.0.1:7830",
138
- cloud: "local",
139
- });
140
-
141
- process.argv = ["bun", "cadet", "unpair", "desk"];
142
- const { exited, errors } = await runUnpair();
143
-
144
- expect(exited).toBe(true);
145
- expect(errors).toContain("cadet retire");
146
- expect(findAssistantByName("desk")).not.toBeNull(); // untouched
147
- });
148
-
149
- test("errors on an unknown name", async () => {
150
- process.argv = ["bun", "cadet", "unpair", "nope"];
151
- const { exited } = await runUnpair();
152
- expect(exited).toBe(true);
153
- });
154
-
155
- test("deleteGuardianToken is a no-op when the token is absent", () => {
156
- // No token seeded for this id; calling (twice) must not throw.
157
- expect(() => deleteGuardianToken("ghost")).not.toThrow();
158
- expect(() => deleteGuardianToken("ghost")).not.toThrow();
159
- });
160
- });
@@ -1,479 +0,0 @@
1
- import { isCloudHome } from "../lib/cloud-values.js";
2
- import {
3
- findAssistantByName,
4
- getActiveAssistant,
5
- loadAllAssistants,
6
- saveAssistantEntry,
7
- type AssistantEntry,
8
- } from "../lib/assistant-config";
9
- import {
10
- GATEWAY_INTERNAL_PORT,
11
- ASSISTANT_INTERNAL_PORT,
12
- dockerResourceNames,
13
- startContainers,
14
- stopContainers,
15
- } from "../lib/docker";
16
- import type { ServiceName } from "../lib/docker";
17
- import { emitCliError, categorizeUpgradeError } from "../lib/cli-error.js";
18
- import {
19
- readCloudToken,
20
- rollbackCloudAssistant,
21
- } from "../lib/cloud-client.js";
22
- import {
23
- broadcastUpgradeEvent,
24
- buildCompleteEvent,
25
- buildProgressEvent,
26
- buildStartingEvent,
27
- buildUpgradeCommitMessage,
28
- captureReplayState,
29
- commitWorkspaceViaGateway,
30
- fetchCurrentVersion,
31
- fetchPreviousVersion,
32
- performDockerRollback,
33
- rollbackMigrations,
34
- UPGRADE_PROGRESS,
35
- waitForReady,
36
- } from "../lib/upgrade-lifecycle.js";
37
-
38
- function parseArgs(): { name: string | null; version: string | null } {
39
- const args = process.argv.slice(3);
40
- let name: string | null = null;
41
- let version: string | null = null;
42
-
43
- for (let i = 0; i < args.length; i++) {
44
- const arg = args[i];
45
- if (arg === "--help" || arg === "-h") {
46
- console.log("Usage: cadet rollback [<name>] [--version <version>]");
47
- console.log("");
48
- console.log(
49
- "Roll back a Docker or cloud assistant to a previous version.",
50
- );
51
- console.log("");
52
- console.log("Arguments:");
53
- console.log(
54
- " <name> Name of the assistant, defaults to active or only assistant",
55
- );
56
- console.log("");
57
- console.log("Options:");
58
- console.log(
59
- " --version <version> Target version, optional for managed — omit to roll back to previous",
60
- );
61
- console.log("");
62
- console.log("Examples:");
63
- console.log(
64
- " cadet rollback my-assistant # Roll back to previous version (Docker or managed)",
65
- );
66
- console.log(
67
- " cadet rollback my-assistant --version v1.2.3 # Roll back to a specific version",
68
- );
69
- process.exit(0);
70
- } else if (arg === "--version") {
71
- const next = args[i + 1];
72
- if (!next || next.startsWith("-")) {
73
- console.error("Error: --version requires a value");
74
- emitCliError("UNKNOWN", "--version requires a value");
75
- process.exit(1);
76
- }
77
- version = next;
78
- i++;
79
- } else if (!arg.startsWith("-")) {
80
- name = arg;
81
- } else {
82
- console.error(`Error: Unknown option '${arg}'.`);
83
- emitCliError("UNKNOWN", `Unknown option '${arg}'`);
84
- process.exit(1);
85
- }
86
- }
87
-
88
- return { name, version };
89
- }
90
-
91
- /**
92
- * Resolve which assistant to target for the rollback command. Priority:
93
- * 1. Explicit name argument
94
- * 2. Active assistant set via `cadet use`
95
- * 3. Sole assistant (when exactly one exists)
96
- */
97
- function resolveTargetAssistant(nameArg: string | null): AssistantEntry {
98
- if (nameArg) {
99
- const entry = findAssistantByName(nameArg);
100
- if (!entry) {
101
- console.error(`No assistant found with name '${nameArg}'.`);
102
- emitCliError(
103
- "ASSISTANT_NOT_FOUND",
104
- `No assistant found with name '${nameArg}'.`,
105
- );
106
- process.exit(1);
107
- }
108
- return entry;
109
- }
110
-
111
- const active = getActiveAssistant();
112
- if (active) {
113
- const entry = findAssistantByName(active);
114
- if (entry) return entry;
115
- }
116
-
117
- const all = loadAllAssistants();
118
- if (all.length === 1) return all[0];
119
-
120
- if (all.length === 0) {
121
- const msg = "No assistants found. Run 'cadet hatch' first.";
122
- console.error(msg);
123
- emitCliError("ASSISTANT_NOT_FOUND", msg);
124
- } else {
125
- const msg =
126
- "Multiple assistants found. Specify a name or set an active assistant with 'cadet use <name>'.";
127
- console.error(msg);
128
- emitCliError("ASSISTANT_NOT_FOUND", msg);
129
- }
130
- process.exit(1);
131
- }
132
-
133
- async function rollbackPlatformViaEndpoint(
134
- entry: AssistantEntry,
135
- version?: string,
136
- ): Promise<void> {
137
- // Step 1 — Authenticate
138
- const token = readCloudToken();
139
- if (!token) {
140
- const msg =
141
- "Error: Not logged in. Run `cadet login --token <token>` first.";
142
- console.error(msg);
143
- emitCliError("AUTH_FAILED", msg);
144
- process.exit(1);
145
- }
146
-
147
- // Fetch current version from health endpoint (best-effort)
148
- const currentVersion = await fetchCurrentVersion(entry.runtimeUrl);
149
-
150
- // Step 3 — Call rollback endpoint
151
- if (version) {
152
- console.log(`Rolling back to ${version}...`);
153
- } else {
154
- console.log("Rolling back to previous version...");
155
- }
156
-
157
- let result: { detail: string; version: string | null };
158
- try {
159
- result = await rollbackCloudAssistant(
160
- token,
161
- entry.assistantId,
162
- version,
163
- entry.runtimeUrl,
164
- );
165
- } catch (err) {
166
- const detail = err instanceof Error ? err.message : String(err);
167
-
168
- // Map specific server error messages to actionable CLI output
169
- if (detail.includes("No previous version")) {
170
- console.error(
171
- "No previous version available. A successful upgrade must have been performed first.",
172
- );
173
- } else if (detail.includes("not older")) {
174
- console.error(
175
- `Target version is not older than the current version. Use 'cadet upgrade --version' instead.`,
176
- );
177
- } else if (detail.includes("not found")) {
178
- console.error(
179
- version
180
- ? `Version ${version} not found.`
181
- : `Rollback target not found.`,
182
- );
183
- } else if (
184
- err instanceof TypeError ||
185
- detail.includes("fetch failed") ||
186
- detail.includes("ECONNREFUSED")
187
- ) {
188
- console.error(
189
- `Connection error: ${detail}\nIs Cadet Cloud reachable? Try 'cadet wake' if the assistant is asleep.`,
190
- );
191
- } else {
192
- console.error(`Error: ${detail}`);
193
- }
194
-
195
- emitCliError("PLATFORM_API_ERROR", "Cadet Cloud rollback failed", detail);
196
- await broadcastUpgradeEvent(
197
- entry.runtimeUrl,
198
- entry.assistantId,
199
- buildCompleteEvent(currentVersion ?? version ?? "unknown", false),
200
- );
201
- process.exit(1);
202
- }
203
-
204
- const rolledBackVersion = result.version ?? version ?? "unknown";
205
-
206
- // Step 4 — Print success
207
- console.log(`Rolled back to version ${rolledBackVersion}.`);
208
- if (!version) {
209
- console.log("Tip: Run 'cadet rollback' again to undo.");
210
- }
211
- }
212
-
213
- export const rollback = Object.assign(
214
- async function rollback(): Promise<void> {
215
- const { name, version } = parseArgs();
216
- const entry = resolveTargetAssistant(name);
217
- const cloud = entry.cloud;
218
-
219
- if (cloud === "apple-container") {
220
- console.error(
221
- `Error: '${entry.assistantId}' uses the Apple Containers runtime. Rollback is not yet supported for this topology.`,
222
- );
223
- process.exit(1);
224
- }
225
-
226
- // ---------- Managed (Cadet Cloud) rollback ----------
227
- if (isCloudHome(cloud)) {
228
- await rollbackPlatformViaEndpoint(entry, version ?? undefined);
229
- return;
230
- }
231
-
232
- // ---------- Unsupported topologies ----------
233
- if (cloud !== "docker") {
234
- const msg = "Rollback is only supported for Docker and cloud assistants.";
235
- console.error(msg);
236
- emitCliError("UNSUPPORTED_TOPOLOGY", msg);
237
- process.exit(1);
238
- }
239
-
240
- // ---------- Docker: Targeted version rollback (--version specified) ----------
241
- if (version) {
242
- try {
243
- await performDockerRollback(entry, { targetVersion: version });
244
- } catch (err) {
245
- const detail = err instanceof Error ? err.message : String(err);
246
- console.error(`\nāŒ Rollback failed: ${detail}`);
247
- await broadcastUpgradeEvent(
248
- entry.runtimeUrl,
249
- entry.assistantId,
250
- buildCompleteEvent(version ?? "unknown", false),
251
- );
252
- emitCliError(categorizeUpgradeError(err), "Rollback failed", detail);
253
- process.exit(1);
254
- }
255
- return;
256
- }
257
-
258
- // ---------- Docker: Saved-state rollback (no --version) ----------
259
-
260
- // Fetch current + previous version from live APIs
261
- const currentVersion = await fetchCurrentVersion(entry.runtimeUrl);
262
- const previousVersion =
263
- (await fetchPreviousVersion(currentVersion, entry.previousVersion)) ??
264
- "unknown";
265
-
266
- // Verify rollback state exists
267
- if (!entry.previousContainerInfo) {
268
- const msg =
269
- "No rollback state available. Run `cadet upgrade` first to create a rollback point.";
270
- console.error(msg);
271
- emitCliError("ROLLBACK_NO_STATE", msg);
272
- process.exit(1);
273
- }
274
-
275
- // Roll back to the previous images. Older entries recorded per-service
276
- // digests (the image sha captured at upgrade time) — those are gone from
277
- // the contract now; the recorded image refs are the rollback target.
278
- const prev = entry.previousContainerInfo;
279
- if (!prev.assistantImage || !prev.gatewayImage || !prev.cesImage) {
280
- const msg =
281
- "Incomplete rollback state. Previous container image refs are missing.";
282
- console.error(msg);
283
- emitCliError("ROLLBACK_NO_STATE", msg);
284
- process.exit(1);
285
- }
286
-
287
- // Build image refs from the previous record
288
- const previousImageRefs: Record<ServiceName, string> = {
289
- assistant: prev.assistantImage,
290
- "credential-executor": prev.cesImage,
291
- gateway: prev.gatewayImage,
292
- };
293
-
294
- const instanceName = entry.assistantId;
295
- const res = dockerResourceNames(instanceName);
296
-
297
- try {
298
- // Record rollback start in workspace git history
299
- await commitWorkspaceViaGateway(
300
- entry.runtimeUrl,
301
- entry.assistantId,
302
- buildUpgradeCommitMessage({
303
- action: "rollback",
304
- phase: "starting",
305
- from: currentVersion ?? "unknown",
306
- to: previousVersion,
307
- topology: "docker",
308
- assistantId: entry.assistantId,
309
- }),
310
- );
311
-
312
- console.log(
313
- `šŸ”„ Rolling back Docker assistant '${instanceName}' to ${previousVersion}...\n`,
314
- );
315
-
316
- const {
317
- bootstrapSecret,
318
- cesServiceToken,
319
- signingKey,
320
- extraAssistantEnv,
321
- extraGatewayEnv,
322
- } = await captureReplayState(res);
323
-
324
- // Parse gateway port from entry's runtimeUrl, fall back to default
325
- let gatewayPort = GATEWAY_INTERNAL_PORT;
326
- try {
327
- const parsed = new URL(entry.runtimeUrl);
328
- const port = parseInt(parsed.port, 10);
329
- if (!isNaN(port)) {
330
- gatewayPort = port;
331
- }
332
- } catch {
333
- // use default
334
- }
335
-
336
- // Recover the assistant host port from the entry, fall back to default.
337
- const assistantPort =
338
- entry.containerInfo?.assistantPort ?? ASSISTANT_INTERNAL_PORT;
339
-
340
- // Notify connected clients that a rollback is about to begin (best-effort)
341
- console.log("šŸ“¢ Notifying connected clients...");
342
- await broadcastUpgradeEvent(
343
- entry.runtimeUrl,
344
- entry.assistantId,
345
- buildStartingEvent(previousVersion),
346
- );
347
- // Brief pause to allow SSE delivery before containers stop.
348
- await new Promise((r) => setTimeout(r, 500));
349
-
350
- // Roll back migrations to pre-upgrade state (must happen before containers stop)
351
- if (
352
- entry.previousDbMigrationVersion !== undefined ||
353
- entry.previousWorkspaceMigrationId !== undefined
354
- ) {
355
- console.log("šŸ”„ Reverting database changes...");
356
- await broadcastUpgradeEvent(
357
- entry.runtimeUrl,
358
- entry.assistantId,
359
- buildProgressEvent(UPGRADE_PROGRESS.REVERTING_MIGRATIONS),
360
- );
361
- await rollbackMigrations(
362
- entry.runtimeUrl,
363
- entry.assistantId,
364
- entry.previousDbMigrationVersion,
365
- entry.previousWorkspaceMigrationId,
366
- );
367
- }
368
-
369
- // Progress: switching version (must be sent BEFORE stopContainers)
370
- await broadcastUpgradeEvent(
371
- entry.runtimeUrl,
372
- entry.assistantId,
373
- buildProgressEvent(UPGRADE_PROGRESS.SWITCHING),
374
- );
375
-
376
- console.log("šŸ›‘ Stopping existing containers...");
377
- await stopContainers(res);
378
- console.log("āœ… Containers stopped\n");
379
-
380
- console.log("šŸš€ Starting containers with previous version...");
381
- await startContainers(
382
- {
383
- signingKey,
384
- bootstrapSecret,
385
- cesServiceToken,
386
- extraAssistantEnv,
387
- extraGatewayEnv,
388
- gatewayPort,
389
- assistantPort,
390
- imageTags: previousImageRefs,
391
- instanceName,
392
- res,
393
- },
394
- (msg) => console.log(msg),
395
- );
396
- console.log("āœ… Containers started\n");
397
-
398
- console.log("Waiting for assistant to become ready...");
399
- const ready = await waitForReady(entry.runtimeUrl);
400
-
401
- if (ready) {
402
- // Swap current/previous state to enable "rollback the rollback"
403
- const updatedEntry: AssistantEntry = {
404
- ...entry,
405
- containerInfo: {
406
- assistantImage: prev.assistantImage ?? previousImageRefs.assistant,
407
- gatewayImage: prev.gatewayImage ?? previousImageRefs.gateway,
408
- cesImage: prev.cesImage ?? previousImageRefs["credential-executor"],
409
- assistantPort,
410
- },
411
- previousContainerInfo: entry.containerInfo,
412
- // Clear the backup path — it belonged to the upgrade we just rolled back
413
- preUpgradeBackupPath: undefined,
414
- previousDbMigrationVersion: undefined,
415
- previousWorkspaceMigrationId: undefined,
416
- };
417
- saveAssistantEntry(updatedEntry);
418
-
419
- // Notify clients that the rollback succeeded
420
- await broadcastUpgradeEvent(
421
- entry.runtimeUrl,
422
- entry.assistantId,
423
- buildCompleteEvent(previousVersion, true),
424
- );
425
-
426
- // Record successful rollback in workspace git history
427
- await commitWorkspaceViaGateway(
428
- entry.runtimeUrl,
429
- entry.assistantId,
430
- buildUpgradeCommitMessage({
431
- action: "rollback",
432
- phase: "complete",
433
- from: currentVersion ?? "unknown",
434
- to: previousVersion,
435
- topology: "docker",
436
- assistantId: entry.assistantId,
437
- result: "success",
438
- }),
439
- );
440
-
441
- console.log(
442
- `\nāœ… Docker assistant '${instanceName}' rolled back to ${previousVersion}.`,
443
- );
444
- console.log(
445
- "\nTip: To also restore data from before the upgrade, use `cadet restore --from <backup-path>`.",
446
- );
447
- } else {
448
- console.error(
449
- `\nāŒ Containers failed to become ready within the timeout.`,
450
- );
451
- console.log(
452
- ` Check logs with: docker logs -f ${res.assistantContainer}`,
453
- );
454
- await broadcastUpgradeEvent(
455
- entry.runtimeUrl,
456
- entry.assistantId,
457
- buildCompleteEvent(previousVersion, false),
458
- );
459
- emitCliError(
460
- "READINESS_TIMEOUT",
461
- "Rolled-back containers failed to become ready within the timeout.",
462
- );
463
- process.exit(1);
464
- }
465
- } catch (err) {
466
- const detail = err instanceof Error ? err.message : String(err);
467
- console.error(`\nāŒ Rollback failed: ${detail}`);
468
- await broadcastUpgradeEvent(
469
- entry.runtimeUrl,
470
- entry.assistantId,
471
- buildCompleteEvent(previousVersion, false),
472
- );
473
- emitCliError(categorizeUpgradeError(err), "Rollback failed", detail);
474
- process.exit(1);
475
- }
476
- },
477
- { description: "Roll back an assistant to a previous version" },
478
- );
479
-