@mycadet/cli 0.12.9 → 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 (55) 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 +4 -3
  8. package/src/__tests__/client-web-config.test.ts +0 -1
  9. package/src/__tests__/cloud-login-poll.test.ts +69 -0
  10. package/src/__tests__/devices.test.ts +62 -0
  11. package/src/__tests__/ensure-binary.test.ts +52 -0
  12. package/src/__tests__/hatch-next-steps.test.ts +38 -0
  13. package/src/__tests__/multi-local.test.ts +3 -3
  14. package/src/__tests__/nginx-ingress.test.ts +58 -26
  15. package/src/__tests__/pair.test.ts +149 -78
  16. package/src/__tests__/ps-cloud-status.test.ts +6 -4
  17. package/src/__tests__/setup.test.ts +14 -4
  18. package/src/__tests__/teleport.test.ts +0 -15
  19. package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
  20. package/src/__tests__/tunnel-stop.test.ts +226 -0
  21. package/src/__tests__/tunnel.test.ts +265 -70
  22. package/src/__tests__/uninstall.test.ts +140 -8
  23. package/src/commands/client.ts +4 -6
  24. package/src/commands/devices.ts +66 -30
  25. package/src/commands/hatch.ts +14 -53
  26. package/src/commands/pair.ts +147 -38
  27. package/src/commands/ps.ts +10 -20
  28. package/src/commands/teleport.ts +2 -21
  29. package/src/commands/tunnel.ts +67 -28
  30. package/src/commands/uninstall.ts +106 -71
  31. package/src/commands/upgrade.ts +6 -1
  32. package/src/lib/__tests__/local-gateway-cli.test.ts +42 -0
  33. package/src/lib/assistant-config.ts +20 -5
  34. package/src/lib/cloud-client.ts +0 -3
  35. package/src/lib/cloud-login-poll.ts +75 -0
  36. package/src/lib/cloud-login.ts +3 -2
  37. package/src/lib/color.ts +4 -0
  38. package/src/lib/docker.ts +46 -70
  39. package/src/lib/ensure-binary.ts +65 -6
  40. package/src/lib/environments/__tests__/paths.test.ts +27 -1
  41. package/src/lib/environments/paths.ts +16 -8
  42. package/src/lib/environments/resolve.ts +2 -4
  43. package/src/lib/hatch-local.ts +16 -59
  44. package/src/lib/hatch-next-steps.ts +18 -9
  45. package/src/lib/live-voice/tunnel.ts +2 -2
  46. package/src/lib/local.ts +42 -9
  47. package/src/lib/nginx-ingress.ts +124 -24
  48. package/src/lib/step-runner.ts +6 -1
  49. package/src/lib/tunnel.ts +86 -34
  50. package/src/lib/tunnels/cadet.ts +12 -1
  51. package/src/lib/tunnels/cloudflare.ts +36 -1
  52. package/src/lib/tunnels/ngrok.ts +16 -2
  53. package/src/lib/tunnels/process-lifecycle.ts +26 -0
  54. package/src/lib/tunnels/tailscale.ts +79 -1
  55. 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.9",
3
+ "version": "0.12.11",
4
4
  "description": "CLI tools for mycadet",
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,11 +20,11 @@
20
20
  "scripts": {
21
21
  "format": "prettier --write .",
22
22
  "format:check": "prettier --check .",
23
- "lint": "eslint --max-warnings 322",
23
+ "lint": "eslint --max-warnings 323",
24
24
  "lint:unused": "knip --include files,dependencies,unlisted",
25
25
  "compile:check": "bun build --compile src/index.ts --outfile /dev/null",
26
26
  "test": "bun run scripts/run-tests.ts",
27
- "typecheck": "bunx tsc --noEmit"
27
+ "typecheck": "bunx tsgo --noEmit"
28
28
  },
29
29
  "author": "Cadet AI",
30
30
  "license": "MIT",
@@ -57,6 +57,7 @@
57
57
  "@types/bun": "1.3.11",
58
58
  "@types/qrcode-terminal": "0.12.2",
