@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
package/src/lib/hatch-local.ts
CHANGED
|
@@ -52,7 +52,6 @@ import {
|
|
|
52
52
|
import { logHatchNextSteps } from "./hatch-next-steps.js";
|
|
53
53
|
import { printInstanceDetails } from "./instance-details.js";
|
|
54
54
|
import { checkProviderApiKey } from "./api-key-check.js";
|
|
55
|
-
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
56
55
|
import {
|
|
57
56
|
describeLoginError,
|
|
58
57
|
ensureCloudLoginInteractive,
|
|
@@ -156,8 +155,6 @@ export interface HatchLocalParams {
|
|
|
156
155
|
name?: string | null;
|
|
157
156
|
/** Build from a local source tree and hot-reload on change. */
|
|
158
157
|
watch?: boolean;
|
|
159
|
-
/** Stay attached after hatch and poll the gateway until it stops. */
|
|
160
|
-
keepAlive?: boolean;
|
|
161
158
|
/** Hatch-time config values (key → value). */
|
|
162
159
|
configValues?: Record<string, string>;
|
|
163
160
|
/** Extra env vars forwarded into the assistant process. */
|
|
@@ -192,13 +189,7 @@ export interface HatchLocalResult {
|
|
|
192
189
|
export async function hatchLocal(
|
|
193
190
|
params: HatchLocalParams = {},
|
|
194
191
|
): Promise<HatchLocalResult> {
|
|
195
|
-
const {
|
|
196
|
-
name,
|
|
197
|
-
watch = false,
|
|
198
|
-
keepAlive = false,
|
|
199
|
-
configValues = {},
|
|
200
|
-
flagEnvVars = {},
|
|
201
|
-
} = params;
|
|
192
|
+
const { name, watch = false, configValues = {}, flagEnvVars = {} } = params;
|
|
202
193
|
const reporter = params.reporter ?? consoleLifecycleReporter;
|
|
203
194
|
const verbose = params.verbose ?? false;
|
|
204
195
|
const setupProviderCredentials = params.setupProviderCredentials !== false;
|
|
@@ -454,46 +445,5 @@ export async function hatchLocal(
|
|
|
454
445
|
guardianAccessToken,
|
|
455
446
|
};
|
|
456
447
|
|
|
457
|
-
if (keepAlive) {
|
|
458
|
-
const healthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`;
|
|
459
|
-
const healthTarget = "Gateway";
|
|
460
|
-
const POLL_INTERVAL_MS = 5000;
|
|
461
|
-
const MAX_FAILURES = 3;
|
|
462
|
-
let consecutiveFailures = 0;
|
|
463
|
-
|
|
464
|
-
const shutdown = async (): Promise<void> => {
|
|
465
|
-
reporter.log("\nShutting down local processes...");
|
|
466
|
-
await stopLocalProcesses(resources);
|
|
467
|
-
process.exit(0);
|
|
468
|
-
};
|
|
469
|
-
|
|
470
|
-
process.on("SIGTERM", () => void shutdown());
|
|
471
|
-
process.on("SIGINT", () => void shutdown());
|
|
472
|
-
|
|
473
|
-
// Poll the health endpoint until it stops responding.
|
|
474
|
-
while (true) {
|
|
475
|
-
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
476
|
-
try {
|
|
477
|
-
const res = await loopbackSafeFetch(healthUrl, {
|
|
478
|
-
signal: AbortSignal.timeout(3000),
|
|
479
|
-
});
|
|
480
|
-
if (res.ok) {
|
|
481
|
-
consecutiveFailures = 0;
|
|
482
|
-
} else {
|
|
483
|
-
consecutiveFailures++;
|
|
484
|
-
}
|
|
485
|
-
} catch {
|
|
486
|
-
consecutiveFailures++;
|
|
487
|
-
}
|
|
488
|
-
if (consecutiveFailures >= MAX_FAILURES) {
|
|
489
|
-
reporter.log(
|
|
490
|
-
`\n⚠️ ${healthTarget} stopped responding — shutting down.`,
|
|
491
|
-
);
|
|
492
|
-
await stopLocalProcesses(resources);
|
|
493
|
-
process.exit(1);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
448
|
return result;
|
|
499
449
|
}
|
|
@@ -1,8 +1,21 @@
|
|
|
1
|
+
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
2
|
+
|
|
3
|
+
function cliCommand(): string {
|
|
4
|
+
// Production keeps the bare `cadet` binary; every non-production
|
|
5
|
+
// environment installs as `cadet-<env>` (the same rule as the config and
|
|
6
|
+
// state dirs — see environments/paths.ts).
|
|
7
|
+
const env = getCurrentEnvironment();
|
|
8
|
+
return env.name === "production" ? "cadet" : `cadet-${env.name}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
1
11
|
export function logHatchNextSteps(log: (message: string) => void): void {
|
|
12
|
+
const command = cliCommand();
|
|
2
13
|
log("Next steps:");
|
|
3
|
-
log(" - Run `cadet client` to interact with your cadet via the terminal");
|
|
4
14
|
log(
|
|
5
|
-
|
|
15
|
+
` - Run \`${command} client\` to interact with your cadet via the terminal`,
|
|
16
|
+
);
|
|
17
|
+
log(
|
|
18
|
+
` - Run \`${command} tunnel\` to make your cadet publicly accessible and interact with it via the browser`,
|
|
6
19
|
);
|
|
7
20
|
log("");
|
|
8
21
|
}
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* mint again rather than reuse what resolution returned.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
import {
|
|
31
|
+
import { tunnelHostForCloudHost } from "@mycadet/service-contracts/ingress";
|
|
32
32
|
|
|
33
33
|
import { authHeaders } from "../cloud-client.js";
|
|
34
34
|
|
|
@@ -66,7 +66,7 @@ export function tunnelHostForCloudUrl(cloudUrl: string): string {
|
|
|
66
66
|
}
|
|
67
67
|
try {
|
|
68
68
|
return (
|
|
69
|
-
|
|
69
|
+
tunnelHostForCloudHost(new URL(cloudUrl).host) ??
|
|
70
70
|
DEFAULT_TUNNEL_HOST
|
|
71
71
|
);
|
|
72
72
|
} catch {
|
package/src/lib/local.ts
CHANGED
|
@@ -79,7 +79,7 @@ import {
|
|
|
79
79
|
} from "./process.js";
|
|
80
80
|
import { stopTunnelProcess } from "./tunnel.js";
|
|
81
81
|
import { stripVersionPrefix } from "./version-compat.js";
|
|
82
|
-
import { openLogFile, pipeToLogFile } from "./xdg-log.js";
|
|
82
|
+
import { openLogFile, openRuntimeLogFile, pipeToLogFile } from "./xdg-log.js";
|
|
83
83
|
|
|
84
84
|
const _require = createRequire(import.meta.url);
|
|
85
85
|
|
|
@@ -839,7 +839,7 @@ async function startDaemonFromSource(
|
|
|
839
839
|
windowsHide: true,
|
|
840
840
|
})
|
|
841
841
|
: (() => {
|
|
842
|
-
const daemonLogFd =
|
|
842
|
+
const daemonLogFd = openRuntimeLogFile(resources?.instanceDir ?? spawnStateRoot(), "assistant");
|
|
843
843
|
const c = spawn(bunPath, ["run", resolve(daemonMainPath)], {
|
|
844
844
|
cwd: spawnStateRoot(),
|
|
845
845
|
detached: true,
|
|
@@ -912,7 +912,7 @@ async function startDaemonWatchFromSource(
|
|
|
912
912
|
// detect the in-progress spawn and wait instead of racing.
|
|
913
913
|
writeFileSync(pidFile, "starting", "utf-8");
|
|
914
914
|
|
|
915
|
-
const daemonLogFd =
|
|
915
|
+
const daemonLogFd = openRuntimeLogFile(resources?.instanceDir ?? spawnStateRoot(), "assistant");
|
|
916
916
|
const child = spawn(
|
|
917
917
|
resolveBunExecutable(),
|
|
918
918
|
["--watch", "run", resolve(mainPath)],
|
|
@@ -1699,7 +1699,7 @@ export async function startLocalDaemon(
|
|
|
1699
1699
|
windowsHide: true,
|
|
1700
1700
|
})
|
|
1701
1701
|
: (() => {
|
|
1702
|
-
const daemonLogFd =
|
|
1702
|
+
const daemonLogFd = openRuntimeLogFile(resources?.instanceDir ?? spawnStateRoot(), "assistant");
|
|
1703
1703
|
const c = spawn(daemonBinary, [], {
|
|
1704
1704
|
cwd: spawnStateRoot(),
|
|
1705
1705
|
detached: true,
|
|
@@ -1905,7 +1905,7 @@ export async function startGateway(
|
|
|
1905
1905
|
// Use the compiled gateway binary when available (desktop app or compiled
|
|
1906
1906
|
// CLI invoked from the terminal). In watch mode, skip the bundled binary
|
|
1907
1907
|
// and use source (bun --watch only works with source files).
|
|
1908
|
-
const gatewayLogFd =
|
|
1908
|
+
const gatewayLogFd = openRuntimeLogFile(resources?.instanceDir ?? spawnStateRoot(), "gateway");
|
|
1909
1909
|
gateway = spawn(gatewayBinary, [], {
|
|
1910
1910
|
cwd: spawnStateRoot(),
|
|
1911
1911
|
detached: true,
|
|
@@ -1921,7 +1921,7 @@ export async function startGateway(
|
|
|
1921
1921
|
const bunArgs = watch
|
|
1922
1922
|
? ["--watch", "run", gatewayEntry, "--cadet-gateway"]
|
|
1923
1923
|
: ["run", gatewayEntry, "--cadet-gateway"];
|
|
1924
|
-
const gwLogFd =
|
|
1924
|
+
const gwLogFd = openRuntimeLogFile(resources?.instanceDir ?? spawnStateRoot(), "gateway");
|
|
1925
1925
|
gateway = spawn(resolveBunExecutable(), bunArgs, {
|
|
1926
1926
|
cwd: spawnStateRoot(),
|
|
1927
1927
|
detached: true,
|
package/src/lib/nginx-ingress.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
rmSync,
|
|
14
14
|
writeFileSync,
|
|
15
15
|
} from "node:fs";
|
|
16
|
+
import { createServer } from "node:net";
|
|
16
17
|
import { networkInterfaces } from "node:os";
|
|
17
18
|
import { join } from "node:path";
|
|
18
19
|
|
|
@@ -166,7 +167,23 @@ export function cloudWebHubUrl(env: string | undefined): string {
|
|
|
166
167
|
// must always run: it is reached through this self-hosted edge however the
|
|
167
168
|
// cadet itself is hosted. "cloud" is never stamped (the cloud serves its
|
|
168
169
|
// own bundles).
|
|
169
|
-
|
|
170
|
+
/** The lockfile's recorded remote kind for the assistant, if one is found. */
|
|
171
|
+
function lockfileAssistantRemote(assistantId: string): string | undefined {
|
|
172
|
+
const result = lookupAssistantByIdentifier(assistantId);
|
|
173
|
+
return result.status === "found" ? result.entry.cloud : undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The remote kind stamped into the served SPA config. Docker and custom
|
|
178
|
+
* cadets are stamped truthfully; every other hosting shape — a local cadet
|
|
179
|
+
* reached through this edge, paired remotes, unknown entries — reads as
|
|
180
|
+
* "custom" so the served bundle always runs the remote-gateway machinery
|
|
181
|
+
* (this edge serves remote browsers however the cadet itself is hosted).
|
|
182
|
+
* "cloud" is never stamped: the cloud serves its own bundles.
|
|
183
|
+
*/
|
|
184
|
+
export function edgeServedRemote(recorded: string | undefined): RemoteHost {
|
|
185
|
+
return recorded === "docker" ? "docker" : "custom";
|
|
186
|
+
}
|
|
170
187
|
|
|
171
188
|
function remoteWebIngressConfig(
|
|
172
189
|
opts: Pick<
|
|
@@ -175,7 +192,9 @@ function remoteWebIngressConfig(
|
|
|
175
192
|
>,
|
|
176
193
|
): Record<string, unknown> {
|
|
177
194
|
return {
|
|
178
|
-
remote:
|
|
195
|
+
remote: edgeServedRemote(
|
|
196
|
+
opts.assistantId ? lockfileAssistantRemote(opts.assistantId) : undefined,
|
|
197
|
+
),
|
|
179
198
|
apiBaseUrl: "/v1",
|
|
180
199
|
...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
|
|
181
200
|
...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
|
|
@@ -192,14 +211,24 @@ function remoteWebIngressConfig(
|
|
|
192
211
|
export const EDGE_TEMPLATE_VERSION = 10;
|
|
193
212
|
|
|
194
213
|
/**
|
|
195
|
-
* Stable fingerprint of the SPA config
|
|
196
|
-
*
|
|
214
|
+
* Stable fingerprint of the injected SPA config, its source bundle, and
|
|
215
|
+
* the template that renders them. Recorded
|
|
197
216
|
* alongside the edge state so a reuse decision can tell whether a running edge
|
|
198
217
|
* already serves the requested config (see `IngressState.remoteWebConfigHash`).
|
|
199
218
|
*/
|
|
200
|
-
function remoteWebConfigFingerprint(
|
|
219
|
+
function remoteWebConfigFingerprint(
|
|
220
|
+
config: Record<string, unknown>,
|
|
221
|
+
webDistDir: string,
|
|
222
|
+
): string {
|
|
201
223
|
return createHash("sha256")
|
|
202
|
-
.update(
|
|
224
|
+
.update(
|
|
225
|
+
JSON.stringify({
|
|
226
|
+
template: EDGE_TEMPLATE_VERSION,
|
|
227
|
+
config,
|
|
228
|
+
webDistDir,
|
|
229
|
+
indexHtml: readFileSync(join(webDistDir, "index.html"), "utf-8"),
|
|
230
|
+
}),
|
|
231
|
+
)
|
|
203
232
|
.digest("hex");
|
|
204
233
|
}
|
|
205
234
|
|
|
@@ -278,6 +307,44 @@ export function hasIpv6Loopback(): boolean {
|
|
|
278
307
|
.some((addr) => addr.address === "::1");
|
|
279
308
|
}
|
|
280
309
|
|
|
310
|
+
/** Whether a TCP listener already holds `port` on the given loopback host. */
|
|
311
|
+
async function isPortInUse(
|
|
312
|
+
port: number,
|
|
313
|
+
host: "127.0.0.1" | "::1",
|
|
314
|
+
): Promise<boolean> {
|
|
315
|
+
return await new Promise((resolve) => {
|
|
316
|
+
const probe = createServer();
|
|
317
|
+
probe.once("error", () => resolve(true));
|
|
318
|
+
probe.once("listening", () => {
|
|
319
|
+
probe.close(() => resolve(false));
|
|
320
|
+
});
|
|
321
|
+
probe.listen(port, host);
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The first free loopback port at or above `startPort`, scanning a short band,
|
|
327
|
+
* or null when the whole band is busy. Both stacks the edge may bind are
|
|
328
|
+
* probed (the IPv4 loopback always, the IPv6 loopback when the host has one),
|
|
329
|
+
* so the returned port is one nginx can actually take.
|
|
330
|
+
*/
|
|
331
|
+
export async function findAvailableIngressPort(
|
|
332
|
+
startPort: number,
|
|
333
|
+
attempts = 20,
|
|
334
|
+
): Promise<number | null> {
|
|
335
|
+
const checkIpv6 = hasIpv6Loopback();
|
|
336
|
+
for (let port = startPort; port < startPort + attempts; port++) {
|
|
337
|
+
if (await isPortInUse(port, "127.0.0.1")) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (checkIpv6 && (await isPortInUse(port, "::1"))) {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
return port;
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
281
348
|
/**
|
|
282
349
|
* Build the nginx config that forwards tunnel web traffic to the gateway.
|
|
283
350
|
*/
|
|
@@ -574,7 +641,7 @@ export interface IngressState {
|
|
|
574
641
|
*/
|
|
575
642
|
gatewayPort?: number;
|
|
576
643
|
/**
|
|
577
|
-
* Fingerprint of the SPA config
|
|
644
|
+
* Fingerprint of the injected SPA config and its source bundle. Undefined
|
|
578
645
|
* for webhooks-only edges and for SPA records that predate the field; an
|
|
579
646
|
* SPA edge without a recorded fingerprint is treated as drifted and
|
|
580
647
|
* restarted so the served index provably carries the requested config.
|
|
@@ -688,6 +755,7 @@ export function startIngressNginx(opts: {
|
|
|
688
755
|
? {
|
|
689
756
|
remoteWebConfigHash: remoteWebConfigFingerprint(
|
|
690
757
|
remoteWebIngress.config,
|
|
758
|
+
remoteWebIngress.webDistDir,
|
|
691
759
|
),
|
|
692
760
|
}
|
|
693
761
|
: {}),
|
|
@@ -857,7 +925,7 @@ export async function startRemoteWebIngress(opts: {
|
|
|
857
925
|
listenPort: number;
|
|
858
926
|
}) => void;
|
|
859
927
|
}): Promise<StartRemoteWebIngressResult> {
|
|
860
|
-
|
|
928
|
+
let listenPort = opts.listenPort ?? getNginxIngressPort();
|
|
861
929
|
const includeWebApp = opts.includeWebApp ?? true;
|
|
862
930
|
|
|
863
931
|
const version = getNginxVersion();
|
|
@@ -868,6 +936,13 @@ export async function startRemoteWebIngress(opts: {
|
|
|
868
936
|
const running = isIngressRunning(opts.workspaceDir);
|
|
869
937
|
const recorded = running ? readIngressState(opts.workspaceDir) : null;
|
|
870
938
|
const recordedMode = recorded?.includeWebApp ?? true;
|
|
939
|
+
if (
|
|
940
|
+
recorded &&
|
|
941
|
+
opts.listenPort === undefined &&
|
|
942
|
+
process.env.CADET_NGINX_INGRESS_PORT === undefined
|
|
943
|
+
) {
|
|
944
|
+
listenPort = recorded.listenPort;
|
|
945
|
+
}
|
|
871
946
|
// The SPA config the served index must carry, computed up front so the
|
|
872
947
|
// reuse decision and the actual spawn share one value and cannot drift.
|
|
873
948
|
const spaOptions = includeWebApp
|
|
@@ -877,9 +952,17 @@ export async function startRemoteWebIngress(opts: {
|
|
|
877
952
|
...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
|
|
878
953
|
}
|
|
879
954
|
: undefined;
|
|
880
|
-
const
|
|
881
|
-
|
|
882
|
-
:
|
|
955
|
+
const webDistDir = includeWebApp ? findWebDistDir() : null;
|
|
956
|
+
if (includeWebApp && !webDistDir) {
|
|
957
|
+
return { status: "web-dist-missing" };
|
|
958
|
+
}
|
|
959
|
+
const requestedConfigHash =
|
|
960
|
+
spaOptions && webDistDir
|
|
961
|
+
? remoteWebConfigFingerprint(
|
|
962
|
+
remoteWebIngressConfig(spaOptions),
|
|
963
|
+
webDistDir,
|
|
964
|
+
)
|
|
965
|
+
: undefined;
|
|
883
966
|
// Both sides are undefined in webhooks-only mode; an SPA record without a
|
|
884
967
|
// fingerprint predates the field and counts as drifted (see IngressState).
|
|
885
968
|
const configMatches = recorded?.remoteWebConfigHash === requestedConfigHash;
|
|
@@ -906,14 +989,6 @@ export async function startRemoteWebIngress(opts: {
|
|
|
906
989
|
return alreadyRunning();
|
|
907
990
|
}
|
|
908
991
|
|
|
909
|
-
let webDistDir: string | null = null;
|
|
910
|
-
if (includeWebApp) {
|
|
911
|
-
webDistDir = findWebDistDir();
|
|
912
|
-
if (!webDistDir) {
|
|
913
|
-
return { status: "web-dist-missing" };
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
|
|
917
992
|
// The running edge serves the other mode, targets a different gateway
|
|
918
993
|
// port, or injects a stale SPA config; restart it with the requested
|
|
919
994
|
// config. A false stop can mean the old edge exited on its own after the
|
|
@@ -926,6 +1001,23 @@ export async function startRemoteWebIngress(opts: {
|
|
|
926
1001
|
return alreadyRunning();
|
|
927
1002
|
}
|
|
928
1003
|
|
|
1004
|
+
// A foreign process squatting the default edge port (another assistant's
|
|
1005
|
+
// edge, a stale dev server) must not kill the tunnel: the edge port is
|
|
1006
|
+
// loopback-local and the public tunnel URL never carries it, so moving to
|
|
1007
|
+
// the next free port is invisible to remote clients. Explicit pins
|
|
1008
|
+
// (opts.listenPort / CADET_NGINX_INGRESS_PORT) keep their intent and still
|
|
1009
|
+
// fail on a collision.
|
|
1010
|
+
if (
|
|
1011
|
+
opts.listenPort === undefined &&
|
|
1012
|
+
process.env.CADET_NGINX_INGRESS_PORT === undefined
|
|
1013
|
+
) {
|
|
1014
|
+
const discovered = await findAvailableIngressPort(listenPort);
|
|
1015
|
+
if (discovered !== null && discovered !== listenPort) {
|
|
1016
|
+
console.log(`Port ${listenPort} in use; using ${discovered} instead.`);
|
|
1017
|
+
listenPort = discovered;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
929
1021
|
opts.onStarting?.({ version, webDistDir, listenPort });
|
|
930
1022
|
|
|
931
1023
|
const child = startIngressNginx({
|
|
@@ -1042,6 +1134,7 @@ export async function stopContainerTunnelEdge(
|
|
|
1042
1134
|
function stampAppProcessEntry(
|
|
1043
1135
|
workspaceDir: string,
|
|
1044
1136
|
assistantId?: string,
|
|
1137
|
+
port = getNginxIngressPort(),
|
|
1045
1138
|
): void {
|
|
1046
1139
|
const pid = getIngressPid(workspaceDir);
|
|
1047
1140
|
if (pid !== null) {
|
|
@@ -1049,6 +1142,7 @@ function stampAppProcessEntry(
|
|
|
1049
1142
|
pid,
|
|
1050
1143
|
startedAt: new Date().toISOString(),
|
|
1051
1144
|
assistantId,
|
|
1145
|
+
port,
|
|
1052
1146
|
});
|
|
1053
1147
|
}
|
|
1054
1148
|
}
|
|
@@ -1116,7 +1210,11 @@ export async function ensureTunnelEdge(opts: {
|
|
|
1116
1210
|
|
|
1117
1211
|
switch (result.status) {
|
|
1118
1212
|
case "started":
|
|
1119
|
-
stampAppProcessEntry(
|
|
1213
|
+
stampAppProcessEntry(
|
|
1214
|
+
opts.workspaceDir,
|
|
1215
|
+
opts.assistantId,
|
|
1216
|
+
result.listenPort,
|
|
1217
|
+
);
|
|
1120
1218
|
return {
|
|
1121
1219
|
port: result.listenPort,
|
|
1122
1220
|
started: true,
|
|
@@ -1150,7 +1248,11 @@ export async function ensureTunnelEdge(opts: {
|
|
|
1150
1248
|
stuckEdgeRecoveryHint(opts.workspaceDir),
|
|
1151
1249
|
);
|
|
1152
1250
|
}
|
|
1153
|
-
stampAppProcessEntry(
|
|
1251
|
+
stampAppProcessEntry(
|
|
1252
|
+
opts.workspaceDir,
|
|
1253
|
+
opts.assistantId,
|
|
1254
|
+
result.listenPort,
|
|
1255
|
+
);
|
|
1154
1256
|
return {
|
|
1155
1257
|
port: result.listenPort,
|
|
1156
1258
|
started: false,
|
package/src/lib/tunnel.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { TUNNEL_PROVIDERS } from "@mycadet/service-contracts/ingress";
|
|
1
3
|
import {
|
|
2
4
|
clearRootProcess,
|
|
3
5
|
loadAllAssistants,
|
|
@@ -24,26 +26,24 @@ import { waitForDaemonReady } from "./http-client.js";
|
|
|
24
26
|
import {
|
|
25
27
|
hasWebhookIntegrations,
|
|
26
28
|
IngressConfigSchema,
|
|
27
|
-
isNgrokProcess,
|
|
28
29
|
maybeStartNgrokTunnel,
|
|
29
30
|
NGROK_TUNNEL_META,
|
|
30
|
-
|
|
31
|
+
NGROK_PROVIDER,
|
|
31
32
|
saveNgrokDomain,
|
|
32
33
|
} from "./tunnels/ngrok.js";
|
|
33
|
-
import { stopProcess } from "./process.js";
|
|
34
34
|
import { shellArg } from "./shell-arg.js";
|
|
35
35
|
import { notifyGatewayOfPublicUrl } from "./tunnels/gateway-notify.js";
|
|
36
36
|
import {
|
|
37
37
|
selectRestartTunnelRecord,
|
|
38
38
|
type TunnelProviderName,
|
|
39
39
|
} from "@mycadet/service-contracts/ingress";
|
|
40
|
-
import {
|
|
40
|
+
import { CADET_PROVIDER, CADET_TUNNEL_META } from "./tunnels/cadet.js";
|
|
41
41
|
import {
|
|
42
|
-
|
|
42
|
+
CLOUDFLARE_PROVIDER,
|
|
43
43
|
CLOUDFLARE_TUNNEL_META,
|
|
44
44
|
} from "./tunnels/cloudflare.js";
|
|
45
45
|
import {
|
|
46
|
-
|
|
46
|
+
TAILSCALE_PROVIDER,
|
|
47
47
|
TAILSCALE_TUNNEL_META,
|
|
48
48
|
} from "./tunnels/tailscale.js";
|
|
49
49
|
|
|
@@ -119,6 +119,25 @@ export interface ProviderTunnelResult {
|
|
|
119
119
|
waitForExit: Promise<void>;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
export interface ProviderStopOptions {
|
|
123
|
+
record: RootProcessRecord;
|
|
124
|
+
assistantId?: string;
|
|
125
|
+
workspaceDir?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface TunnelProvider {
|
|
129
|
+
run(opts: ProviderTunnelOptions): Promise<ProviderTunnelResult>;
|
|
130
|
+
stop(opts: ProviderStopOptions): Promise<boolean>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const providers: Record<TunnelProviderName, TunnelProvider> = {
|
|
134
|
+
ngrok: NGROK_PROVIDER,
|
|
135
|
+
cloudflare: CLOUDFLARE_PROVIDER,
|
|
136
|
+
tailscale: TAILSCALE_PROVIDER,
|
|
137
|
+
cadet: CADET_PROVIDER,
|
|
138
|
+
};
|
|
139
|
+
const providerNameSchema = z.enum(TUNNEL_PROVIDERS);
|
|
140
|
+
|
|
122
141
|
/**
|
|
123
142
|
* Run a tunnel provider by name — the dispatch `cadet tunnel` uses, so the
|
|
124
143
|
* command layer never names a provider implementation directly. Notifies the
|
|
@@ -140,29 +159,7 @@ function runProvider(
|
|
|
140
159
|
provider: TunnelProviderName,
|
|
141
160
|
opts: ProviderTunnelOptions,
|
|
142
161
|
): Promise<ProviderTunnelResult> {
|
|
143
|
-
|
|
144
|
-
port: opts.port,
|
|
145
|
-
assistantId: opts.assistantId,
|
|
146
|
-
workspaceDir: opts.workspaceDir,
|
|
147
|
-
};
|
|
148
|
-
if (provider === "ngrok") {
|
|
149
|
-
return runNgrokTunnel({
|
|
150
|
-
...base,
|
|
151
|
-
...(opts.domain ? { domain: opts.domain } : {}),
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
if (provider === "cadet") {
|
|
155
|
-
return runCadetTunnel(opts);
|
|
156
|
-
}
|
|
157
|
-
if (provider === "cloudflare") {
|
|
158
|
-
return runCloudflareTunnel(base);
|
|
159
|
-
}
|
|
160
|
-
return runTailscaleTunnel({
|
|
161
|
-
...base,
|
|
162
|
-
...(opts.preserveIngressUrl
|
|
163
|
-
? { preserveIngressUrl: opts.preserveIngressUrl }
|
|
164
|
-
: {}),
|
|
165
|
-
});
|
|
162
|
+
return providers[provider].run(opts);
|
|
166
163
|
}
|
|
167
164
|
|
|
168
165
|
/** Drop the saved reserved-domain record (providers that keep one). */
|
|
@@ -176,13 +173,31 @@ export function clearReservedDomain(): void {
|
|
|
176
173
|
* signaling — a stale entry after PID reuse must not kill an unrelated
|
|
177
174
|
* process.
|
|
178
175
|
*/
|
|
179
|
-
export async function stopTunnelProcess(
|
|
176
|
+
export async function stopTunnelProcess(
|
|
177
|
+
assistantId?: string,
|
|
178
|
+
workspaceDir?: string,
|
|
179
|
+
): Promise<boolean> {
|
|
180
180
|
const recorded: RootProcessRecord | undefined = readRootProcesses().tunnel;
|
|
181
|
-
if (!recorded) return;
|
|
182
|
-
if (
|
|
183
|
-
|
|
181
|
+
if (!recorded) return false;
|
|
182
|
+
if (
|
|
183
|
+
assistantId &&
|
|
184
|
+
recorded.assistantId &&
|
|
185
|
+
recorded.assistantId !== assistantId
|
|
186
|
+
) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`The recorded tunnel belongs to '${recorded.assistantId}', not '${assistantId}'.`,
|
|
189
|
+
);
|
|
184
190
|
}
|
|
185
|
-
|
|
191
|
+
const provider = providerNameSchema.parse(
|
|
192
|
+
recorded.provider ?? DEFAULT_TUNNEL_PROVIDER,
|
|
193
|
+
);
|
|
194
|
+
const stopped = await providers[provider].stop({
|
|
195
|
+
record: recorded,
|
|
196
|
+
assistantId,
|
|
197
|
+
workspaceDir,
|
|
198
|
+
});
|
|
199
|
+
clearRootProcess("tunnel", recorded.pid);
|
|
200
|
+
return stopped;
|
|
186
201
|
}
|
|
187
202
|
|
|
188
203
|
/**
|
|
@@ -197,6 +212,43 @@ export function adoptedTunnelStopNotice(pid: number | undefined): string {
|
|
|
197
212
|
);
|
|
198
213
|
}
|
|
199
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Pull the single most meaningful failure line out of a tunnel log so the
|
|
217
|
+
* `cadet tunnel` wrapper can name why the child died instead of only pointing
|
|
218
|
+
* at the log file. Priority: nginx emerg/bind failures, ngrok error codes,
|
|
219
|
+
* auth/usage failures, then any generic error/fatal/failed line — the LAST
|
|
220
|
+
* match wins (the freshest), capped to keep the console line readable. Falls
|
|
221
|
+
* back to the last non-empty log line, or null for an empty log.
|
|
222
|
+
*/
|
|
223
|
+
export function summarizeStartupFailure(logText: string): string | null {
|
|
224
|
+
const lines = logText
|
|
225
|
+
.split("\n")
|
|
226
|
+
.map((line) => line.trim())
|
|
227
|
+
.filter(Boolean);
|
|
228
|
+
if (lines.length === 0) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
const patterns: RegExp[] = [
|
|
232
|
+
/\[emerg\]/,
|
|
233
|
+
/bind\(\).*failed/i,
|
|
234
|
+
/address already in use/i,
|
|
235
|
+
/ERR_NGROK_\d+/,
|
|
236
|
+
/authtoken|authentication failed|usage limit|unauthorized/i,
|
|
237
|
+
/\berror\b|\bfatal\b|\bfailed\b/i,
|
|
238
|
+
];
|
|
239
|
+
const cap = (line: string): string =>
|
|
240
|
+
line.length > 200 ? `${line.slice(0, 200)}…` : line;
|
|
241
|
+
for (const pattern of patterns) {
|
|
242
|
+
const matches = lines.filter((line) => pattern.test(line));
|
|
243
|
+
const last = matches.at(-1);
|
|
244
|
+
if (last !== undefined) {
|
|
245
|
+
return cap(last);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const lastLine = lines.at(-1);
|
|
249
|
+
return lastLine === undefined ? null : cap(lastLine);
|
|
250
|
+
}
|
|
251
|
+
|
|
200
252
|
export {
|
|
201
253
|
ADOPTED_TUNNEL_RE,
|
|
202
254
|
hasWebhookIntegrationsConfigured,
|
package/src/lib/tunnels/cadet.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { STALE_CLI_UPDATE_HINT } from "../stale-cli-hint.js";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
TunnelProvider,
|
|
4
|
+
ProviderMeta,
|
|
5
|
+
ProviderTunnelOptions,
|
|
6
|
+
} from "../tunnel.js";
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* The managed `cadet` tunnel. It is listed in the shared registry so the
|
|
@@ -27,3 +31,10 @@ export async function runCadetTunnel(
|
|
|
27
31
|
): Promise<never> {
|
|
28
32
|
throw cadetTunnelComingSoonError("cadet");
|
|
29
33
|
}
|
|
34
|
+
|
|
35
|
+
export const CADET_PROVIDER: TunnelProvider = {
|
|
36
|
+
run: runCadetTunnel,
|
|
37
|
+
async stop() {
|
|
38
|
+
throw cadetTunnelComingSoonError("cadet");
|
|
39
|
+
},
|
|
40
|
+
};
|