@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.
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/package.json +1 -1
- package/src/__tests__/cloud-login-poll.test.ts +69 -0
- package/src/__tests__/devices.test.ts +62 -0
- package/src/__tests__/ensure-binary.test.ts +52 -0
- package/src/__tests__/hatch-next-steps.test.ts +38 -0
- package/src/__tests__/multi-local.test.ts +3 -3
- package/src/__tests__/nginx-ingress.test.ts +55 -17
- package/src/__tests__/ps-cloud-status.test.ts +2 -1
- package/src/__tests__/setup.test.ts +14 -4
- package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
- package/src/__tests__/tunnel-stop.test.ts +226 -0
- package/src/__tests__/tunnel.test.ts +12 -2
- package/src/commands/devices.ts +66 -30
- package/src/commands/hatch.ts +10 -52
- package/src/commands/pair.ts +7 -1
- package/src/commands/ps.ts +4 -2
- package/src/commands/tunnel.ts +43 -4
- package/src/commands/upgrade.ts +6 -1
- package/src/lib/assistant-config.ts +20 -5
- package/src/lib/cloud-login-poll.ts +75 -0
- package/src/lib/cloud-login.ts +3 -1
- package/src/lib/docker.ts +6 -12
- package/src/lib/ensure-binary.ts +65 -6
- package/src/lib/hatch-local.ts +1 -51
- package/src/lib/hatch-next-steps.ts +15 -2
- package/src/lib/live-voice/tunnel.ts +2 -2
- package/src/lib/local.ts +6 -6
- package/src/lib/nginx-ingress.ts +123 -21
- package/src/lib/tunnel.ts +86 -34
- package/src/lib/tunnels/cadet.ts +12 -1
- package/src/lib/tunnels/cloudflare.ts +36 -1
- package/src/lib/tunnels/ngrok.ts +16 -2
- package/src/lib/tunnels/process-lifecycle.ts +26 -0
- package/src/lib/tunnels/tailscale.ts +79 -1
- 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
|
-
|
|
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("
|
|
22
|
+
describe("tunnelHostForCloudHost", () => {
|
|
23
23
|
test("maps the prod platform host to the prod tunnel", () => {
|
|
24
|
-
expect(
|
|
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(
|
|
30
|
+
expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
|
|
31
31
|
"tunnel-staging.mycadet.ai",
|
|
32
32
|
);
|
|
33
|
-
expect(
|
|
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(
|
|
40
|
-
expect(
|
|
39
|
+
expect(tunnelHostForCloudHost("localhost")).toBeNull();
|
|
40
|
+
expect(tunnelHostForCloudHost("platform.example.com")).toBeNull();
|
|
41
41
|
expect(
|
|
42
|
-
|
|
42
|
+
tunnelHostForCloudHost("staging-cloud.mycadet.ai.evil.com"),
|
|
43
43
|
).toBeNull();
|
|
44
44
|
});
|
|
45
45
|
});
|
package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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("
|
|
22
|
+
describe("tunnelHostForCloudHost", () => {
|
|
23
23
|
test("maps the prod platform host to the prod tunnel", () => {
|
|
24
|
-
expect(
|
|
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(
|
|
30
|
+
expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
|
|
31
31
|
"tunnel-staging.mycadet.ai",
|
|
32
32
|
);
|
|
33
|
-
expect(
|
|
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(
|
|
40
|
-
expect(
|
|
39
|
+
expect(tunnelHostForCloudHost("localhost")).toBeNull();
|
|
40
|
+
expect(tunnelHostForCloudHost("platform.example.com")).toBeNull();
|
|
41
41
|
expect(
|
|
42
|
-
|
|
42
|
+
tunnelHostForCloudHost("staging-cloud.mycadet.ai.evil.com"),
|
|
43
43
|
).toBeNull();
|
|
44
44
|
});
|
|
45
45
|
});
|
package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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("
|
|
22
|
+
describe("tunnelHostForCloudHost", () => {
|
|
23
23
|
test("maps the prod platform host to the prod tunnel", () => {
|
|
24
|
-
expect(
|
|
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(
|
|
30
|
+
expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
|
|
31
31
|
"tunnel-staging.mycadet.ai",
|
|
32
32
|
);
|
|
33
|
-
expect(
|
|
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(
|
|
40
|
-
expect(
|
|
39
|
+
expect(tunnelHostForCloudHost("localhost")).toBeNull();
|
|
40
|
+
expect(tunnelHostForCloudHost("platform.example.com")).toBeNull();
|
|
41
41
|
expect(
|
|
42
|
-
|
|
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
|
|
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
|
@@ -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, ".
|
|
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, ".
|
|
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, ".
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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
|
|
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, ".
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
);
|