59
59
  "@types/react": "19.2.14",
60
+ "@typescript/native-preview": "7.0.0-dev.20260624.1",
60
61
  "eslint": "10.1.0",
61
62
  "knip": "5.88.1",
62
63
  "prettier": "3.8.1",
@@ -13,7 +13,6 @@ import { buildWebInterfaceConfig } from "../commands/client.js";
13
13
  const BASE = {
14
14
  webUrl: "https://app.example.com",
15
15
  cloudUrl: "https://platform.example.com",
16
- disableCloud: true,
17
16
  };
18
17
 
19
18
  describe("cadet client web __config", () => {
@@ -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,
@@ -160,7 +161,6 @@ describe("buildIngressNginxConfig", () => {
160
161
  config: {
161
162
  remote: "docker",
162
163
  apiBaseUrl: "/v1",
163
- cloudDisabled: true,
164
164
  },
165
165
  },
166
166
  });
@@ -330,7 +330,7 @@ describe("buildIngressNginxConfig", () => {
330
330
  expect(remoteConf).toContain("default_type application/json;");
331
331
  expect(remoteConf).toContain('add_header Cache-Control "no-store";');
332
332
  expect(remoteConf).toContain(
333
- 'return 200 "{\\"remote\\":\\"docker\\",\\"apiBaseUrl\\":\\"/v1\\",\\"cloudDisabled\\":true,\\"disableCloud\\":true}";',
333
+ 'return 200 "{\\"remote\\":\\"docker\\",\\"apiBaseUrl\\":\\"/v1\\"}";',
334
334
  );
335
335
  expect(remoteConf).not.toContain("assistantId");
336
336
  });
@@ -347,7 +347,7 @@ describe("buildIngressNginxConfig", () => {
347
347
  },
348
348
  });
349
349
  expect(named).toContain(
350
- 'return 200 "{\\"remote\\":\\"custom\\",\\"apiBaseUrl\\":\\"/v1\\",\\"cloudDisabled\\":true,\\"disableCloud\\":true,\\"assistantName\\":\\"Homelab\\",\\"assistantId\\":\\"assistant-1\\",\\"hubUrl\\":\\"https://mycadet.ai/assistant\\"}";',
350
+ 'return 200 "{\\"remote\\":\\"custom\\",\\"apiBaseUrl\\":\\"/v1\\",\\"assistantName\\":\\"Homelab\\",\\"assistantId\\":\\"assistant-1\\",\\"hubUrl\\":\\"https://mycadet.ai/assistant\\"}";',
351
351
  );
352
352
  });
353
353
 
@@ -449,18 +449,15 @@ describe("buildRemoteWebIndexHtml", () => {
449
449
  const result = buildRemoteWebIndexHtml(html, {
450
450
  remote: "docker",
451
451
  apiBaseUrl: "/v1",
452
- disableCloud: true,
453
452
  });
454
453
 
455
454
  expect(result).toContain(
456
455
  'window.__CADET_CONFIG__={"webUrl":"https://mycadet.ai"}',
457
456
  );
458
457
  expect(result).toContain(
459
- 'window.__CADET_CONFIG__={"remote":"docker","apiBaseUrl":"/v1","disableCloud":true}',
460
- );
461
- expect(result.indexOf('"webUrl"')).toBeLessThan(
462
- result.indexOf('"docker"'),
458
+ 'window.__CADET_CONFIG__={"remote":"docker","apiBaseUrl":"/v1"}',
463
459
  );
460
+ expect(result.indexOf('"webUrl"')).toBeLessThan(result.indexOf('"docker"'));
464
461
  });
465
462
 
