@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
@@ -0,0 +1,110 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createServer } from "node:net";
3
+
4
+ import { findAvailableIngressPort } from "../lib/nginx-ingress";
5
+ import { edgeServedRemote } from "../lib/nginx-ingress";
6
+ import { summarizeStartupFailure } from "../lib/tunnel";
7
+
8
+ function portOf(server: ReturnType<typeof createServer>): number {
9
+ const addr = server.address();
10
+ return typeof addr === "object" && addr !== null ? addr.port : -1;
11
+ }
12
+
13
+ describe("summarizeStartupFailure", () => {
14
+ test("names an nginx emerg line over generic log noise", () => {
15
+ const log = [
16
+ "Starting ngrok tunnel to localhost:7840...",
17
+ "nginx: [emerg] bind() to 0.0.0.0:7840 failed (98: Address already in use)",
18
+ ].join("\n");
19
+ expect(summarizeStartupFailure(log)).toContain("[emerg]");
20
+ });
21
+
22
+ test("names an ngrok error code", () => {
23
+ expect(
24
+ summarizeStartupFailure(
25
+ "agent started\nERR_NGROK_4018: Authentication failed",
26
+ ),
27
+ ).toContain("ERR_NGROK_4018");
28
+ });
29
+
30
+ test("the last matching line wins", () => {
31
+ const log = [
32
+ "nginx: [emerg] bind() to 0.0.0.0:7840 failed (98: Address already in use)",
33
+ "nginx: [emerg] bind() to 0.0.0.0:7841 failed (98: Address already in use)",
34
+ ].join("\n");
35
+ expect(summarizeStartupFailure(log)).toContain(":7841");
36
+ });
37
+
38
+ test("falls back to the last non-empty line when nothing error-shaped appears", () => {
39
+ expect(summarizeStartupFailure("first line\nsome plain output")).toBe(
40
+ "some plain output",
41
+ );
42
+ });
43
+
44
+ test("an empty log summarizes to null", () => {
45
+ expect(summarizeStartupFailure("")).toBeNull();
46
+ expect(summarizeStartupFailure("\n \n")).toBeNull();
47
+ });
48
+
49
+ test("long lines are capped", () => {
50
+ const long = `nginx: [emerg] ${"x".repeat(400)}`;
51
+ const summary = summarizeStartupFailure(long) ?? "";
52
+ expect(summary.length).toBeLessThanOrEqual(201);
53
+ expect(summary.endsWith("…")).toBe(true);
54
+ });
55
+ });
56
+
57
+ describe("findAvailableIngressPort", () => {
58
+ test("skips a port a foreign listener already holds", async () => {
59
+ const blocker = createServer();
60
+ await new Promise<void>((resolve) => {
61
+ blocker.listen(0, "127.0.0.1", resolve);
62
+ });
63
+ const occupied = portOf(blocker);
64
+ try {
65
+ const port = await findAvailableIngressPort(occupied);
66
+ expect(port).toBe(occupied + 1);
67
+ } finally {
68
+ await new Promise<void>((resolve) => blocker.close(() => resolve()));
69
+ }
70
+ });
71
+
72
+ test("returns null when the whole band is busy", async () => {
73
+ const scout = createServer();
74
+ await new Promise<void>((resolve) => scout.listen(0, "127.0.0.1", resolve));
75
+ const base = portOf(scout);
76
+ await new Promise<void>((resolve) => scout.close(() => resolve()));
77
+ // Hold both ports of a 2-port band with fresh listeners.
78
+ const first = createServer();
79
+ const second = createServer();
80
+ await new Promise<void>((resolve) =>
81
+ first.listen(base, "127.0.0.1", resolve),
82
+ );
83
+ await new Promise<void>((resolve) =>
84
+ second.listen(base + 1, "127.0.0.1", resolve),
85
+ );
86
+ try {
87
+ expect(await findAvailableIngressPort(base, 2)).toBeNull();
88
+ } finally {
89
+ await new Promise<void>((resolve) => first.close(() => resolve()));
90
+ await new Promise<void>((resolve) => second.close(() => resolve()));
91
+ }
92
+ });
93
+ });
94
+
95
+ describe("edgeServedRemote", () => {
96
+ test("a docker cadet is stamped truthfully", () => {
97
+ expect(edgeServedRemote("docker")).toBe("docker");
98
+ });
99
+
100
+ test("a custom cadet keeps its kind", () => {
101
+ expect(edgeServedRemote("custom")).toBe("custom");
102
+ });
103
+
104
+ test("local, paired, cloud, and unknown entries read as custom so the remote-gateway machinery always runs", () => {
105
+ expect(edgeServedRemote("local")).toBe("custom");
106
+ expect(edgeServedRemote("paired")).toBe("custom");
107
+ expect(edgeServedRemote("cloud")).toBe("custom");
108
+ expect(edgeServedRemote(undefined)).toBe("custom");
109
+ });
110
+ });
@@ -0,0 +1,226 @@
1
+ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test";
2
+ import {
3
+ mkdtempSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import * as processes from "../lib/process.js";
12
+ import {
13
+ TAILSCALE_PROVIDER,
14
+ stopTailscaleTunnel,
15
+ type TailscaleDeps,
16
+ } from "../lib/tunnels/tailscale.js";
17
+ import * as ngrok from "../lib/tunnels/ngrok.js";
18
+ import { tunnel } from "../commands/tunnel.js";
19
+ import {
20
+ readRootProcesses,
21
+ saveAssistantEntry,
22
+ stampRootProcess,
23
+ findAssistantByName,
24
+ } from "../lib/assistant-config.js";
25
+
26
+ const originalArgv = process.argv;
27
+ const originalLock = process.env.CADET_LOCKFILE_DIR;
28
+ const originalConfig = process.env.XDG_CONFIG_HOME;
29
+ let root = "";
30
+ let workspace = "";
31
+ let stop: ReturnType<typeof spyOn<typeof processes, "stopProcess">>;
32
+ let identity: ReturnType<
33
+ typeof spyOn<typeof processes, "readProcessCommandLine">
34
+ >;
35
+ let logs: ReturnType<typeof spyOn<typeof console, "log">>;
36
+
37
+ beforeEach(() => {
38
+ root = mkdtempSync(join(tmpdir(), "cadet-tunnel-stop-"));
39
+ workspace = join(root, "instance", "workspace");
40
+ mkdirSync(workspace, { recursive: true });
41
+ process.env.CADET_LOCKFILE_DIR = root;
42
+ process.env.XDG_CONFIG_HOME = root;
43
+ saveAssistantEntry({
44
+ assistantId: "fixture",
45
+ name: "fixture",
46
+ cloud: "local",
47
+ runtimeUrl: "http://127.0.0.1:7830",
48
+ ingressUrl: "https://old.ngrok.app",
49
+ resources: {
50
+ instanceDir: join(root, "instance"),
51
+ gatewayPort: 7830,
52
+ daemonPort: 7821,
53
+ cesPort: 8090,
54
+ qdrantPort: 6333,
55
+ },
56
+ });
57
+ writeFileSync(
58
+ join(workspace, "config.json"),
59
+ JSON.stringify({
60
+ ingress: {
61
+ publicBaseUrl: "https://old.ngrok.app",
62
+ lastTunnel: {
63
+ provider: "ngrok",
64
+ publicBaseUrl: "https://old.ngrok.app",
65
+ },
66
+ },
67
+ }),
68
+ );
69
+ stampRootProcess("tunnel", {
70
+ pid: 424242,
71
+ provider: "ngrok",
72
+ assistantId: "fixture",
73
+ startedAt: new Date().toISOString(),
74
+ });
75
+ stampRootProcess("app", { pid: 424243, startedAt: new Date().toISOString() });
76
+ ngrok.saveNgrokDomain("old.ngrok.app");
77
+ stop = spyOn(processes, "stopProcess").mockResolvedValue(true);
78
+ identity = spyOn(processes, "readProcessCommandLine").mockReturnValue(
79
+ "ngrok http 7840",
80
+ );
81
+ logs = spyOn(console, "log").mockImplementation(() => {});
82
+ process.argv = ["bun", "cadet", "tunnel", "stop", "fixture"];
83
+ });
84
+ afterEach(() => {
85
+ stop.mockRestore();
86
+ identity.mockRestore();
87
+ logs.mockRestore();
88
+ process.argv = originalArgv;
89
+ if (originalLock === undefined) delete process.env.CADET_LOCKFILE_DIR;
90
+ else process.env.CADET_LOCKFILE_DIR = originalLock;
91
+ if (originalConfig === undefined) delete process.env.XDG_CONFIG_HOME;
92
+ else process.env.XDG_CONFIG_HOME = originalConfig;
93
+ rmSync(root, { recursive: true, force: true });
94
+ });
95
+
96
+ test("stop terminates the owned agent and clears ingress, retaining nginx and domain; repeated stop is harmless", async () => {
97
+ await tunnel();
98
+ expect(stop).toHaveBeenCalledWith(424242, "tunnel");
99
+ expect(readRootProcesses().tunnel).toBeUndefined();
100
+ expect(readRootProcesses().app?.pid).toBe(424243);
101
+ expect(findAssistantByName("fixture")?.ingressUrl).toBeUndefined();
102
+ expect(
103
+ JSON.parse(readFileSync(join(workspace, "config.json"), "utf8")).ingress
104
+ .publicBaseUrl,
105
+ ).toBeUndefined();
106
+ expect(ngrok.loadNgrokDomain()).toBe("old.ngrok.app");
107
+ await tunnel();
108
+ expect(stop).toHaveBeenCalledTimes(1);
109
+ });
110
+ test("a recycled PID is not signaled and its stale record is cleared", async () => {
111
+ identity.mockReturnValue("unrelated-process");
112
+ await tunnel();
113
+ expect(stop).not.toHaveBeenCalled();
114
+ expect(readRootProcesses().tunnel).toBeUndefined();
115
+ });
116
+ test("another assistant's tunnel is left intact", async () => {
117
+ stampRootProcess("tunnel", {
118
+ pid: 424242,
119
+ provider: "ngrok",
120
+ assistantId: "another",
121
+ startedAt: new Date().toISOString(),
122
+ });
123
+ await expect(tunnel()).rejects.toThrow("belongs to 'another'");
124
+ expect(stop).not.toHaveBeenCalled();
125
+ expect(readRootProcesses().tunnel?.assistantId).toBe("another");
126
+ });
127
+ test("failure to stop retains process and ingress state", async () => {
128
+ stop.mockResolvedValue(false);
129
+ await expect(tunnel()).rejects.toThrow("Could not stop ngrok");
130
+ expect(readRootProcesses().tunnel?.pid).toBe(424242);
131
+ expect(findAssistantByName("fixture")?.ingressUrl).toBe(
132
+ "https://old.ngrok.app",
133
+ );
134
+ });
135
+
136
+ test("Cloudflare dispatch stops cloudflared and clears its ingress", async () => {
137
+ stampRootProcess("tunnel", {
138
+ pid: 424242,
139
+ provider: "cloudflare",
140
+ assistantId: "fixture",
141
+ startedAt: new Date().toISOString(),
142
+ });
143
+ identity.mockReturnValue(
144
+ "/usr/local/bin/cloudflared tunnel --url http://localhost:7840",
145
+ );
146
+ await tunnel();
147
+ expect(stop).toHaveBeenCalledWith(424242, "tunnel");
148
+ expect(readRootProcesses().tunnel).toBeUndefined();
149
+ expect(findAssistantByName("fixture")?.ingressUrl).toBeUndefined();
150
+ });
151
+
152
+ test("Tailscale dispatch disables serve and preserves a separate webhook ingress", async () => {
153
+ stampRootProcess("tunnel", {
154
+ pid: 424242,
155
+ provider: "tailscale",
156
+ previous: {
157
+ pid: 424240,
158
+ provider: "ngrok",
159
+ assistantId: "fixture",
160
+ startedAt: new Date().toISOString(),
161
+ },
162
+ assistantId: "fixture",
163
+ startedAt: new Date().toISOString(),
164
+ });
165
+ writeFileSync(
166
+ join(workspace, "config.json"),
167
+ JSON.stringify({
168
+ ingress: {
169
+ publicBaseUrl: "https://old.ngrok.app",
170
+ pairingTunnel: { provider: "tailscale" },
171
+ },
172
+ }),
173
+ );
174
+ const calls: string[][] = [];
175
+ const deps: TailscaleDeps = {
176
+ findBinary: async () => "tailscale",
177
+ run: (_bin, args) => {
178
+ calls.push(args);
179
+ return { status: 0, stdout: "", stderr: "" };
180
+ },
181
+ };
182
+ const provider = spyOn(TAILSCALE_PROVIDER, "stop").mockImplementation(
183
+ (opts) => stopTailscaleTunnel(opts, deps),
184
+ );
185
+ try {
186
+ await tunnel();
187
+ expect(calls).toEqual([["serve", "--https=443", "off"]]);
188
+ expect(stop).not.toHaveBeenCalled();
189
+ expect(readRootProcesses().tunnel?.pid).toBe(424240);
190
+ expect(findAssistantByName("fixture")?.ingressUrl).toBe(
191
+ "https://old.ngrok.app",
192
+ );
193
+ const state = JSON.parse(
194
+ readFileSync(join(workspace, "config.json"), "utf8"),
195
+ );
196
+ expect(state.ingress.publicBaseUrl).toBe("https://old.ngrok.app");
197
+ expect(state.ingress.pairingTunnel).toBeUndefined();
198
+ } finally {
199
+ provider.mockRestore();
200
+ }
201
+ });
202
+
203
+ test("Tailscale shutdown failure preserves the recorded tunnel and ingress", async () => {
204
+ stampRootProcess("tunnel", {
205
+ pid: 424242,
206
+ provider: "tailscale",
207
+ assistantId: "fixture",
208
+ startedAt: new Date().toISOString(),
209
+ });
210
+ const deps: TailscaleDeps = {
211
+ findBinary: async () => "tailscale",
212
+ run: () => ({ status: 1, stdout: "", stderr: "permission denied" }),
213
+ };
214
+ const provider = spyOn(TAILSCALE_PROVIDER, "stop").mockImplementation(
215
+ (opts) => stopTailscaleTunnel(opts, deps),
216
+ );
217
+ try {
218
+ await expect(tunnel()).rejects.toThrow("Could not stop tailscale serve");
219
+ expect(readRootProcesses().tunnel?.provider).toBe("tailscale");
220
+ expect(findAssistantByName("fixture")?.ingressUrl).toBe(
221
+ "https://old.ngrok.app",
222
+ );
223
+ } finally {
224
+ provider.mockRestore();
225
+ }
226
+ });
@@ -53,6 +53,10 @@ const runCloudflareTunnelMock = mock<
53
53
  mock.module("../lib/tunnels/cloudflare.js", () => ({
54
54
  ...realCloudflareTunnel,
55
55
  runCloudflareTunnel: runCloudflareTunnelMock,
56
+ CLOUDFLARE_PROVIDER: {
57
+ ...realCloudflareTunnel.CLOUDFLARE_PROVIDER,
58
+ run: runCloudflareTunnelMock,
59
+ },
56
60
  }));
57
61
 
58
62
  const runNgrokTunnelMock = mock<typeof ngrok.runNgrokTunnel>(async () =>
@@ -61,6 +65,7 @@ const runNgrokTunnelMock = mock<typeof ngrok.runNgrokTunnel>(async () =>
61
65
  mock.module("../lib/tunnels/ngrok", () => ({
62
66
  ...realNgrok,
63
67
  runNgrokTunnel: runNgrokTunnelMock,
68
+ NGROK_PROVIDER: { ...realNgrok.NGROK_PROVIDER, run: runNgrokTunnelMock },
64
69
  }));
65
70
 
66
71
  const runTailscaleTunnelMock = mock<typeof tailscaleTunnel.runTailscaleTunnel>(
@@ -69,6 +74,10 @@ const runTailscaleTunnelMock = mock<typeof tailscaleTunnel.runTailscaleTunnel>(
69
74
  mock.module("../lib/tunnels/tailscale.js", () => ({
70
75
  ...realTailscaleTunnel,
71
76
  runTailscaleTunnel: runTailscaleTunnelMock,
77
+ TAILSCALE_PROVIDER: {
78
+ ...realTailscaleTunnel.TAILSCALE_PROVIDER,
79
+ run: runTailscaleTunnelMock,
80
+ },
72
81
  }));
73
82
 
74
83
  const notifyGatewayOfPublicUrlMock = mock<
@@ -82,7 +91,12 @@ mock.module("../lib/tunnels/gateway-notify.js", () => ({
82
91
  const EDGE_PORT = 18080;
83
92
 
84
93
  const ensureTunnelEdgeMock = mock<typeof nginxIngress.ensureTunnelEdge>(
85
- async () => ({ port: EDGE_PORT, started: true, includesWebApp: true }),
94
+ async () => ({
95
+ port: EDGE_PORT,
96
+ started: true,
97
+ includesWebApp: true,
98
+ shellMode: "web",
99
+ }),
86
100
  );
87
101
  const getNginxVersionMock = mock<typeof nginxIngress.getNginxVersion>(
88
102
  () => "nginx version (test)",
@@ -123,7 +137,7 @@ function makeCloudEntry(assistantId = "cloud-1"): AssistantEntry {
123
137
  return {
124
138
  assistantId,
125
139
  runtimeUrl: `https://runtime.example.com/${assistantId}`,
126
- cloud: "cadet",
140
+ cloud: "cloud",
127
141
  };
128
142
  }
129
143
 
@@ -280,6 +294,7 @@ describe("tunnel edge targeting", () => {
280
294
  port: EDGE_PORT,
281
295
  started: true,
282
296
  includesWebApp: true,
297
+ shellMode: "web",
283
298
  });
284
299
  });
285
300
 
@@ -794,6 +809,7 @@ describe("tunnel edge targeting", () => {
794
809
  port: EDGE_PORT,
795
810
  started: false,
796
811
  includesWebApp: false,
812
+ shellMode: "web",
797
813
  });
798
814
 
799
815
  const logs = await runTunnelCapturingLogs();
@@ -1705,6 +1721,7 @@ describe("tunnel background mode", () => {
1705
1721
  mock.module("../lib/tunnels/ngrok", () => ({
1706
1722
  ...realNgrok,
1707
1723
  runNgrokTunnel: runNgrokTunnelMock,
1724
+ NGROK_PROVIDER: { ...realNgrok.NGROK_PROVIDER, run: runNgrokTunnelMock },
1708
1725
  }));
1709
1726
  mock.module("../lib/nginx-ingress.js", () => ({
1710
1727
  ...realNginxIngress,
@@ -1734,6 +1751,7 @@ describe("tunnel background mode", () => {
1734
1751
  port: EDGE_PORT,
1735
1752
  started: true,
1736
1753
  includesWebApp: true,
1754
+ shellMode: "web",
1737
1755
  });
1738
1756
  const configHome = mkdtempSync(join(tmpdir(), "cadet-tunnel-xdg-"));
1739
1757
  tempDirs.push(configHome);
@@ -1780,7 +1798,7 @@ describe("tunnel background mode", () => {
1780
1798
 
1781
1799
  expect(logs).toContain("Tunnel established: https://foo.ngrok.app");
1782
1800
  expect(logs).toContain("Running in background (pid 9999)");
1783
- expect(logs).toContain("Stop with: kill 9999");
1801
+ expect(logs).toContain("Stop with: cadet tunnel stop");
1784
1802
 
1785
1803
  // The parent hands the actual tunnel workflow off to the child: it never
1786
1804
  // runs the provider or edge setup itself.
@@ -1818,7 +1836,7 @@ describe("tunnel background mode", () => {
1818
1836
  const { exited, errors } = await runTunnelExpectingExit1();
1819
1837
 
1820
1838
  expect(exited).toBe(true);
1821
- expect(errors).toContain("exited during startup (exit code 1)");
1839
+ expect(errors).toContain("Error (1): tunnel process exited during startup");
1822
1840
  expect(runNgrokTunnelMock).not.toHaveBeenCalled();
1823
1841
  });
1824
1842
 
@@ -55,7 +55,7 @@ mock.module("../lib/assistant-config", () => ({
55
55
  }));
56
56
 
57
57
  const createBackupMock = mock<typeof backupOps.createBackup>(
58
- async () => "/tmp/local-pre-upgrade.vbundle",
58
+ async () => "/tmp/local-pre-upgrade.tar.gz",
59
59
  );
60
60
  const pruneOldBackupsMock = mock<typeof backupOps.pruneOldBackups>(() => {});
61
61
  const restoreBackupMock = mock<typeof backupOps.restoreBackup>(
@@ -238,7 +238,7 @@ beforeEach(() => {
238
238
  loadAllAssistantsMock.mockReturnValue([entry]);
239
239
  saveAssistantEntryMock.mockReset();
240
240
  createBackupMock.mockReset();
241
- createBackupMock.mockResolvedValue("/tmp/local-pre-upgrade.vbundle");
241
+ createBackupMock.mockResolvedValue("/tmp/local-pre-upgrade.tar.gz");
242
242
  pruneOldBackupsMock.mockReset();
243
243
  pruneOldBackupsMock.mockReturnValue(undefined);
244
244
  restoreBackupMock.mockReset();
@@ -372,7 +372,7 @@ describe("cadet upgrade local", () => {
372
372
  await upgrade();
373
373
 
374
374
  expect(loopbackSafeFetchMock).toHaveBeenCalledWith(
375
- "http://127.0.0.1:7830/healthz?include=migrations",
375
+ "http://127.0.0.1:7830/healthz",
376
376
  expect.any(Object),
377
377
  );
378
378
  expect(createBackupMock).toHaveBeenCalledWith(
@@ -382,14 +382,6 @@ describe("cadet upgrade local", () => {
382
382
  prefix: "local-assistant-pre-upgrade",
383
383
  }),
384
384
  );
385
- expect(saveAssistantEntryMock).toHaveBeenCalledWith(
386
- expect.objectContaining({
387
- previousVersion: "v0.8.11",
388
- previousDbMigrationVersion: 12,
389
- previousWorkspaceMigrationId: "011-test",
390
- preUpgradeBackupPath: "/tmp/local-pre-upgrade.vbundle",
391
- }),
392
- );
393
385
  expect(stopLocalProcessesMock).toHaveBeenCalledWith(
394
386
  expect.objectContaining({
395
387
  instanceDir: tempDir,
@@ -446,6 +438,24 @@ describe("cadet upgrade local", () => {
446
438
  expect(consoleLogSpy.mock.calls.flat().join("\n")).toContain("upgraded to");
447
439
  });
448
440
 
441
+ test("failed safety backup aborts before runtime installation or service replacement", async () => {
442
+ createBackupMock.mockResolvedValue(null);
443
+ await expect(upgrade()).rejects.toThrow("process.exit(1)");
444
+ expect(ensureLocalRuntimeMock).not.toHaveBeenCalled();
445
+ expect(stopLocalProcessesMock).not.toHaveBeenCalled();
446
+ expect(startLocalDaemonMock).not.toHaveBeenCalled();
447
+ });
448
+
449
+ test("failed target startup restores prior runtime selection and all roots before restarting", async () => {
450
+ startLocalDaemonMock.mockRejectedValueOnce(new Error("target startup failed"));
451
+ await expect(upgrade()).rejects.toThrow("process.exit(1)");
452
+ expect(restoreBackupMock).toHaveBeenCalledWith("http://127.0.0.1:7830", "local-assistant", "/tmp/local-pre-upgrade.tar.gz", { resume: false });
453
+ expect(startLocalDaemonMock).toHaveBeenCalledTimes(2);
454
+ expect(startCesMock).toHaveBeenCalledTimes(2);
455
+ expect(stopLocalProcessesMock).toHaveBeenCalledTimes(2);
456
+ expect(waitForReadyMock).toHaveBeenCalled();
457
+ });
458
+
449
459
  test("skips restart when the local assistant is already on the target version", async () => {
450
460
  loopbackSafeFetchMock.mockResolvedValue({
451
461
  ok: true,
@@ -156,10 +156,10 @@ describe("buildReplayState", () => {
156
156
  expect(state.signingKey).toBe("sign-key");
157
157
  });
158
158
 
159
- test("generates fresh secrets when missing from captured env", () => {
159
+ test("generates a service token but preserves file-backed signing identity when env is absent", () => {
160
160
  const state = buildReplayState({}, {});
161
161
  expect(state.bootstrapSecret).toBeUndefined();
162
162
  expect(state.cesServiceToken).toMatch(/^[0-9a-f]{64}$/);
163
- expect(state.signingKey).toMatch(/^[0-9a-f]{64}$/);
163
+ expect(state.signingKey).toBeUndefined();
164
164
  });
165
165
  });