@mycadet/cli 0.12.7-dev.202610111049.d2b52a8 → 0.12.8
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/events/cloud-disconnected.ts +2 -2
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
- package/package.json +4 -3
- package/src/__tests__/{cloudflare-tunnel.test.ts → cloudflare.test.ts} +1 -1
- package/src/__tests__/ps-cloud-status.test.ts +53 -0
- package/src/__tests__/retire.test.ts +37 -178
- package/src/__tests__/root-process-lockfile.test.ts +99 -0
- package/src/__tests__/{tailscale-tunnel.test.ts → tailscale.test.ts} +1 -1
- package/src/__tests__/tunnel.test.ts +127 -80
- package/src/__tests__/upgrade-local.test.ts +3 -3
- package/src/__tests__/wake.test.ts +16 -9
- package/src/commands/client.ts +1 -1
- package/src/commands/ps.ts +181 -72
- package/src/commands/retire.ts +28 -137
- package/src/commands/tunnel.ts +76 -118
- package/src/commands/upgrade.ts +2 -9
- package/src/commands/wake.ts +5 -9
- package/src/lib/assistant-config.ts +53 -0
- package/src/lib/detached-process.ts +1 -1
- package/src/lib/ingress-config.ts +7 -34
- package/src/lib/local.ts +6 -27
- package/src/lib/nginx-ingress.ts +29 -8
- package/src/lib/{tunnel-edge.ts → tunnel.ts} +199 -11
- package/src/lib/tunnels/cadet.ts +29 -0
- package/src/lib/{cloudflare-tunnel.ts → tunnels/cloudflare.ts} +15 -4
- package/src/lib/tunnels/gateway-notify.ts +61 -0
- package/src/lib/{ngrok.ts → tunnels/ngrok.ts} +200 -29
- package/src/lib/{tailscale-tunnel.ts → tunnels/tailscale.ts} +19 -5
|
@@ -25,42 +25,60 @@ import { join } from "node:path";
|
|
|
25
25
|
import { TUNNEL_PROVIDERS } from "@mycadet/service-contracts/ingress";
|
|
26
26
|
import { z } from "zod";
|
|
27
27
|
|
|
28
|
-
import * as cloudflareTunnel from "../lib/cloudflare
|
|
29
|
-
import * as
|
|
28
|
+
import * as cloudflareTunnel from "../lib/tunnels/cloudflare.js";
|
|
29
|
+
import * as gatewayNotify from "../lib/tunnels/gateway-notify.js";
|
|
30
|
+
import * as ngrok from "../lib/tunnels/ngrok.js";
|
|
30
31
|
import * as nginxIngress from "../lib/nginx-ingress.js";
|
|
31
|
-
import * as tailscaleTunnel from "../lib/tailscale
|
|
32
|
+
import * as tailscaleTunnel from "../lib/tunnels/tailscale.js";
|
|
32
33
|
import { getLogDir } from "../lib/xdg-log.js";
|
|
33
34
|
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
34
35
|
import { withPreconnect } from "./helpers/http.js";
|
|
35
36
|
|
|
36
37
|
const realCloudflareTunnel = { ...cloudflareTunnel };
|
|
38
|
+
const realGatewayNotify = { ...gatewayNotify };
|
|
37
39
|
const realNgrok = { ...ngrok };
|
|
38
40
|
const realNginxIngress = { ...nginxIngress };
|
|
39
41
|
const realTailscaleTunnel = { ...tailscaleTunnel };
|
|
40
42
|
const realChildProcess = { ...childProcess };
|
|
41
43
|
|
|
44
|
+
/** What a mocked provider run hands back: a URL and an already-settled exit. */
|
|
45
|
+
const fakeTunnelResult = (publicUrl: string) => ({
|
|
46
|
+
publicUrl,
|
|
47
|
+
waitForExit: Promise.resolve(),
|
|
48
|
+
});
|
|
49
|
+
|
|
42
50
|
const runCloudflareTunnelMock = mock<
|
|
43
51
|
typeof cloudflareTunnel.runCloudflareTunnel
|
|
44
|
-
>(async () =>
|
|
45
|
-
mock.module("../lib/cloudflare
|
|
52
|
+
>(async () => fakeTunnelResult("https://test.example.cloudflare.dev"));
|
|
53
|
+
mock.module("../lib/tunnels/cloudflare.js", () => ({
|
|
46
54
|
...realCloudflareTunnel,
|
|
47
55
|
runCloudflareTunnel: runCloudflareTunnelMock,
|
|
48
56
|
}));
|
|
49
57
|
|
|
50
|
-
const runNgrokTunnelMock = mock<typeof ngrok.runNgrokTunnel>(async () =>
|
|
51
|
-
|
|
58
|
+
const runNgrokTunnelMock = mock<typeof ngrok.runNgrokTunnel>(async () =>
|
|
59
|
+
fakeTunnelResult("https://test.example.ngrok.dev"),
|
|
60
|
+
);
|
|
61
|
+
mock.module("../lib/tunnels/ngrok", () => ({
|
|
52
62
|
...realNgrok,
|
|
53
63
|
runNgrokTunnel: runNgrokTunnelMock,
|
|
54
64
|
}));
|
|
55
65
|
|
|
56
66
|
const runTailscaleTunnelMock = mock<typeof tailscaleTunnel.runTailscaleTunnel>(
|
|
57
|
-
async () =>
|
|
67
|
+
async () => fakeTunnelResult("https://test-machine.tailnet.ts.net"),
|
|
58
68
|
);
|
|
59
|
-
mock.module("../lib/tailscale
|
|
69
|
+
mock.module("../lib/tunnels/tailscale.js", () => ({
|
|
60
70
|
...realTailscaleTunnel,
|
|
61
71
|
runTailscaleTunnel: runTailscaleTunnelMock,
|
|
62
72
|
}));
|
|
63
73
|
|
|
74
|
+
const notifyGatewayOfPublicUrlMock = mock<
|
|
75
|
+
typeof gatewayNotify.notifyGatewayOfPublicUrl
|
|
76
|
+
>(async () => {});
|
|
77
|
+
mock.module("../lib/tunnels/gateway-notify.js", () => ({
|
|
78
|
+
...realGatewayNotify,
|
|
79
|
+
notifyGatewayOfPublicUrl: notifyGatewayOfPublicUrlMock,
|
|
80
|
+
}));
|
|
81
|
+
|
|
64
82
|
const EDGE_PORT = 18080;
|
|
65
83
|
|
|
66
84
|
const ensureTunnelEdgeMock = mock<typeof nginxIngress.ensureTunnelEdge>(
|
|
@@ -75,6 +93,7 @@ const { tunnel } = await import("../commands/tunnel.js");
|
|
|
75
93
|
|
|
76
94
|
const originalArgv = [...process.argv];
|
|
77
95
|
const originalFetch = globalThis.fetch;
|
|
96
|
+
const originalConfigHome = process.env.XDG_CONFIG_HOME;
|
|
78
97
|
const originalLockfileDir = process.env.CADET_LOCKFILE_DIR;
|
|
79
98
|
const originalWorkspaceDir = process.env.CADET_WORKSPACE_DIR;
|
|
80
99
|
const tempDirs: string[] = [];
|
|
@@ -227,12 +246,24 @@ describe("tunnel edge targeting", () => {
|
|
|
227
246
|
globalThis.fetch = withPreconnect(async () => {
|
|
228
247
|
throw new Error("gateway unavailable");
|
|
229
248
|
});
|
|
249
|
+
// The ngrok config file lives under the XDG config dir; a fresh one per
|
|
250
|
+
// test keeps the saved-domain state hermetic.
|
|
251
|
+
const configHome = mkdtempSync(join(tmpdir(), "cadet-tunnel-xdg-"));
|
|
252
|
+
tempDirs.push(configHome);
|
|
253
|
+
process.env.XDG_CONFIG_HOME = configHome;
|
|
230
254
|
runCloudflareTunnelMock.mockReset();
|
|
231
|
-
runCloudflareTunnelMock.mockResolvedValue(
|
|
255
|
+
runCloudflareTunnelMock.mockResolvedValue(
|
|
256
|
+
fakeTunnelResult("https://test.example.cloudflare.dev"),
|
|
257
|
+
);
|
|
232
258
|
runNgrokTunnelMock.mockReset();
|
|
233
|
-
runNgrokTunnelMock.mockResolvedValue(
|
|
259
|
+
runNgrokTunnelMock.mockResolvedValue(
|
|
260
|
+
fakeTunnelResult("https://test.example.ngrok.dev"),
|
|
261
|
+
);
|
|
234
262
|
runTailscaleTunnelMock.mockReset();
|
|
235
|
-
runTailscaleTunnelMock.mockResolvedValue(
|
|
263
|
+
runTailscaleTunnelMock.mockResolvedValue(
|
|
264
|
+
fakeTunnelResult("https://test-machine.tailnet.ts.net"),
|
|
265
|
+
);
|
|
266
|
+
notifyGatewayOfPublicUrlMock.mockReset();
|
|
236
267
|
ensureTunnelEdgeMock.mockReset();
|
|
237
268
|
ensureTunnelEdgeMock.mockResolvedValue({
|
|
238
269
|
port: EDGE_PORT,
|
|
@@ -244,6 +275,12 @@ describe("tunnel edge targeting", () => {
|
|
|
244
275
|
afterEach(() => {
|
|
245
276
|
process.argv = originalArgv;
|
|
246
277
|
globalThis.fetch = originalFetch;
|
|
278
|
+
rmSync(realNgrok.ngrokConfigPath(), { force: true });
|
|
279
|
+
if (originalConfigHome === undefined) {
|
|
280
|
+
delete process.env.XDG_CONFIG_HOME;
|
|
281
|
+
} else {
|
|
282
|
+
process.env.XDG_CONFIG_HOME = originalConfigHome;
|
|
283
|
+
}
|
|
247
284
|
if (originalLockfileDir === undefined) {
|
|
248
285
|
delete process.env.CADET_LOCKFILE_DIR;
|
|
249
286
|
} else {
|
|
@@ -260,9 +297,9 @@ describe("tunnel edge targeting", () => {
|
|
|
260
297
|
});
|
|
261
298
|
|
|
262
299
|
afterAll(() => {
|
|
263
|
-
mock.module("../lib/cloudflare
|
|
264
|
-
mock.module("../lib/ngrok", () => realNgrok);
|
|
265
|
-
mock.module("../lib/tailscale
|
|
300
|
+
mock.module("../lib/tunnels/cloudflare.js", () => realCloudflareTunnel);
|
|
301
|
+
mock.module("../lib/tunnels/ngrok", () => realNgrok);
|
|
302
|
+
mock.module("../lib/tunnels/tailscale.js", () => realTailscaleTunnel);
|
|
266
303
|
mock.module("../lib/nginx-ingress.js", () => realNginxIngress);
|
|
267
304
|
});
|
|
268
305
|
|
|
@@ -290,6 +327,20 @@ describe("tunnel edge targeting", () => {
|
|
|
290
327
|
expect(logs).toContain("serves remote web + webhooks");
|
|
291
328
|
});
|
|
292
329
|
|
|
330
|
+
test("the tunnel level notifies the gateway of the provider's public URL", async () => {
|
|
331
|
+
writeLockfile(makeLocalEntry());
|
|
332
|
+
process.argv = ["bun", "cadet", "tunnel", "--provider", "ngrok"];
|
|
333
|
+
|
|
334
|
+
await runTunnelCapturingLogs();
|
|
335
|
+
|
|
336
|
+
// The provider hands its URL back; the notification is the tunnel
|
|
337
|
+
// level's job, so it holds no matter which provider ran.
|
|
338
|
+
expect(notifyGatewayOfPublicUrlMock).toHaveBeenCalledWith(
|
|
339
|
+
"https://test.example.ngrok.dev",
|
|
340
|
+
"assistant-1",
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
293
344
|
test("an active cloud assistant falls back to the sole local entry with a note", async () => {
|
|
294
345
|
const cloud = makeCloudEntry();
|
|
295
346
|
const local = makeLocalEntry();
|
|
@@ -625,46 +676,40 @@ describe("tunnel edge targeting", () => {
|
|
|
625
676
|
expect(logs).toContain("serves webhooks only");
|
|
626
677
|
});
|
|
627
678
|
|
|
628
|
-
test("a bare invocation
|
|
629
|
-
|
|
679
|
+
test("a bare invocation tunnels the default provider", async () => {
|
|
680
|
+
const entry = makeLocalEntry();
|
|
681
|
+
writeLockfile(entry);
|
|
630
682
|
process.argv = ["bun", "cadet", "tunnel"];
|
|
631
683
|
|
|
632
|
-
const
|
|
684
|
+
const logs = await runTunnelCapturingLogs();
|
|
633
685
|
|
|
634
|
-
expect(
|
|
635
|
-
expect(
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
expect(ensureTunnelEdgeMock).
|
|
686
|
+
expect(logs).toContain("Using the default provider: ngrok");
|
|
687
|
+
expect(runNgrokTunnelMock).toHaveBeenCalledWith({
|
|
688
|
+
port: EDGE_PORT,
|
|
689
|
+
assistantId: "assistant-1",
|
|
690
|
+
workspaceDir: join(entry.resources!.instanceDir, "workspace"),
|
|
691
|
+
});
|
|
692
|
+
expect(ensureTunnelEdgeMock).toHaveBeenCalled();
|
|
641
693
|
expect(runTailscaleTunnelMock).not.toHaveBeenCalled();
|
|
642
|
-
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
643
694
|
expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
|
|
644
695
|
});
|
|
645
696
|
|
|
646
|
-
test("a named assistant without --provider
|
|
647
|
-
|
|
697
|
+
test("a named assistant without --provider tunnels it with the default provider", async () => {
|
|
698
|
+
const entry = makeLocalEntry();
|
|
699
|
+
entry.name = "Ada";
|
|
700
|
+
writeLockfile(entry);
|
|
648
701
|
process.argv = ["bun", "cadet", "tunnel", "Ada"];
|
|
649
702
|
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
expect(exited).toBe(true);
|
|
653
|
-
expect(errors).toContain("--provider is required");
|
|
654
|
-
// The suggestions keep the assistant that was asked for, so pasting one
|
|
655
|
-
// cannot tunnel the active assistant instead.
|
|
656
|
-
expect(errors).toContain("cadet tunnel Ada --provider ngrok");
|
|
657
|
-
expect(errors).toContain("cadet tunnel Ada --provider tailscale");
|
|
658
|
-
expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
|
|
659
|
-
});
|
|
660
|
-
|
|
661
|
-
test("a shell-sensitive assistant name is quoted in the suggestions", async () => {
|
|
662
|
-
writeLockfile(makeLocalEntry());
|
|
663
|
-
process.argv = ["bun", "cadet", "tunnel", "Bob&Alice"];
|
|
664
|
-
|
|
665
|
-
const { errors } = await runTunnelExpectingExit1();
|
|
703
|
+
const logs = await runTunnelCapturingLogs();
|
|
666
704
|
|
|
667
|
-
expect(
|
|
705
|
+
expect(logs).toContain("Using the default provider: ngrok");
|
|
706
|
+
// The assistant that was asked for is the one tunneled.
|
|
707
|
+
expect(runNgrokTunnelMock).toHaveBeenCalledWith({
|
|
708
|
+
port: EDGE_PORT,
|
|
709
|
+
assistantId: entry.assistantId,
|
|
710
|
+
workspaceDir: join(entry.resources!.instanceDir, "workspace"),
|
|
711
|
+
});
|
|
712
|
+
expect(ensureTunnelEdgeMock).toHaveBeenCalled();
|
|
668
713
|
});
|
|
669
714
|
|
|
670
715
|
test("--provider tailscale keeps the webhook callback base and says so", async () => {
|
|
@@ -819,7 +864,7 @@ describe("tunnel edge targeting", () => {
|
|
|
819
864
|
|
|
820
865
|
expect(exited).toBe(true);
|
|
821
866
|
expect(errors).toContain(
|
|
822
|
-
"--domain is
|
|
867
|
+
"--domain is not supported with --provider cloudflare",
|
|
823
868
|
);
|
|
824
869
|
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
825
870
|
expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
|
|
@@ -830,10 +875,7 @@ describe("tunnel edge targeting", () => {
|
|
|
830
875
|
writeLockfile(entry);
|
|
831
876
|
const workspaceDir = join(entry.resources!.instanceDir, "workspace");
|
|
832
877
|
mkdirSync(workspaceDir, { recursive: true });
|
|
833
|
-
|
|
834
|
-
join(workspaceDir, "config.json"),
|
|
835
|
-
JSON.stringify({ ingress: { ngrok: { domain: "dead.ngrok.app" } } }),
|
|
836
|
-
);
|
|
878
|
+
realNgrok.saveNgrokDomain("dead.ngrok.app");
|
|
837
879
|
process.argv = [
|
|
838
880
|
"bun",
|
|
839
881
|
"cadet",
|
|
@@ -845,11 +887,9 @@ describe("tunnel edge targeting", () => {
|
|
|
845
887
|
|
|
846
888
|
const logs = await runTunnelCapturingLogs();
|
|
847
889
|
|
|
848
|
-
expect(logs).toContain("Cleared the saved
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
);
|
|
852
|
-
expect(config.ingress?.ngrok).toBeUndefined();
|
|
890
|
+
expect(logs).toContain("Cleared the saved reserved domain");
|
|
891
|
+
// The saved domain lives in the XDG ngrok config, not the workspace config.
|
|
892
|
+
expect(realNgrok.loadNgrokDomain()).toBeNull();
|
|
853
893
|
// The run proceeds domainless: no `domain` key reaches runNgrokTunnel.
|
|
854
894
|
expect(runNgrokTunnelMock).toHaveBeenCalledWith({
|
|
855
895
|
port: EDGE_PORT,
|
|
@@ -891,7 +931,7 @@ describe("tunnel edge targeting", () => {
|
|
|
891
931
|
|
|
892
932
|
expect(exited).toBe(true);
|
|
893
933
|
expect(errors).toContain(
|
|
894
|
-
"--clear-domain is
|
|
934
|
+
"--clear-domain is not supported with --provider cloudflare",
|
|
895
935
|
);
|
|
896
936
|
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
897
937
|
expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
|
|
@@ -975,11 +1015,22 @@ describe("ngrok --domain spawn args", () => {
|
|
|
975
1015
|
spawnMock.mockClear();
|
|
976
1016
|
lastChild = null;
|
|
977
1017
|
delete process.env.IS_CONTAINERIZED;
|
|
1018
|
+
// The ngrok config file lives under the XDG config dir; a fresh one per
|
|
1019
|
+
// test keeps the saved-domain state hermetic.
|
|
1020
|
+
const configHome = mkdtempSync(join(tmpdir(), "cadet-ngrok-xdg-"));
|
|
1021
|
+
tempDirs.push(configHome);
|
|
1022
|
+
process.env.XDG_CONFIG_HOME = configHome;
|
|
978
1023
|
mockNgrokApiFetch([{ tunnels: [] }]);
|
|
979
1024
|
});
|
|
980
1025
|
|
|
981
1026
|
afterEach(() => {
|
|
982
1027
|
globalThis.fetch = originalFetch;
|
|
1028
|
+
rmSync(realNgrok.ngrokConfigPath(), { force: true });
|
|
1029
|
+
if (originalConfigHome === undefined) {
|
|
1030
|
+
delete process.env.XDG_CONFIG_HOME;
|
|
1031
|
+
} else {
|
|
1032
|
+
process.env.XDG_CONFIG_HOME = originalConfigHome;
|
|
1033
|
+
}
|
|
983
1034
|
if (originalContainerized === undefined) {
|
|
984
1035
|
delete process.env.IS_CONTAINERIZED;
|
|
985
1036
|
} else {
|
|
@@ -1089,17 +1140,18 @@ describe("ngrok --domain spawn args", () => {
|
|
|
1089
1140
|
const [cmd, args] = spawnMock.mock.calls[0];
|
|
1090
1141
|
expect(cmd).toBe("ngrok");
|
|
1091
1142
|
expect(args).toEqual([
|
|
1092
|
-
"
|
|
1093
|
-
"
|
|
1143
|
+
"start",
|
|
1144
|
+
"cadet",
|
|
1145
|
+
`--config=${realNgrok.ngrokConfigPath()}`,
|
|
1094
1146
|
"--log=stdout",
|
|
1095
|
-
"--domain=foo.ngrok.app",
|
|
1096
1147
|
]);
|
|
1148
|
+
// The reserved domain rides the XDG config the agent loads, not a flag.
|
|
1149
|
+
expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
|
|
1097
1150
|
|
|
1098
1151
|
const config = IngressTestConfigSchema.parse(
|
|
1099
1152
|
JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
|
|
1100
1153
|
);
|
|
1101
1154
|
expect(config.ingress?.publicBaseUrl).toBe("https://foo.ngrok.app");
|
|
1102
|
-
expect(config.ingress?.ngrok?.domain).toBe("foo.ngrok.app");
|
|
1103
1155
|
});
|
|
1104
1156
|
|
|
1105
1157
|
function mockTunnelListFetch(publicUrl: string, addr: string): void {
|
|
@@ -1188,14 +1240,12 @@ describe("ngrok --domain spawn args", () => {
|
|
|
1188
1240
|
JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
|
|
1189
1241
|
);
|
|
1190
1242
|
expect(config.ingress?.publicBaseUrl).toBe("https://foo.ngrok.app");
|
|
1191
|
-
expect(
|
|
1243
|
+
expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
|
|
1192
1244
|
});
|
|
1193
1245
|
|
|
1194
1246
|
test("maybeStartNgrokTunnel rejects a mismatched existing tunnel without adopting or spawning", async () => {
|
|
1195
|
-
const ws = makeWorkspace({
|
|
1196
|
-
|
|
1197
|
-
ingress: { ngrok: { domain: "foo.ngrok.app" } },
|
|
1198
|
-
});
|
|
1247
|
+
const ws = makeWorkspace({ telegram: { botUsername: "example_bot" } });
|
|
1248
|
+
realNgrok.saveNgrokDomain("foo.ngrok.app");
|
|
1199
1249
|
mockTunnelListFetch("https://other.ngrok-free.app", "localhost:7830");
|
|
1200
1250
|
|
|
1201
1251
|
const warnings: string[] = [];
|
|
@@ -1229,7 +1279,7 @@ describe("ngrok --domain spawn args", () => {
|
|
|
1229
1279
|
// The mismatched tunnel is not blessed in config…
|
|
1230
1280
|
expect(config.ingress?.publicBaseUrl).toBeUndefined();
|
|
1231
1281
|
// …and the reserved domain stays saved as standing intent.
|
|
1232
|
-
expect(
|
|
1282
|
+
expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
|
|
1233
1283
|
});
|
|
1234
1284
|
|
|
1235
1285
|
test("an adopted automatic tunnel records the assistant it fronts", async () => {
|
|
@@ -1359,10 +1409,8 @@ describe("ngrok --domain spawn args", () => {
|
|
|
1359
1409
|
});
|
|
1360
1410
|
|
|
1361
1411
|
test("maybeStartNgrokTunnel passes the saved domain to the spawn args", async () => {
|
|
1362
|
-
const ws = makeWorkspace({
|
|
1363
|
-
|
|
1364
|
-
ingress: { ngrok: { domain: "foo.ngrok.app" } },
|
|
1365
|
-
});
|
|
1412
|
+
const ws = makeWorkspace({ telegram: { botUsername: "example_bot" } });
|
|
1413
|
+
realNgrok.saveNgrokDomain("foo.ngrok.app");
|
|
1366
1414
|
mockNgrokApiFetch([
|
|
1367
1415
|
{ tunnels: [] },
|
|
1368
1416
|
{
|
|
@@ -1383,10 +1431,10 @@ describe("ngrok --domain spawn args", () => {
|
|
|
1383
1431
|
const [cmd, args] = spawnMock.mock.calls[0];
|
|
1384
1432
|
expect(cmd).toBe("ngrok");
|
|
1385
1433
|
expect(args).toEqual([
|
|
1386
|
-
"
|
|
1387
|
-
"
|
|
1434
|
+
"start",
|
|
1435
|
+
"cadet",
|
|
1436
|
+
`--config=${realNgrok.ngrokConfigPath()}`,
|
|
1388
1437
|
"--log=stdout",
|
|
1389
|
-
"--domain=foo.ngrok.app",
|
|
1390
1438
|
]);
|
|
1391
1439
|
|
|
1392
1440
|
const config = IngressTestConfigSchema.parse(
|
|
@@ -1396,9 +1444,8 @@ describe("ngrok --domain spawn args", () => {
|
|
|
1396
1444
|
});
|
|
1397
1445
|
|
|
1398
1446
|
test("runNgrokTunnel without --domain reuses the saved domain and does not delete it", async () => {
|
|
1399
|
-
const ws = makeWorkspace({
|
|
1400
|
-
|
|
1401
|
-
});
|
|
1447
|
+
const ws = makeWorkspace({});
|
|
1448
|
+
realNgrok.saveNgrokDomain("foo.ngrok.app");
|
|
1402
1449
|
mockNgrokApiFetch([
|
|
1403
1450
|
{ tunnels: [] },
|
|
1404
1451
|
{
|
|
@@ -1422,17 +1469,17 @@ describe("ngrok --domain spawn args", () => {
|
|
|
1422
1469
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
1423
1470
|
const [, args] = spawnMock.mock.calls[0];
|
|
1424
1471
|
expect(args).toEqual([
|
|
1425
|
-
"
|
|
1426
|
-
"
|
|
1472
|
+
"start",
|
|
1473
|
+
"cadet",
|
|
1474
|
+
`--config=${realNgrok.ngrokConfigPath()}`,
|
|
1427
1475
|
"--log=stdout",
|
|
1428
|
-
"--domain=foo.ngrok.app",
|
|
1429
1476
|
]);
|
|
1430
1477
|
|
|
1431
1478
|
const config = IngressTestConfigSchema.parse(
|
|
1432
1479
|
JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
|
|
1433
1480
|
);
|
|
1434
1481
|
expect(config.ingress?.publicBaseUrl).toBe("https://foo.ngrok.app");
|
|
1435
|
-
expect(
|
|
1482
|
+
expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
|
|
1436
1483
|
});
|
|
1437
1484
|
});
|
|
1438
1485
|
|
|
@@ -20,7 +20,7 @@ import type { GuardianTokenData } from "../lib/guardian-token.js";
|
|
|
20
20
|
import * as guardianToken from "../lib/guardian-token.js";
|
|
21
21
|
import * as local from "../lib/local.js";
|
|
22
22
|
import * as loopbackFetch from "../lib/loopback-fetch.js";
|
|
23
|
-
import * as ngrok from "../lib/ngrok.js";
|
|
23
|
+
import * as ngrok from "../lib/tunnels/ngrok.js";
|
|
24
24
|
import * as upgradeLifecycle from "../lib/upgrade-lifecycle.js";
|
|
25
25
|
|
|
26
26
|
const realAssistantConfig = { ...assistantConfig };
|
|
@@ -157,7 +157,7 @@ const maybeStartNgrokTunnelMock = mock<typeof ngrok.maybeStartNgrokTunnel>(
|
|
|
157
157
|
async () => null,
|
|
158
158
|
);
|
|
159
159
|
|
|
160
|
-
mock.module("../lib/ngrok.js", () => ({
|
|
160
|
+
mock.module("../lib/tunnels/ngrok.js", () => ({
|
|
161
161
|
...realNgrok,
|
|
162
162
|
maybeStartNgrokTunnel: maybeStartNgrokTunnelMock,
|
|
163
163
|
}));
|
|
@@ -302,7 +302,7 @@ afterAll(() => {
|
|
|
302
302
|
mock.module("../lib/guardian-token.js", () => realGuardianToken);
|
|
303
303
|
mock.module("../lib/local.js", () => realLocal);
|
|
304
304
|
mock.module("../lib/loopback-fetch.js", () => realLoopbackFetch);
|
|
305
|
-
mock.module("../lib/ngrok.js", () => realNgrok);
|
|
305
|
+
mock.module("../lib/tunnels/ngrok.js", () => realNgrok);
|
|
306
306
|
mock.module("../lib/upgrade-lifecycle.js", () => realUpgradeLifecycle);
|
|
307
307
|
});
|
|
308
308
|
|
|
@@ -20,7 +20,7 @@ import * as httpClient from "../lib/http-client.js";
|
|
|
20
20
|
import * as ingressConfig from "../lib/ingress-config.js";
|
|
21
21
|
import * as local from "../lib/local.js";
|
|
22
22
|
import * as nginxIngress from "../lib/nginx-ingress.js";
|
|
23
|
-
import * as ngrok from "../lib/ngrok.js";
|
|
23
|
+
import * as ngrok from "../lib/tunnels/ngrok.js";
|
|
24
24
|
import * as processLib from "../lib/process.js";
|
|
25
25
|
import * as portAllocator from "../lib/port-allocator.js";
|
|
26
26
|
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
@@ -157,7 +157,7 @@ const maybeStartNgrokTunnelMock = mock<typeof ngrok.maybeStartNgrokTunnel>(
|
|
|
157
157
|
async () => null,
|
|
158
158
|
);
|
|
159
159
|
|
|
160
|
-
mock.module("../lib/ngrok", () => ({
|
|
160
|
+
mock.module("../lib/tunnels/ngrok", () => ({
|
|
161
161
|
...realNgrok,
|
|
162
162
|
maybeStartNgrokTunnel: maybeStartNgrokTunnelMock,
|
|
163
163
|
}));
|
|
@@ -329,7 +329,7 @@ afterAll(() => {
|
|
|
329
329
|
mock.module("../lib/process", () => realProcessLib);
|
|
330
330
|
mock.module("../lib/port-allocator.js", () => realPortAllocator);
|
|
331
331
|
mock.module("../lib/local", () => realLocal);
|
|
332
|
-
mock.module("../lib/ngrok", () => realNgrok);
|
|
332
|
+
mock.module("../lib/tunnels/ngrok", () => realNgrok);
|
|
333
333
|
mock.module("../lib/ingress-config.js", () => realIngressConfig);
|
|
334
334
|
mock.module("../lib/nginx-ingress.js", () => realNginxIngress);
|
|
335
335
|
mock.module("../lib/http-client.js", () => realHttpClient);
|
|
@@ -956,9 +956,13 @@ describe("cadet wake — tunnel edge restore", () => {
|
|
|
956
956
|
workspaceDir: defaultWorkspace,
|
|
957
957
|
gatewayPort: 7930,
|
|
958
958
|
});
|
|
959
|
-
// Container wakes
|
|
960
|
-
//
|
|
961
|
-
expect(maybeStartNgrokTunnelMock).
|
|
959
|
+
// Container wakes run the same auto-tunnel as local wakes (docker
|
|
960
|
+
// parity); the spawned agent records itself in the root lockfile.
|
|
961
|
+
expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
|
|
962
|
+
7840,
|
|
963
|
+
defaultWorkspace,
|
|
964
|
+
"docker-1",
|
|
965
|
+
);
|
|
962
966
|
expect(logSpy).toHaveBeenCalledWith("Wake complete.");
|
|
963
967
|
} finally {
|
|
964
968
|
if (originalWorkspaceDir === undefined) {
|
|
@@ -1093,7 +1097,7 @@ describe("cadet wake — tunnel edge restore", () => {
|
|
|
1093
1097
|
}
|
|
1094
1098
|
});
|
|
1095
1099
|
|
|
1096
|
-
test("a failed docker edge restore
|
|
1100
|
+
test("a failed docker edge restore falls back to the gateway tunnel", async () => {
|
|
1097
1101
|
const defaultWorkspace = mkdtempSync(join(tmpdir(), "cadet-wake-default-"));
|
|
1098
1102
|
const originalWorkspaceDir = process.env.CADET_WORKSPACE_DIR;
|
|
1099
1103
|
process.env.CADET_WORKSPACE_DIR = defaultWorkspace;
|
|
@@ -1112,11 +1116,14 @@ describe("cadet wake — tunnel edge restore", () => {
|
|
|
1112
1116
|
try {
|
|
1113
1117
|
await wake();
|
|
1114
1118
|
|
|
1119
|
+
// Docker wakes get the same tunnel logic as local ones: when the edge
|
|
1120
|
+
// cannot be rebuilt, the auto-tunnel still fronts the gateway port so
|
|
1121
|
+
// webhook delivery survives.
|
|
1115
1122
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
1116
|
-
expect.stringContaining("
|
|
1123
|
+
expect.stringContaining("Webhooks still work"),
|
|
1117
1124
|
);
|
|
1118
1125
|
expect(warnSpy).not.toHaveBeenCalledWith(
|
|
1119
|
-
expect.stringContaining("
|
|
1126
|
+
expect.stringContaining("webhook delivery are unavailable"),
|
|
1120
1127
|
);
|
|
1121
1128
|
} finally {
|
|
1122
1129
|
if (originalWorkspaceDir === undefined) {
|
package/src/commands/client.ts
CHANGED
|
@@ -1223,7 +1223,7 @@ const WEB_BACKGROUND_START_TIMEOUT_MS = 30_000;
|
|
|
1223
1223
|
* The port is resolved up front (explicit --port must be free; otherwise the
|
|
1224
1224
|
* first free port at/above 3000) and pinned via `--port` on the child so the
|
|
1225
1225
|
* URL printed here is the one the child binds. The child's stdout/stderr go to
|
|
1226
|
-
* `<xdg-log-dir>/client-web.log` — same detach idiom as the nginx/
|
|
1226
|
+
* `<xdg-log-dir>/client-web.log` — same detach idiom as the nginx/tunnel
|
|
1227
1227
|
* spawns. Success is only reported once the child is accepting connections on
|
|
1228
1228
|
* the port; an early child exit (e.g. missing @mycadet/web assets) or a
|
|
1229
1229
|
* startup timeout fails with a pointer at the log file.
|