@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.
- 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 +4 -3
- package/src/__tests__/client-web-config.test.ts +0 -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 +58 -26
- package/src/__tests__/pair.test.ts +149 -78
- package/src/__tests__/ps-cloud-status.test.ts +6 -4
- package/src/__tests__/setup.test.ts +14 -4
- package/src/__tests__/teleport.test.ts +0 -15
- 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 +265 -70
- package/src/__tests__/uninstall.test.ts +140 -8
- package/src/commands/client.ts +4 -6
- package/src/commands/devices.ts +66 -30
- package/src/commands/hatch.ts +14 -53
- package/src/commands/pair.ts +147 -38
- package/src/commands/ps.ts +10 -20
- package/src/commands/teleport.ts +2 -21
- package/src/commands/tunnel.ts +67 -28
- package/src/commands/uninstall.ts +106 -71
- package/src/commands/upgrade.ts +6 -1
- package/src/lib/__tests__/local-gateway-cli.test.ts +42 -0
- package/src/lib/assistant-config.ts +20 -5
- package/src/lib/cloud-client.ts +0 -3
- package/src/lib/cloud-login-poll.ts +75 -0
- package/src/lib/cloud-login.ts +3 -2
- package/src/lib/color.ts +4 -0
- package/src/lib/docker.ts +46 -70
- package/src/lib/ensure-binary.ts +65 -6
- package/src/lib/environments/__tests__/paths.test.ts +27 -1
- package/src/lib/environments/paths.ts +16 -8
- package/src/lib/environments/resolve.ts +2 -4
- package/src/lib/hatch-local.ts +16 -59
- package/src/lib/hatch-next-steps.ts +18 -9
- package/src/lib/live-voice/tunnel.ts +2 -2
- package/src/lib/local.ts +42 -9
- package/src/lib/nginx-ingress.ts +124 -24
- package/src/lib/step-runner.ts +6 -1
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mycadet/cli",
|
|
3
|
-
"version": "0.12.
|
|
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
|
|
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
|
|
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",
|
|
@@ -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,
|
|
@@ -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\\"
|
|
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\\",\\"
|
|
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"
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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,
|