@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
@@ -0,0 +1,69 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { pollingCloudLogin } from "../lib/cloud-login-poll.js";
3
+
4
+ const login = {
5
+ request_id: "a".repeat(64),
6
+ poll_secret: "b".repeat(64),
7
+ verification_uri: "https://example.com/account/cli?request=test",
8
+ user_code: "AAAAAAAA",
9
+ interval: 1,
10
+ };
11
+
12
+ describe("terminal login polling", () => {
13
+ test("prints the browser URL/code and waits for approval using a private poll secret", async () => {
14
+ const logs: string[] = [];
15
+ const bodies: unknown[] = [];
16
+ let polls = 0;
17
+ const request = async (url: string | URL | Request, init?: RequestInit) => {
18
+ bodies.push(init?.body);
19
+ if (String(url).endsWith("/start")) return Response.json(login);
20
+ return ++polls === 1
21
+ ? Response.json({ status: "pending" }, { status: 202 })
22
+ : Response.json({ session_token: "session-test" });
23
+ };
24
+ expect(
25
+ await pollingCloudLogin("https://cloud.example.com", {
26
+ fetch: request,
27
+ log: (line) => logs.push(line),
28
+ }),
29
+ ).toBe("session-test");
30
+ expect(polls).toBe(2);
31
+ expect(logs.join("\n")).toContain(login.verification_uri);
32
+ expect(logs.join("\n")).toContain(login.user_code);
33
+ expect(logs.join("\n")).not.toContain(login.poll_secret);
34
+ expect(JSON.parse(String(bodies[1]))).toEqual({
35
+ request_id: login.request_id,
36
+ poll_secret: login.poll_secret,
37
+ });
38
+ });
39
+
40
+ test("times out even while a network request is hung", async () => {
41
+ const request = async (_url: unknown, init?: RequestInit) =>
42
+ new Promise<Response>((_resolve, reject) => {
43
+ init?.signal?.addEventListener(
44
+ "abort",
45
+ () => reject(new Error("aborted")),
46
+ { once: true },
47
+ );
48
+ });
49
+ await expect(
50
+ pollingCloudLogin("https://example.com", {
51
+ fetch: request,
52
+ timeoutMs: 20,
53
+ }),
54
+ ).rejects.toThrow("Login timed out after 5 minutes");
55
+ });
56
+
57
+ test("expired approval fails without returning a token", async () => {
58
+ const request = async (url: unknown) =>
59
+ String(url).endsWith("/start")
60
+ ? Response.json(login)
61
+ : Response.json({}, { status: 410 });
62
+ await expect(
63
+ pollingCloudLogin("https://example.com", {
64
+ fetch: request,
65
+ log: () => {},
66
+ }),
67
+ ).rejects.toThrow("expired");
68
+ });
69
+ });
@@ -13,6 +13,8 @@ import {
13
13
  isStagingVersion,
14
14
  } from "../lib/version-compat.js";
15
15
 
16
+ import { CADET_GITHUB_REPOSITORY } from "../lib/github-repository.js";
17
+
16
18
  const originalFetch = globalThis.fetch;
17
19
 
18
20
  function mockFetchJson(body: unknown, status = 200) {
@@ -207,3 +209,83 @@ describe("fetchLatestVersion", () => {
207
209
  expect(await fetchLatestVersion("preview")).toBeNull();
208
210
  });
209
211
  });
