@mycadet/cli 0.12.10 → 0.12.11

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 (41) hide show
  1. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
  2. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
  3. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
  4. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
  5. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
  6. package/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/cloud-login-poll.test.ts +69 -0
  9. package/src/__tests__/devices.test.ts +62 -0
  10. package/src/__tests__/ensure-binary.test.ts +52 -0
  11. package/src/__tests__/hatch-next-steps.test.ts +38 -0
  12. package/src/__tests__/multi-local.test.ts +3 -3
  13. package/src/__tests__/nginx-ingress.test.ts +55 -17
  14. package/src/__tests__/ps-cloud-status.test.ts +2 -1
  15. package/src/__tests__/setup.test.ts +14 -4
  16. package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
  17. package/src/__tests__/tunnel-stop.test.ts +226 -0
  18. package/src/__tests__/tunnel.test.ts +12 -2
  19. package/src/commands/devices.ts +66 -30
  20. package/src/commands/hatch.ts +10 -52
  21. package/src/commands/pair.ts +7 -1
  22. package/src/commands/ps.ts +4 -2
  23. package/src/commands/tunnel.ts +43 -4
  24. package/src/commands/upgrade.ts +6 -1
  25. package/src/lib/assistant-config.ts +20 -5
  26. package/src/lib/cloud-login-poll.ts +75 -0
  27. package/src/lib/cloud-login.ts +3 -1
  28. package/src/lib/docker.ts +6 -12
  29. package/src/lib/ensure-binary.ts +65 -6
  30. package/src/lib/hatch-local.ts +1 -51
  31. package/src/lib/hatch-next-steps.ts +15 -2
  32. package/src/lib/live-voice/tunnel.ts +2 -2
  33. package/src/lib/local.ts +6 -6
  34. package/src/lib/nginx-ingress.ts +123 -21
  35. package/src/lib/tunnel.ts +86 -34
  36. package/src/lib/tunnels/cadet.ts +12 -1
  37. package/src/lib/tunnels/cloudflare.ts +36 -1
  38. package/src/lib/tunnels/ngrok.ts +16 -2
  39. package/src/lib/tunnels/process-lifecycle.ts +26 -0
  40. package/src/lib/tunnels/tailscale.ts +79 -1
  41. package/src/lib/xdg-log.ts +11 -0
@@ -10,7 +10,7 @@ import {
10
10
  trimmedNonEmptyString,
11
11
  TUNNEL_PROVIDERS,
12
12
  type TunnelRecord,
13
- tunnelHostForPlatformHost,
13
+ tunnelHostForCloudHost,
14
14
  } from "../ingress.js";