466
463
  test("escapes config JSON before embedding it in a script tag", () => {
@@ -532,21 +529,13 @@ describe("buildRemoteWebIndexHtml", () => {
532
529
 
533
530
  describe("cloudWebHubUrl", () => {
534
531
  test("maps build environments to their cloud SPA base", () => {
535
- expect(cloudWebHubUrl("production")).toBe(
536
- "https://mycadet.ai/assistant",
537
- );
532
+ expect(cloudWebHubUrl("production")).toBe("https://mycadet.ai/assistant");
538
533
  expect(cloudWebHubUrl("staging")).toBe(
539
534
  "https://staging.mycadet.ai/assistant",
540
535
  );
541
- expect(cloudWebHubUrl("dev")).toBe(
542
- "https://dev.mycadet.ai/assistant",
543
- );
544
- expect(cloudWebHubUrl("local")).toBe(
545
- "https://dev.mycadet.ai/assistant",
546
- );
547
- expect(cloudWebHubUrl(undefined)).toBe(
548
- "https://dev.mycadet.ai/assistant",
549
- );
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");
550
539
  });
551
540
  });
552
541
 
@@ -848,14 +837,19 @@ const PRODUCTION_HUB_URL = "https://mycadet.ai/assistant";
848
837
 
849
838
  /**
850
839
  * Mirror of the SPA config fingerprint the edge records in its ingress state
851
- * (sha256 over the edge template version and the injected config JSON). Pins
840
+ * (sha256 over the template, injected config, bundle directory and index). Pins
852
841
  * both the injected config shape and the hash format; assumes the
853
842
  * production-pinned environment. The version itself is imported rather than
854
843
  * mirrored: bumping it is the correct response to any template edit, so a
855
844
  * copy here would fail every such edit without naming a defect.
856
845
  */
857
846
  function spaConfigHash(
858
- opts: { assistantName?: string; assistantId?: string } = {},
847
+ opts: {
848
+ assistantName?: string;
849
+ assistantId?: string;
850
+ indexHtml?: string;
851
+ webDistDir?: string;
852
+ } = {},
859
853
  ): string {
860
854
  return createHash("sha256")
861
855
  .update(
@@ -864,12 +858,12 @@ function spaConfigHash(
864
858
  config: {
865
859
  remote: "custom",
866
860
  apiBaseUrl: "/v1",
867
- cloudDisabled: true,
868
- disableCloud: true,
869
861
  ...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
870
862
  ...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
871
863
  hubUrl: PRODUCTION_HUB_URL,
872
864
  },
865
+ webDistDir: opts.webDistDir ?? findWebDistDir(),
866
+ indexHtml: opts.indexHtml ?? FAKE_INDEX_HTML,
873
867
  }),
874
868
  )
875
869
  .digest("hex");
@@ -1251,6 +1245,7 @@ describe("startRemoteWebIngress", () => {
1251
1245
  const ws = makeWorkspace();
1252
1246
  mockNginxInstalled();
1253
1247
  mockNginxSpawn();
1248
+ mockWebDistPresent();
1254
1249
  const pid = mockRunningEdge(ws, {
1255
1250
  listenPort: 7845,
1256
1251
  gatewayPort: 7830,
@@ -1304,6 +1299,44 @@ describe("startRemoteWebIngress", () => {
1304
1299
  expect(spawnMock).not.toHaveBeenCalled();
1305
1300
  });
1306
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
+
1307
1340
  test("restarts the edge when the injected SPA config drifts from the recorded one", async () => {
1308
1341
  const ws = makeWorkspace();
1309
1342
  mockNginxInstalled();
@@ -1354,8 +1387,6 @@ describe("startRemoteWebIngress", () => {
1354
1387
  config: {
1355
1388
  remote: "custom",
1356
1389
  apiBaseUrl: "/v1",
1357
- cloudDisabled: true,
1358
- disableCloud: true,
1359
1390
  hubUrl: PRODUCTION_HUB_URL,
1360
1391
  },
1361
1392
  }),
@@ -1807,6 +1838,7 @@ describe("ensureTunnelEdge", () => {
1807
1838
  const ws = makeWorkspace();
1808
1839
  mockNginxInstalled();
1809
1840
  mockNginxSpawn();
1841
+ mockWebDistPresent();
1810
1842
  const pid = mockRunningEdge(ws, {
1811
1843
  listenPort: 7845,
1812
1844
  includeWebApp: true,