212
+
213
+ describe("stable release fallback", () => {
214
+ function withGitHubFallback(cloudBody: unknown, cloudStatus = 200) {
215
+ const fetchMock = mock(async (url: RequestInfo | URL) => {
216
+ if (String(url).startsWith("https://api.github.com/")) {
217
+ return Response.json({
218
+ tag_name: "v0.12.11",
219
+ draft: false,
220
+ prerelease: false,
221
+ });
222
+ }
223
+ return Response.json(cloudBody, { status: cloudStatus });
224
+ });
225
+ globalThis.fetch = Object.assign(fetchMock, {
226
+ preconnect: originalFetch.preconnect,
227
+ });
228
+ return fetchMock;
229
+ }
230
+
231
+ test.each([
232
+ [404, { detail: "not found" }],
233
+ [503, { detail: "unavailable" }],
234
+ [200, []],
235
+ [200, { results: [] }],
236
+ [200, [{ version: "not-a-version" }]],
237
+ [200, [{ version: "0.13.0-preview.1" }]],
238
+ [200, [{ version: "0.13.0", is_stable: false }]],
239
+ ])("falls back for cloud status %s and body %j", async (status, body) => {
240
+ const fetchMock = withGitHubFallback(body, status);
241
+ expect(await fetchLatestVersion()).toBe("0.12.11");
242
+ expect(fetchMock).toHaveBeenCalledTimes(2);
243
+ expect(String(fetchMock.mock.calls[1][0])).toBe(
244
+ `https://api.github.com/repos/${CADET_GITHUB_REPOSITORY}/releases/latest`,
245
+ );
246
+ });
247
+
248
+ test("falls back when the cloud request throws", async () => {
249
+ const fetchMock = withGitHubFallback([]);
250
+ globalThis.fetch = Object.assign(
251
+ mock(async (url: RequestInfo | URL) => {
252
+ if (!String(url).startsWith("https://api.github.com/")) {
253
+ throw new TypeError("fetch failed");
254
+ }
255
+ return fetchMock(url);
256
+ }),
257
+ { preconnect: originalFetch.preconnect },
258
+ );
259
+ expect(await fetchLatestVersion()).toBe("0.12.11");
260
+ });
261
+
262
+ test("does not contact GitHub when cloud discovery succeeds", async () => {
263
+ const fetchMock = withGitHubFallback([RELEASE]);
264
+ expect(await fetchLatestVersion()).toBe(RELEASE.version);
265
+ expect(fetchMock).toHaveBeenCalledTimes(1);
266
+ });
267
+
268
+ test("does not substitute stable for an unavailable preview channel", async () => {
269
+ const fetchMock = withGitHubFallback([], 503);
270
+ expect(await fetchLatestVersion("preview")).toBeNull();
271
+ expect(fetchMock).toHaveBeenCalledTimes(1);
272
+ });
273
+
274
+ test.each([
275
+ { tag_name: "v0.13.0", draft: true, prerelease: false },
276
+ { tag_name: "v0.13.0", draft: false, prerelease: true },
277
+ { tag_name: "v0.13.0-preview.1", draft: false, prerelease: false },
278
+ { tag_name: "not-a-version", draft: false, prerelease: false },
279
+ { tag_name: "v0.13.0" },
280
+ ])("rejects invalid GitHub stable release %j", async (body) => {
281
+ mockFetchJson(body);
282
+ expect(await fetchLatestVersion()).toBeNull();
283
+ });
284
+
285
+ test("returns null when both services fail", async () => {
286
+ mockFetchError();
287
+ expect(await fetchLatestVersion()).toBeNull();
288
+ mockFetchJson({ message: "rate limited" }, 403);
289
+ expect(await fetchLatestVersion()).toBeNull();
290
+ });
291
+ });
@@ -0,0 +1,101 @@
1
+ import type { ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ import * as childProcess from "node:child_process";
4
+ import { PassThrough } from "node:stream";
5
+ import { spyOn } from "bun:test";
6
+
7
+ export interface CommandRequest {
8
+ command: string;
9
+ args: string[];
10
+ input: Buffer;
11
+ }
12
+ export interface CommandResponse {
13
+ stdout?: string;
14
+ stderr?: string;
15
+ code?: number;
16
+ }
17
+
18
+ /** requests-mock pattern at the subprocess boundary: explicit registrations, history and no fallthrough to real commands. */
19
+ export function mockCommands() {
20
+ const calls: CommandRequest[] = [];
21
+ const handlers: Array<{
22
+ command: string;
23
+ match: (args: string[]) => boolean;
24
+ respond: (
25
+ request: CommandRequest,
26
+ ) => CommandResponse | Promise<CommandResponse>;
27
+ }> = [];
28
+ const implementation = (command: string, argsOrOptions: unknown = []) => {
29
+ const args: string[] = Array.isArray(argsOrOptions) ? argsOrOptions : [];
30
+ const request: CommandRequest = {
31
+ command,
32
+ args: [...args],
33
+ input: Buffer.alloc(0),
34
+ };
35
+ calls.push(request);
36
+ const handler = [...handlers]
37
+ .reverse()
38
+ .find((entry) => entry.command === command && entry.match(request.args));
39
+ if (!handler) throw new Error(`Unregistered command: ${command}`);
40
+ const stdin = new PassThrough(),
41
+ stdout = new PassThrough(),
42
+ stderr = new PassThrough();
43
+ const child = Object.assign(new EventEmitter(), {
44
+ stdin,
45
+ stdout,
46
+ stderr,
47
+ stdio: [stdin, stdout, stderr],
48
+ kill: () => true,
49
+ });
50
+ const chunks: Buffer[] = [];
51
+ stdin.on("data", (chunk: Buffer) => chunks.push(chunk));
52
+ const respond = async () => {
53
+ request.input = Buffer.concat(chunks);
54
+ try {
55
+ const result = await handler.respond(request);
56
+ stdout.end(result.stdout ?? "");
57
+ stderr.end(result.stderr ?? "");
58
+ child.emit("close", result.code ?? 0);
59
+ } catch (error) {
60
+ child.emit("error", error);
61
+ child.emit("close", 1);
62
+ }
63
+ };
64
+ if (request.args.includes("-i"))
65
+ stdin.once("finish", () => {
66
+ void respond();
67
+ });
68
+ else
69
+ queueMicrotask(() => {
70
+ void respond();
71
+ });
72
+ return child as unknown as ChildProcessWithoutNullStreams;
73
+ };
74
+ const spawn = spyOn(childProcess, "spawn").mockImplementation(
75
+ implementation as typeof childProcess.spawn,
76
+ );
77
+ return {
78
+ calls,
79
+ on(
80
+ command: string,
81
+ args: string[] | ((args: string[]) => boolean),
82
+ response:
83
+ | CommandResponse
84
+ | ((
85
+ request: CommandRequest,
86
+ ) => CommandResponse | Promise<CommandResponse>),
87
+ ) {
88
+ handlers.push({
89
+ command,
90
+ match:
91
+ typeof args === "function"
92
+ ? args
93
+ : (actual) => JSON.stringify(actual) === JSON.stringify(args),
94
+ respond: typeof response === "function" ? response : () => response,
95
+ });
96
+ },
97
+ restore() {
98
+ spawn.mockRestore();
99
+ },
100
+ };
101
+ }
@@ -18,7 +18,6 @@ process.env.CADET_LOCKFILE_DIR = testDir;
18
18
 
19
19
  import * as assistantConfig from "../lib/assistant-config.js";
20
20
  import * as guardianToken from "../lib/guardian-token.js";
21
- import * as localRuntimeClient from "../lib/local-runtime-client.js";
22
21
  import * as cloudClient from "../lib/cloud-client.js";
23
22
  import { withPreconnect } from "./helpers/http.js";
24
23
  import { makeGuardianTokenData } from "./helpers/guardian-token.js";
@@ -29,26 +28,9 @@ const resolveTargetMock = spyOn(
29
28
  ).mockImplementation(() => {
30
29
  throw new Error("process.exit:1");
31
30
  });
32
- const readCloudTokenMock = spyOn(
33
- cloudClient,
34
- "readCloudToken",
35
- ).mockReturnValue("platform-token");
36
- const exportMock = spyOn(
37
- localRuntimeClient,
38
- "localRuntimeExportToGcs",
39
- ).mockResolvedValue({ jobId: "job-1" });
40
- const identityMock = spyOn(
41
- localRuntimeClient,
42
- "localRuntimeIdentity",
43
- ).mockResolvedValue({ version: "0.12.3" });
44
- const pollMock = spyOn(
45
- localRuntimeClient,
46
- "localRuntimePollJobStatus",
47
- ).mockResolvedValue({
48
- jobId: "job-1",
49
- type: "export",
50
- status: "complete",
51
- });
31
+ const readCloudTokenMock = spyOn(cloudClient, "readCloudToken").mockReturnValue(
32
+ "platform-token",
33
+ );
52
34
  const loadGuardianTokenSpy = spyOn(
53
35
  guardianToken,
54
36
  "loadGuardianToken",
@@ -109,13 +91,9 @@ beforeEach(() => {
109
91
  );
110
92
  resolveTargetMock.mockReset();
111
93
  resolveTargetMock.mockReturnValue(LOCAL_ENTRY);
112
- identityMock.mockReset();
113
- identityMock.mockResolvedValue({ version: "0.12.3" });
114
94
  leaseGuardianTokenSpy.mockClear();
115
95
  readCloudTokenMock.mockReset();
116
96
  readCloudTokenMock.mockReturnValue("platform-token");
117
- exportMock.mockClear();
118
- pollMock.mockClear();
119
97
  });
120
98
 
121
99
  afterEach(() => {
@@ -125,40 +103,29 @@ afterEach(() => {
125
103
 
126
104
  afterAll(() => {
127
105
  resolveTargetMock.mockRestore();
128
- identityMock.mockRestore();
129
106
  leaseGuardianTokenSpy.mockRestore();
130
107
  readCloudTokenMock.mockRestore();
131
- exportMock.mockRestore();
132
- pollMock.mockRestore();
133
108
  loadGuardianTokenSpy.mockRestore();
134
109
  rmSync(testDir, { recursive: true, force: true });
135
110
  });
136
111
 
137
112
  describe("cadet debug", () => {
138
- test("mints the URL on the cloud, exports with the debug profile, and waits", async () => {
113
+ test("requests sanitized logs and uploads them without runtime export jobs", async () => {
139
114
  await debug();
140
-
141
- expect(fetchCalls).toHaveLength(1);
142
- expect(fetchCalls[0].url).toBe(
115
+ expect(fetchCalls.map((call) => call.url)).toEqual([
143
116
  "https://cloud.mycadet.ai/v1/assistants/11111111-2222-3333-4444-555555555555/debug-bundle-upload-url/",
117
+ "http://127.0.0.1:7821/v1/logs/export",
118
+ "https://storage.googleapis.com/b/signed",
119
+ ]);
120
+ expect(fetchCalls[1].init?.method).toBe("POST");
121
+ expect(new Headers(fetchCalls[1].init?.headers).get("Authorization")).toBe(
122
+ "Bearer local-token",
144
123
  );
145
- expect(fetchCalls[0].init?.method).toBe("POST");
146
- expect(
147
- new Headers(fetchCalls[0].init?.headers).get("Cadet-Organization-Id"),
148
- ).toBe("org-1");
149
-
150
- expect(exportMock).toHaveBeenCalledTimes(1);
151
- const [entry, token, params] = exportMock.mock.calls[0];
152
- expect(entry).toBe(LOCAL_ENTRY);
153
- expect(token).toBe("local-token");
154
- expect(params).toEqual({
155
- uploadUrl: "https://storage.googleapis.com/b/signed",
156
- description: "debug bundle for Cadet staff",
157
- profile: "debug",
158
- });
159
- expect(pollMock).toHaveBeenCalled();
160
- // The daemon was checked for the debug profile before the cloud call.
161
- expect(identityMock).toHaveBeenCalledTimes(1);
124
+ expect(fetchCalls[2].init?.method).toBe("PUT");
125
+ expect(new Headers(fetchCalls[2].init?.headers).has("Authorization")).toBe(
126
+ false,
127
+ );
128
+ expect(fetchCalls[2].init?.body).toBeInstanceOf(Blob);
162
129
  });
163
130
 
164
131
  test("passes an unquoted multi-word display name through to the shared resolver", async () => {
@@ -167,46 +134,6 @@ describe("cadet debug", () => {
167
134
  expect(resolveTargetMock).toHaveBeenCalledWith("Support Bot");
168
135
  });
169
136
 
170
- test("refuses a daemon older than the debug profile before any URL is minted", async () => {
171
- // Such a daemon strips `profile` and would upload the owner's
172
- // credentials to the staff bucket.
173
- identityMock.mockResolvedValue({ version: "0.12.2" });
174
- const errors: string[] = [];
175
- const errorSpy = spyOn(console, "error").mockImplementation((msg) => {
176
- errors.push(String(msg));
177
- });
178
- try {
179
- await expect(debug()).rejects.toThrow("process.exit:1");
180
- } finally {
181
- errorSpy.mockRestore();
182
- }
183
- expect(errors.join("\n")).toContain("0.12.3");
184
- expect(fetchCalls).toHaveLength(0);
185
- expect(exportMock).not.toHaveBeenCalled();
186
- });
187
-
188
- test("re-leases the guardian token when the daemon answers 401 mid-poll", async () => {
189
- pollMock.mockReset();
190
- let polls = 0;
191
- pollMock.mockImplementation(async (_entry, token) => {
192
- polls += 1;
193
- if (polls === 1) {
194
- throw new Error("Local job status check failed: 401 Unauthorized");
195
- }
196
- expect(token).toBe("leased-token");
197
- return {
198
- jobId: "job-1",
199
- type: "export",
200
- status: "complete",
201
- };
202
- });
203
-
204
- await debug();
205
-
206
- expect(leaseGuardianTokenSpy).toHaveBeenCalledTimes(1);
207
- expect(polls).toBe(2);
208
- });
209
-
210
137
  test("explains what to turn on when the cloud refuses", async () => {
211
138
  uploadUrlStatus = 403;
212
139
  const errors: string[] = [];
@@ -219,13 +146,12 @@ describe("cadet debug", () => {
219
146
  errorSpy.mockRestore();
220
147
  }
221
148
  expect(errors.join("\n")).toContain("Allow Staff Access");
222
- expect(exportMock).not.toHaveBeenCalled();
223
149
  });
224
150
 
225
151
  test("refuses a Cadet-hosted assistant, which needs no bundle", async () => {
226
152
  resolveTargetMock.mockReturnValue({
227
153
  ...LOCAL_ENTRY,
228
- cloud: "cadet",
154
+ cloud: "cloud",
229
155
  });
230
156
  const errorSpy = spyOn(console, "error").mockImplementation(() => {});
231
157
  try {
@@ -26,6 +26,7 @@ const ORIGINAL_FETCH = globalThis.fetch;
26
26
 
27
27
  import { createHash } from "node:crypto";
28
28
 
29
+ import * as stepRunner from "../lib/step-runner.js";
29
30
  import { devices } from "../commands/devices.js";
30
31
  import { saveAssistantEntry } from "../lib/assistant-config.js";
31
32
  import { computeDeviceId } from "../lib/guardian-token.js";
@@ -102,6 +103,67 @@ describe("cadet devices", () => {
102
103
  stubFetch(() => jsonResponse({ error: "unexpected" }, 500));
103
104
  });
104
105
 
106
+ test("Docker devices list and revoke execute inside the gateway without a host port", async () => {
107
+ saveAssistantEntry({
108
+ assistantId: "docker-fixture",
109
+ cloud: "docker",
110
+ runtimeUrl: "https://tunnel.example",
111
+ });
112
+ const calls: string[][] = [];
113
+ const docker = spyOn(stepRunner, "execOutput").mockImplementation(
114
+ async (_command, args) => {
115
+ calls.push(args);
116
+ return JSON.stringify({
117
+ status: 200,
118
+ body: args.some((arg) => arg.endsWith("/revoke"))
119
+ ? '{"revoked":true}'
120
+ : '{"devices":[]}',
121
+ });
122
+ },
123
+ );
124
+ try {
125
+ process.argv = ["bun", "cadet", "devices", "docker-fixture", "--json"];
126
+ expect((await runDevices()).exited).toBe(false);
127
+ process.argv = [
128
+ "bun",
129
+ "cadet",
130
+ "devices",
131
+ "revoke",
132
+ "fixture-device",
133
+ "docker-fixture",
134
+ "--yes",
135
+ "--json",
136
+ ];
137
+ expect((await runDevices()).exited).toBe(false);
138
+ expect(calls).toHaveLength(2);
139
+ expect(calls[0].slice(0, 2)).toEqual(["exec", "docker-fixture-gateway"]);
140
+ expect(calls[0]).toContain("http://127.0.0.1:7830/v1/devices");
141
+ expect(calls[1]).toContain("http://127.0.0.1:7830/v1/devices/revoke");
142
+ expect(fetchCalls).toHaveLength(0);
143
+ } finally {
144
+ docker.mockRestore();
145
+ }
146
+ });
147
+
148
+ test("Docker gateway output rejects malformed status and body fields", async () => {
149
+ saveAssistantEntry({
150
+ assistantId: "docker-invalid",
151
+ cloud: "docker",
152
+ runtimeUrl: "https://tunnel.example",
153
+ });
154
+ const docker = spyOn(stepRunner, "execOutput").mockResolvedValue(
155
+ '{"status":"200","body":{}}',
156
+ );
157
+ try {
158
+ process.argv = ["bun", "cadet", "devices", "docker-invalid", "--json"];
159
+ const result = await runDevices();
160
+ expect(result.exited).toBe(true);
161
+ expect(result.errors).toContain("could not reach the gateway");
162
+ } finally {
163
+ docker.mockRestore();
164
+ }
165
+ });
166
+
105
167
  afterEach(() => {
106
168
  process.argv = [...ORIGINAL_ARGV];
107
169
  globalThis.fetch = ORIGINAL_FETCH;
@@ -358,7 +420,7 @@ describe("cadet devices", () => {
358
420
  expect(fetchCalls).toHaveLength(0);
359
421
  });
360
422
 
361
- test("host-gates a paired connection (points to the host / unpair)", async () => {
423
+ test("host-gates a paired connection (points to the host)", async () => {
362
424
  saveAssistantEntry({
363
425
  assistantId: "paired-box",
364
426
  name: "Paired Box",
@@ -371,7 +433,7 @@ describe("cadet devices", () => {
371
433
  const { exited, errors } = await runDevices();
372
434
 
373
435
  expect(exited).toBe(true);
374
- expect(errors).toContain("cadet unpair");
436
+ expect(errors).toContain("cadet devices");
375
437
  expect(fetchCalls).toHaveLength(0);
376
438
  });
377
439
 
@@ -180,3 +180,55 @@ describe("readLineText", () => {
180
180
  expect(await pending).toBe("");
181
181
  });
182
182
  });
183
+
184
+ test("a Mac without Homebrew asks separately before bootstrapping and installing nginx", async () => {
185
+ const installs: string[][] = [];
186
+ let homebrew = false;
187
+ let nginx = false;
188
+ let brewPrompts = 0;
189
+ const version = await ensureBinaryInstalled({
190
+ binary: "nginx",
191
+ purpose: "the tunnel edge",
192
+ platform: "darwin",
193
+ packages: { brew: ["brew", "install", "nginx"] },
194
+ manualHint: "install nginx",
195
+ probe: () => (nginx ? "nginx 1.2" : null),
196
+ pmAvailable: (pm) => pm === "brew" && homebrew,
197
+ confirmInstall: async () => true,
198
+ confirmHomebrewInstall: async () => {
199
+ brewPrompts++;
200
+ return true;
201
+ },
202
+ runInstall: (argv) => {
203
+ installs.push(argv);
204
+ if (argv[0] === "/bin/bash") homebrew = true;
205
+ if (argv[0] === "brew") nginx = true;
206
+ return true;
207
+ },
208
+ });
209
+ expect(version).toBe("nginx 1.2");
210
+ expect(brewPrompts).toBe(1);
211
+ expect(installs.length).toBe(2);
212
+ expect(installs[1]).toEqual(["brew", "install", "nginx"]);
213
+ });
214
+
215
+ test("declining Homebrew runs no installer", async () => {
216
+ const installs: string[][] = [];
217
+ const result = await ensureBinaryInstalled({
218
+ binary: "ngrok",
219
+ purpose: "a tunnel",
220
+ platform: "darwin",
221
+ packages: { brew: ["brew", "install", "ngrok/ngrok/ngrok"] },
222
+ manualHint: "install ngrok",
223
+ probe: () => null,
224
+ pmAvailable: () => false,
225
+ confirmInstall: async () => true,
226
+ confirmHomebrewInstall: async () => false,
227
+ runInstall: (argv) => {
228
+ installs.push(argv);
229
+ return true;
230
+ },
231
+ });
232
+ expect(result).toBeNull();
233
+ expect(installs).toEqual([]);
234
+ });