15
15
  import {
16
16
  buildTwilioMediaStreamUrl,
@@ -19,27 +19,27 @@ import {
19
19
  resolveTwilioPublicBaseUrl,
20
20
  } from "../twilio-ingress.js";
21
21
 
22
- describe("tunnelHostForPlatformHost", () => {
22
+ describe("tunnelHostForCloudHost", () => {
23
23
  test("maps the prod platform host to the prod tunnel", () => {
24
- expect(tunnelHostForPlatformHost("cloud.mycadet.ai")).toBe(
24
+ expect(tunnelHostForCloudHost("cloud.mycadet.ai")).toBe(
25
25
  "tunnel.mycadet.ai",
26
26
  );
27
27
  });
28
28
 
29
29
  test("maps env-prefixed platform hosts to their env tunnel", () => {
30
- expect(tunnelHostForPlatformHost("staging-cloud.mycadet.ai")).toBe(
30
+ expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
31
31
  "tunnel-staging.mycadet.ai",
32
32
  );
33
- expect(tunnelHostForPlatformHost("dev-cloud.mycadet.ai")).toBe(
33
+ expect(tunnelHostForCloudHost("dev-cloud.mycadet.ai")).toBe(
34
34
  "tunnel-dev.mycadet.ai",
35
35
  );
36
36
  });
37
37
 
38
38
  test("returns null for hosts outside the deployment convention", () => {
39
- expect(tunnelHostForPlatformHost("localhost")).toBeNull();
40
- expect(tunnelHostForPlatformHost("platform.example.com")).toBeNull();
39
+ expect(tunnelHostForCloudHost("localhost")).toBeNull();
40
+ expect(tunnelHostForCloudHost("platform.example.com")).toBeNull();
41
41
  expect(
42
- tunnelHostForPlatformHost("staging-cloud.mycadet.ai.evil.com"),
42
+ tunnelHostForCloudHost("staging-cloud.mycadet.ai.evil.com"),
43
43
  ).toBeNull();
44
44
  });
45
45
  });
@@ -6,7 +6,7 @@
6
6
  * gateway speech relay and the web live-voice client so the two ends of the
7
7
  * cloud-speech transport can't drift.
8
8
  */
9
- export function tunnelHostForPlatformHost(host: string): string | null {
9
+ export function tunnelHostForCloudHost(host: string): string | null {
10
10
  if (host === "cloud.mycadet.ai") {
11
11
  return "tunnel.mycadet.ai";
12
12
  }
@@ -10,7 +10,7 @@ import {
10
10
  trimmedNonEmptyString,
11
11
  TUNNEL_PROVIDERS,
12
12
  type TunnelRecord,
13
- tunnelHostForPlatformHost,
13
+ tunnelHostForCloudHost,
14
14
  } from "../ingress.js";
15
15
  import {
16
16
  buildTwilioMediaStreamUrl,
@@ -19,27 +19,27 @@ import {
19
19
  resolveTwilioPublicBaseUrl,
20
20
  } from "../twilio-ingress.js";
21
21
 
22
- describe("tunnelHostForPlatformHost", () => {
22
+ describe("tunnelHostForCloudHost", () => {
23
23
  test("maps the prod platform host to the prod tunnel", () => {
24
- expect(tunnelHostForPlatformHost("cloud.mycadet.ai")).toBe(
24
+ expect(tunnelHostForCloudHost("cloud.mycadet.ai")).toBe(
25
25
  "tunnel.mycadet.ai",
26
26
  );
27
27
  });
28
28
 
29
29
  test("maps env-prefixed platform hosts to their env tunnel", () => {
30
- expect(tunnelHostForPlatformHost("staging-cloud.mycadet.ai")).toBe(
30
+ expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
31
31
  "tunnel-staging.mycadet.ai",
32
32
  );
33
- expect(tunnelHostForPlatformHost("dev-cloud.mycadet.ai")).toBe(
33
+ expect(tunnelHostForCloudHost("dev-cloud.mycadet.ai")).toBe(
34
34
  "tunnel-dev.mycadet.ai",
35
35
  );
36
36
  });
37
37
 
38
38
  test("returns null for hosts outside the deployment convention", () => {
39
- expect(tunnelHostForPlatformHost("localhost")).toBeNull();
40
- expect(tunnelHostForPlatformHost("platform.example.com")).toBeNull();
39
+ expect(tunnelHostForCloudHost("localhost")).toBeNull();
40
+ expect(tunnelHostForCloudHost("platform.example.com")).toBeNull();
41
41
  expect(
42
- tunnelHostForPlatformHost("staging-cloud.mycadet.ai.evil.com"),
42
+ tunnelHostForCloudHost("staging-cloud.mycadet.ai.evil.com"),
43
43
  ).toBeNull();
44
44
  });
45
45
  });
@@ -6,7 +6,7 @@
6
6
  * gateway speech relay and the web live-voice client so the two ends of the
7
7
  * cloud-speech transport can't drift.
8
8
  */
9
- export function tunnelHostForPlatformHost(host: string): string | null {
9
+ export function tunnelHostForCloudHost(host: string): string | null {
10
10
  if (host === "cloud.mycadet.ai") {
11
11
  return "tunnel.mycadet.ai";
12
12
  }
@@ -10,7 +10,7 @@ import {
10
10
  trimmedNonEmptyString,
11
11
  TUNNEL_PROVIDERS,
12
12
  type TunnelRecord,
13
- tunnelHostForPlatformHost,
13
+ tunnelHostForCloudHost,
14
14
  } from "../ingress.js";
15
15
  import {
16
16
  buildTwilioMediaStreamUrl,
@@ -19,27 +19,27 @@ import {
19
19
  resolveTwilioPublicBaseUrl,
20
20
  } from "../twilio-ingress.js";
21
21
 
22
- describe("tunnelHostForPlatformHost", () => {
22
+ describe("tunnelHostForCloudHost", () => {
23
23
  test("maps the prod platform host to the prod tunnel", () => {
24
- expect(tunnelHostForPlatformHost("cloud.mycadet.ai")).toBe(
24
+ expect(tunnelHostForCloudHost("cloud.mycadet.ai")).toBe(
25
25
  "tunnel.mycadet.ai",
26
26
  );
27
27
  });
28
28
 
29
29
  test("maps env-prefixed platform hosts to their env tunnel", () => {
30
- expect(tunnelHostForPlatformHost("staging-cloud.mycadet.ai")).toBe(
30
+ expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
31
31
  "tunnel-staging.mycadet.ai",
32
32
  );
33
- expect(tunnelHostForPlatformHost("dev-cloud.mycadet.ai")).toBe(
33
+ expect(tunnelHostForCloudHost("dev-cloud.mycadet.ai")).toBe(
34
34
  "tunnel-dev.mycadet.ai",
35
35
  );
36
36
  });
37
37
 
38
38
  test("returns null for hosts outside the deployment convention", () => {
39
- expect(tunnelHostForPlatformHost("localhost")).toBeNull();
40
- expect(tunnelHostForPlatformHost("platform.example.com")).toBeNull();
39
+ expect(tunnelHostForCloudHost("localhost")).toBeNull();
40
+ expect(tunnelHostForCloudHost("platform.example.com")).toBeNull();
41
41
  expect(
42
- tunnelHostForPlatformHost("staging-cloud.mycadet.ai.evil.com"),
42
+ tunnelHostForCloudHost("staging-cloud.mycadet.ai.evil.com"),
43
43
  ).toBeNull();
44
44
  });
45
45
  });
@@ -6,7 +6,7 @@
6
6
  * gateway speech relay and the web live-voice client so the two ends of the
7
7
  * cloud-speech transport can't drift.
8
8
  */
9
- export function tunnelHostForPlatformHost(host: string): string | null {
9
+ export function tunnelHostForCloudHost(host: string): string | null {
10
10
  if (host === "cloud.mycadet.ai") {
11
11
  return "tunnel.mycadet.ai";
12
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mycadet/cli",
3
- "version": "0.12.10",
3
+ "version": "0.12.11",
4
4
  "description": "CLI tools for mycadet",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
+ });
@@ -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;
@@ -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
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { logHatchNextSteps } from "../lib/hatch-next-steps";
4
+
5
+ function captureNextSteps(): string[] {
6
+ const lines: string[] = [];
7
+ logHatchNextSteps((message) => lines.push(message));
8
+ return lines;
9
+ }
10
+
11
+ describe("logHatchNextSteps", () => {
12
+ test("names the production binary in a production environment", () => {
13
+ const prev = process.env.CADET_ENVIRONMENT;
14
+ try {
15
+ process.env.CADET_ENVIRONMENT = "production";
16
+ const lines = captureNextSteps();
17
+ expect(lines.join("\n")).toContain("`cadet client`");
18
+ expect(lines.join("\n")).toContain("`cadet tunnel`");
19
+ expect(lines.join("\n")).not.toContain("cadet-dev");
20
+ } finally {
21
+ if (prev === undefined) delete process.env.CADET_ENVIRONMENT;
22
+ else process.env.CADET_ENVIRONMENT = prev;
23
+ }
24
+ });
25
+
26
+ test("names the environment-suffixed binary in dev", () => {
27
+ const prev = process.env.CADET_ENVIRONMENT;
28
+ try {
29
+ process.env.CADET_ENVIRONMENT = "dev";
30
+ const lines = captureNextSteps();
31
+ expect(lines.join("\n")).toContain("`cadet-dev client`");
32
+ expect(lines.join("\n")).toContain("`cadet-dev tunnel`");
33
+ } finally {
34
+ if (prev === undefined) delete process.env.CADET_ENVIRONMENT;
35
+ else process.env.CADET_ENVIRONMENT = prev;
36
+ }
37
+ });
38
+ });
@@ -43,14 +43,14 @@ afterAll(() => {
43
43
 
44
44
  function writeLockfile(data: unknown): void {
45
45
  writeFileSync(
46
- join(testDir, ".cadet.lock.json"),
46
+ join(testDir, "lockfile.json"),
47
47
  JSON.stringify(data, null, 2),
48
48
  );
49
49
  }
50
50
 
51
51
  function readLockfileRaw(): Record<string, unknown> {
52
52
  return JSON.parse(
53
- readFileSync(join(testDir, ".cadet.lock.json"), "utf-8"),
53
+ readFileSync(join(testDir, "lockfile.json"), "utf-8"),
54
54
  ) as Record<string, unknown>;
55
55
  }
56
56
 
@@ -67,7 +67,7 @@ const makeEntry = (
67
67
 
68
68
  function resetLockfile(): void {
69
69
  try {
70
- rmSync(join(testDir, ".cadet.lock.json"));
70
+ rmSync(join(testDir, "lockfile.json"));
71
71
  } catch {
72
72
  // file may not exist
73
73
  }
@@ -76,6 +76,7 @@ import {
76
76
  MAX_PRESERVED_MODULE_PRELOADS,
77
77
  ensureTunnelEdge,
78
78
  EDGE_TEMPLATE_VERSION,
79
+ findWebDistDir,
79
80
  startRemoteWebIngress,
80
81
  stopContainerTunnelEdge,
81
82
  stopIngressNginx,
@@ -456,9 +457,7 @@ describe("buildRemoteWebIndexHtml", () => {
456
457
  expect(result).toContain(
457
458
  'window.__CADET_CONFIG__={"remote":"docker","apiBaseUrl":"/v1"}',
458
459
  );
459
- expect(result.indexOf('"webUrl"')).toBeLessThan(
460
- result.indexOf('"docker"'),
461
- );
460
+ expect(result.indexOf('"webUrl"')).toBeLessThan(result.indexOf('"docker"'));
462
461
  });
463
462
 
464
463
  test("escapes config JSON before embedding it in a script tag", () => {
@@ -530,21 +529,13 @@ describe("buildRemoteWebIndexHtml", () => {
530
529
 
531
530
  describe("cloudWebHubUrl", () => {
532
531
  test("maps build environments to their cloud SPA base", () => {
533
- expect(cloudWebHubUrl("production")).toBe(
534
- "https://mycadet.ai/assistant",
535
- );
532
+ expect(cloudWebHubUrl("production")).toBe("https://mycadet.ai/assistant");
536
533
  expect(cloudWebHubUrl("staging")).toBe(
537
534
  "https://staging.mycadet.ai/assistant",
538
535
  );
539
- expect(cloudWebHubUrl("dev")).toBe(
540
- "https://dev.mycadet.ai/assistant",
541
- );
542
- expect(cloudWebHubUrl("local")).toBe(
543
- "https://dev.mycadet.ai/assistant",
544
- );
545
- expect(cloudWebHubUrl(undefined)).toBe(
546
- "https://dev.mycadet.ai/assistant",
547
- );
536
+ expect(cloudWebHubUrl("dev")).toBe("https://dev.mycadet.ai/assistant");
537
+ expect(cloudWebHubUrl("local")).toBe("https://dev.mycadet.ai/assistant");
538
+ expect(cloudWebHubUrl(undefined)).toBe("https://dev.mycadet.ai/assistant");
548
539
  });
549
540
  });
550
541
 
@@ -846,14 +837,19 @@ const PRODUCTION_HUB_URL = "https://mycadet.ai/assistant";
846
837
 
847
838
  /**
848
839
  * Mirror of the SPA config fingerprint the edge records in its ingress state
849
- * (sha256 over the edge template version and the injected config JSON). Pins
840
+ * (sha256 over the template, injected config, bundle directory and index). Pins
850
841
  * both the injected config shape and the hash format; assumes the
851
842
  * production-pinned environment. The version itself is imported rather than
852
843
  * mirrored: bumping it is the correct response to any template edit, so a
853
844
  * copy here would fail every such edit without naming a defect.
854
845
  */
855
846
  function spaConfigHash(
856
- opts: { assistantName?: string; assistantId?: string } = {},
847
+ opts: {
848
+ assistantName?: string;
849
+ assistantId?: string;
850
+ indexHtml?: string;
851
+ webDistDir?: string;
852
+ } = {},
857
853
  ): string {
858
854
  return createHash("sha256")
859
855
  .update(
@@ -866,6 +862,8 @@ function spaConfigHash(
866
862
  ...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
867
863
  hubUrl: PRODUCTION_HUB_URL,
868
864
  },
865
+ webDistDir: opts.webDistDir ?? findWebDistDir(),
866
+ indexHtml: opts.indexHtml ?? FAKE_INDEX_HTML,
869
867
  }),
870
868
  )
871
869
  .digest("hex");
@@ -1247,6 +1245,7 @@ describe("startRemoteWebIngress", () => {
1247
1245
  const ws = makeWorkspace();
1248
1246
  mockNginxInstalled();
1249
1247
  mockNginxSpawn();
1248
+ mockWebDistPresent();
1250
1249
  const pid = mockRunningEdge(ws, {
1251
1250
  listenPort: 7845,
1252
1251
  gatewayPort: 7830,
@@ -1300,6 +1299,44 @@ describe("startRemoteWebIngress", () => {
1300
1299
  expect(spawnMock).not.toHaveBeenCalled();
1301
1300
  });
1302
1301
 
1302
+ for (const drift of ["indexHtml", "webDistDir"] as const) {
1303
+ test(`restarts a detached edge when the web bundle ${drift} changes`, async () => {
1304
+ const ws = makeWorkspace();
1305
+ mockNginxInstalled();
1306
+ mockNginxSpawn();
1307
+ mockWebDistPresent();
1308
+ const pid = mockRunningEdge(ws, {
1309
+ listenPort: 7845,
1310
+ includeWebApp: true,
1311
+ gatewayPort: 7830,
1312
+ remoteWebConfigHash: spaConfigHash({
1313
+ ...(drift === "indexHtml"
1314
+ ? {
1315
+ indexHtml:
1316
+ '<html><head><script src="/assistant/assets/old.js"></script></head></html>',
1317
+ }
1318
+ : { webDistDir: "/old-release/dist" }),
1319
+ }),
1320
+ });
1321
+ const edge = mockKillableNginx(pid);
1322
+ const result = await startRemoteWebIngress({
1323
+ workspaceDir: ws,
1324
+ gatewayPort: 7830,
1325
+ });
1326
+ expect(result).toMatchObject({ status: "started", listenPort: 7845 });
1327
+ expect(edge.killed()).toBe(true);
1328
+ expect(readConfig(ws).ingress?.nginx?.remoteWebConfigHash).toBe(
1329
+ spaConfigHash(),
1330
+ );
1331
+ expect(
1332
+ realFs.readFileSync(
1333
+ join(ws, "data", "ingress", "assistant-index.html"),
1334
+ "utf-8",
1335
+ ),
1336
+ ).not.toContain("old.js");
1337
+ });
1338
+ }
1339
+
1303
1340
  test("restarts the edge when the injected SPA config drifts from the recorded one", async () => {
1304
1341
  const ws = makeWorkspace();
1305
1342
  mockNginxInstalled();
@@ -1801,6 +1838,7 @@ describe("ensureTunnelEdge", () => {
1801
1838
  const ws = makeWorkspace();
1802
1839
  mockNginxInstalled();
1803
1840
  mockNginxSpawn();
1841
+ mockWebDistPresent();
1804
1842
  const pid = mockRunningEdge(ws, {
1805
1843
  listenPort: 7845,
1806
1844
  includeWebApp: true,
@@ -262,7 +262,8 @@ describe("cadet ps — platform status line", () => {
262
262
  // children of an assistant row…
263
263
  expect(output).toContain("ngrok");
264
264
  expect(output).not.toContain("└ ngrok");
265
- expect(output).toContain("nginx edge");
265
+ expect(output).toContain("nginx");
266
+ expect(output).toContain("port ");
266
267
  // …the type column distinguishes the three process kinds…
267
268
  expect(output).toContain("🧑‍🚀 Assistant");
268
269
  expect(output).toContain("🚇 Tunnel");
@@ -62,7 +62,7 @@ function writeLockfile(entry: AssistantEntry): void {
62
62
  const lockfileDir = process.env.CADET_LOCKFILE_DIR!;
63
63
  mkdirSync(lockfileDir, { recursive: true });
64
64
  writeFileSync(
65
- join(lockfileDir, ".cadet.lock.json"),
65
+ join(lockfileDir, "lockfile.json"),
66
66
  JSON.stringify(
67
67
  {
68
68
  assistants: [entry],
@@ -119,6 +119,10 @@ function installFetchStub(
119
119
  });
120
120
  }
121
121
 
122
+ if (url.endsWith("/v1/config/llm/default-provider")) {
123
+ return Response.json({ provider: "anthropic" });
124
+ }
125
+
122
126
  if (url.endsWith("/v1/secrets/read")) {
123
127
  return new Response(JSON.stringify({ found: false }), {
124
128
  status: 200,
@@ -189,7 +193,9 @@ describe("setup command", () => {
189
193
 
190
194
  await setup();
191
195
 
192
- expect(fetchCalls[0].url).toBe("http://127.0.0.1:3000/v1/secrets/read");
196
+ expect(fetchCalls[0].url).toBe(
197
+ "http://127.0.0.1:3000/v1/config/llm/default-provider",
198
+ );
193
199
  expect(fetchCalls[0].headers.get("Authorization")).toBe(
194
200
  "Bearer guardian-token",
195
201
  );
@@ -245,7 +251,9 @@ describe("setup command", () => {
245
251
  // gate instead of burning retry attempts against it.
246
252
  expect(fetchCalls[0].url).toBe("http://127.0.0.1:3000/readyz");
247
253
  expect(fetchCalls[1].url).toBe("http://127.0.0.1:3000/v1/guardian/init");
248
- expect(fetchCalls[2].url).toBe("http://127.0.0.1:3000/v1/secrets/read");
254
+ expect(fetchCalls[2].url).toBe(
255
+ "http://127.0.0.1:3000/v1/config/llm/default-provider",
256
+ );
249
257
  expect(fetchCalls[2].headers.get("Authorization")).toBe(
250
258
  "Bearer leased-guardian-token",
251
259
  );
@@ -288,7 +296,9 @@ describe("setup command", () => {
288
296
 
289
297
  await setup();
290
298
 
291
- expect(fetchCalls[0].url).toBe("https://assistant.example/v1/secrets/read");
299
+ expect(fetchCalls[0].url).toBe(
300
+ "https://assistant.example/v1/config/llm/default-provider",
301
+ );
292
302
  expect(fetchCalls[0].headers.get("Authorization")).toBe(
293
303
  "Bearer entry-token",
294
304
  );