@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
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)],
|
|
@@ -984,6 +984,41 @@ function resolveGatewayDir(resources?: LocalInstanceResources): string {
|
|
|
984
984
|
}
|
|
985
985
|
}
|
|
986
986
|
|
|
987
|
+
/**
|
|
988
|
+
* How to invoke a `gateway <subcommand>` CLI process so it resolves the same
|
|
989
|
+
* workspace AND gateway security dir as the locally spawned gateway server:
|
|
990
|
+
* run the gateway package's CLI entry through bun with the spawn state root
|
|
991
|
+
* as cwd and the instance-pinned CADET_WORKSPACE_DIR / GATEWAY_SECURITY_DIR
|
|
992
|
+
* the server was given (the file-backed pairing store must resolve
|
|
993
|
+
* identically in both processes). Callers pass the resolved assistant
|
|
994
|
+
* entry's `resources` (undefined for the default instance, where both
|
|
995
|
+
* processes fall back to the same cwd-relative paths).
|
|
996
|
+
*/
|
|
997
|
+
export function gatewayCliInvocation(resources?: LocalInstanceResources): {
|
|
998
|
+
command: string;
|
|
999
|
+
args: string[];
|
|
1000
|
+
cwd: string;
|
|
1001
|
+
env: Record<string, string | undefined>;
|
|
1002
|
+
} {
|
|
1003
|
+
return {
|
|
1004
|
+
command: resolveBunExecutable(),
|
|
1005
|
+
args: ["run", join(resolveGatewayDir(resources), "src/cli/index.ts")],
|
|
1006
|
+
cwd: spawnStateRoot(),
|
|
1007
|
+
env: {
|
|
1008
|
+
...process.env,
|
|
1009
|
+
...(resources
|
|
1010
|
+
? {
|
|
1011
|
+
CADET_WORKSPACE_DIR: join(resources.instanceDir, "workspace"),
|
|
1012
|
+
GATEWAY_SECURITY_DIR: join(
|
|
1013
|
+
resources.instanceDir,
|
|
1014
|
+
"gateway-security",
|
|
1015
|
+
),
|
|
1016
|
+
}
|
|
1017
|
+
: {}),
|
|
1018
|
+
},
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
|
|
987
1022
|
function resolveCesDir(resources?: LocalInstanceResources): string {
|
|
988
1023
|
const runtimeCesDir = localRuntimeCesDir(resources);
|
|
989
1024
|
if (runtimeCesDir) return runtimeCesDir;
|
|
@@ -1118,9 +1153,7 @@ export async function startCes(
|
|
|
1118
1153
|
// Source tree / bunx: run the CES entry point via bun.
|
|
1119
1154
|
const cesDir = runtimeCesDir ?? resolveCesDir(resources);
|
|
1120
1155
|
const cesEntry = join(cesDir, "src/main.ts");
|
|
1121
|
-
const bunArgs = watch
|
|
1122
|
-
? ["--watch", "run", cesEntry]
|
|
1123
|
-
: ["run", cesEntry];
|
|
1156
|
+
const bunArgs = watch ? ["--watch", "run", cesEntry] : ["run", cesEntry];
|
|
1124
1157
|
const cesLogFd = openLogFile("hatch.log");
|
|
1125
1158
|
ces = spawn(resolveBunExecutable(), bunArgs, {
|
|
1126
1159
|
cwd: spawnStateRoot(),
|
|
@@ -1666,7 +1699,7 @@ export async function startLocalDaemon(
|
|
|
1666
1699
|
windowsHide: true,
|
|
1667
1700
|
})
|
|
1668
1701
|
: (() => {
|
|
1669
|
-
const daemonLogFd =
|
|
1702
|
+
const daemonLogFd = openRuntimeLogFile(resources?.instanceDir ?? spawnStateRoot(), "assistant");
|
|
1670
1703
|
const c = spawn(daemonBinary, [], {
|
|
1671
1704
|
cwd: spawnStateRoot(),
|
|
1672
1705
|
detached: true,
|
|
@@ -1872,7 +1905,7 @@ export async function startGateway(
|
|
|
1872
1905
|
// Use the compiled gateway binary when available (desktop app or compiled
|
|
1873
1906
|
// CLI invoked from the terminal). In watch mode, skip the bundled binary
|
|
1874
1907
|
// and use source (bun --watch only works with source files).
|
|
1875
|
-
const gatewayLogFd =
|
|
1908
|
+
const gatewayLogFd = openRuntimeLogFile(resources?.instanceDir ?? spawnStateRoot(), "gateway");
|
|
1876
1909
|
gateway = spawn(gatewayBinary, [], {
|
|
1877
1910
|
cwd: spawnStateRoot(),
|
|
1878
1911
|
detached: true,
|
|
@@ -1888,7 +1921,7 @@ export async function startGateway(
|
|
|
1888
1921
|
const bunArgs = watch
|
|
1889
1922
|
? ["--watch", "run", gatewayEntry, "--cadet-gateway"]
|
|
1890
1923
|
: ["run", gatewayEntry, "--cadet-gateway"];
|
|
1891
|
-
const gwLogFd =
|
|
1924
|
+
const gwLogFd = openRuntimeLogFile(resources?.instanceDir ?? spawnStateRoot(), "gateway");
|
|
1892
1925
|
gateway = spawn(resolveBunExecutable(), bunArgs, {
|
|
1893
1926
|
cwd: spawnStateRoot(),
|
|
1894
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,10 +192,10 @@ 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
|
-
cloudDisabled: true,
|
|
181
|
-
disableCloud: true,
|
|
182
199
|
...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
|
|
183
200
|
...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
|
|
184
201
|
...(opts.hubUrl ? { hubUrl: opts.hubUrl } : {}),
|
|
@@ -191,17 +208,27 @@ function remoteWebIngressConfig(
|
|
|
191
208
|
* fingerprint matches, so this must change whenever the generated index or
|
|
192
209
|
* nginx template does.
|
|
193
210
|
*/
|
|
194
|
-
export const EDGE_TEMPLATE_VERSION =
|
|
211
|
+
export const EDGE_TEMPLATE_VERSION = 10;
|
|
195
212
|
|
|
196
213
|
/**
|
|
197
|
-
* Stable fingerprint of the SPA config
|
|
198
|
-
*
|
|
214
|
+
* Stable fingerprint of the injected SPA config, its source bundle, and
|
|
215
|
+
* the template that renders them. Recorded
|
|
199
216
|
* alongside the edge state so a reuse decision can tell whether a running edge
|
|
200
217
|
* already serves the requested config (see `IngressState.remoteWebConfigHash`).
|
|
201
218
|
*/
|
|
202
|
-
function remoteWebConfigFingerprint(
|
|
219
|
+
function remoteWebConfigFingerprint(
|
|
220
|
+
config: Record<string, unknown>,
|
|
221
|
+
webDistDir: string,
|
|
222
|
+
): string {
|
|
203
223
|
return createHash("sha256")
|
|
204
|
-
.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
|
+
)
|
|
205
232
|
.digest("hex");
|
|
206
233
|
}
|
|
207
234
|
|
|
@@ -280,6 +307,44 @@ export function hasIpv6Loopback(): boolean {
|
|
|
280
307
|
.some((addr) => addr.address === "::1");
|
|
281
308
|
}
|
|
282
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
|
+
|
|
283
348
|
/**
|
|
284
349
|
* Build the nginx config that forwards tunnel web traffic to the gateway.
|
|
285
350
|
*/
|
|
@@ -576,7 +641,7 @@ export interface IngressState {
|
|
|
576
641
|
*/
|
|
577
642
|
gatewayPort?: number;
|
|
578
643
|
/**
|
|
579
|
-
* Fingerprint of the SPA config
|
|
644
|
+
* Fingerprint of the injected SPA config and its source bundle. Undefined
|
|
580
645
|
* for webhooks-only edges and for SPA records that predate the field; an
|
|
581
646
|
* SPA edge without a recorded fingerprint is treated as drifted and
|
|
582
647
|
* restarted so the served index provably carries the requested config.
|
|
@@ -690,6 +755,7 @@ export function startIngressNginx(opts: {
|
|
|
690
755
|
? {
|
|
691
756
|
remoteWebConfigHash: remoteWebConfigFingerprint(
|
|
692
757
|
remoteWebIngress.config,
|
|
758
|
+
remoteWebIngress.webDistDir,
|
|
693
759
|
),
|
|
694
760
|
}
|
|
695
761
|
: {}),
|
|
@@ -859,7 +925,7 @@ export async function startRemoteWebIngress(opts: {
|
|
|
859
925
|
listenPort: number;
|
|
860
926
|
}) => void;
|
|
861
927
|
}): Promise<StartRemoteWebIngressResult> {
|
|
862
|
-
|
|
928
|
+
let listenPort = opts.listenPort ?? getNginxIngressPort();
|
|
863
929
|
const includeWebApp = opts.includeWebApp ?? true;
|
|
864
930
|
|
|
865
931
|
const version = getNginxVersion();
|
|
@@ -870,6 +936,13 @@ export async function startRemoteWebIngress(opts: {
|
|
|
870
936
|
const running = isIngressRunning(opts.workspaceDir);
|
|
871
937
|
const recorded = running ? readIngressState(opts.workspaceDir) : null;
|
|
872
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
|
+
}
|
|
873
946
|
// The SPA config the served index must carry, computed up front so the
|
|
874
947
|
// reuse decision and the actual spawn share one value and cannot drift.
|
|
875
948
|
const spaOptions = includeWebApp
|
|
@@ -879,9 +952,17 @@ export async function startRemoteWebIngress(opts: {
|
|
|
879
952
|
...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
|
|
880
953
|
}
|
|
881
954
|
: undefined;
|
|
882
|
-
const
|
|
883
|
-
|
|
884
|
-
:
|
|
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;
|
|
885
966
|
// Both sides are undefined in webhooks-only mode; an SPA record without a
|
|
886
967
|
// fingerprint predates the field and counts as drifted (see IngressState).
|
|
887
968
|
const configMatches = recorded?.remoteWebConfigHash === requestedConfigHash;
|
|
@@ -908,14 +989,6 @@ export async function startRemoteWebIngress(opts: {
|
|
|
908
989
|
return alreadyRunning();
|
|
909
990
|
}
|
|
910
991
|
|
|
911
|
-
let webDistDir: string | null = null;
|
|
912
|
-
if (includeWebApp) {
|
|
913
|
-
webDistDir = findWebDistDir();
|
|
914
|
-
if (!webDistDir) {
|
|
915
|
-
return { status: "web-dist-missing" };
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
|
|
919
992
|
// The running edge serves the other mode, targets a different gateway
|
|
920
993
|
// port, or injects a stale SPA config; restart it with the requested
|
|
921
994
|
// config. A false stop can mean the old edge exited on its own after the
|
|
@@ -928,6 +1001,23 @@ export async function startRemoteWebIngress(opts: {
|
|
|
928
1001
|
return alreadyRunning();
|
|
929
1002
|
}
|
|
930
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
|
+
|
|
931
1021
|
opts.onStarting?.({ version, webDistDir, listenPort });
|
|
932
1022
|
|
|
933
1023
|
const child = startIngressNginx({
|
|
@@ -1044,6 +1134,7 @@ export async function stopContainerTunnelEdge(
|
|
|
1044
1134
|
function stampAppProcessEntry(
|
|
1045
1135
|
workspaceDir: string,
|
|
1046
1136
|
assistantId?: string,
|
|
1137
|
+
port = getNginxIngressPort(),
|
|
1047
1138
|
): void {
|
|
1048
1139
|
const pid = getIngressPid(workspaceDir);
|
|
1049
1140
|
if (pid !== null) {
|
|
@@ -1051,6 +1142,7 @@ function stampAppProcessEntry(
|
|
|
1051
1142
|
pid,
|
|
1052
1143
|
startedAt: new Date().toISOString(),
|
|
1053
1144
|
assistantId,
|
|
1145
|
+
port,
|
|
1054
1146
|
});
|
|
1055
1147
|
}
|
|
1056
1148
|
}
|
|
@@ -1118,7 +1210,11 @@ export async function ensureTunnelEdge(opts: {
|
|
|
1118
1210
|
|
|
1119
1211
|
switch (result.status) {
|
|
1120
1212
|
case "started":
|
|
1121
|
-
stampAppProcessEntry(
|
|
1213
|
+
stampAppProcessEntry(
|
|
1214
|
+
opts.workspaceDir,
|
|
1215
|
+
opts.assistantId,
|
|
1216
|
+
result.listenPort,
|
|
1217
|
+
);
|
|
1122
1218
|
return {
|
|
1123
1219
|
port: result.listenPort,
|
|
1124
1220
|
started: true,
|
|
@@ -1152,7 +1248,11 @@ export async function ensureTunnelEdge(opts: {
|
|
|
1152
1248
|
stuckEdgeRecoveryHint(opts.workspaceDir),
|
|
1153
1249
|
);
|
|
1154
1250
|
}
|
|
1155
|
-
stampAppProcessEntry(
|
|
1251
|
+
stampAppProcessEntry(
|
|
1252
|
+
opts.workspaceDir,
|
|
1253
|
+
opts.assistantId,
|
|
1254
|
+
result.listenPort,
|
|
1255
|
+
);
|
|
1156
1256
|
return {
|
|
1157
1257
|
port: result.listenPort,
|
|
1158
1258
|
started: false,
|
package/src/lib/step-runner.ts
CHANGED
|
@@ -112,11 +112,16 @@ export function execWithStdin(
|
|
|
112
112
|
export function execOutput(
|
|
113
113
|
command: string,
|
|
114
114
|
args: string[],
|
|
115
|
-
options: {
|
|
115
|
+
options: {
|
|
116
|
+
cwd?: string;
|
|
117
|
+
timeoutMs?: number;
|
|
118
|
+
env?: Record<string, string | undefined>;
|
|
119
|
+
} = {},
|
|
116
120
|
): Promise<string> {
|
|
117
121
|
return new Promise((resolve, reject) => {
|
|
118
122
|
const child = spawn(command, args, {
|
|
119
123
|
cwd: options.cwd,
|
|
124
|
+
env: options.env,
|
|
120
125
|
stdio: ["pipe", "pipe", "pipe"],
|
|
121
126
|
windowsHide: true,
|
|
122
127
|
});
|
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
|
+
};
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { clearRootProcess, stampRootProcess } from "../assistant-config.js";
|
|
2
|
+
import { readProcessCommandLine } from "../process.js";
|
|
3
|
+
import { stopOwnedTunnelAgent } from "./process-lifecycle.js";
|
|
4
|
+
import type {
|
|
5
|
+
TunnelProvider,
|
|
6
|
+
ProviderMeta,
|
|
7
|
+
ProviderTunnelResult,
|
|
8
|
+
} from "../tunnel.js";
|
|
2
9
|
|
|
3
10
|
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
|
|
4
11
|
|
|
@@ -106,6 +113,7 @@ export function waitForCloudflareTunnelUrl(
|
|
|
106
113
|
});
|
|
107
114
|
|
|
108
115
|
child.on("exit", (code) => {
|
|
116
|
+
if (child.pid) clearRootProcess("tunnel", child.pid);
|
|
109
117
|
if (resolved) return;
|
|
110
118
|
clearTimeout(timer);
|
|
111
119
|
reject(
|
|
@@ -177,6 +185,7 @@ export async function runCloudflareTunnel(
|
|
|
177
185
|
|
|
178
186
|
const cleanup = (): void => {
|
|
179
187
|
if (!child.killed) child.kill("SIGTERM");
|
|
188
|
+
if (child.pid) clearRootProcess("tunnel", child.pid);
|
|
180
189
|
if (publicUrl) {
|
|
181
190
|
console.log("\nClearing ingress URL from config...");
|
|
182
191
|
clearIngressUrl(workspaceDir, opts.assistantId);
|
|
@@ -198,6 +207,7 @@ export async function runCloudflareTunnel(
|
|
|
198
207
|
});
|
|
199
208
|
|
|
200
209
|
child.on("exit", (code) => {
|
|
210
|
+
if (child.pid) clearRootProcess("tunnel", child.pid);
|
|
201
211
|
// Always clear the saved ingress URL when the tunnel process ends so
|
|
202
212
|
// webhook integrations don't keep hitting a dead endpoint.
|
|
203
213
|
if (publicUrl !== undefined) {
|
|
@@ -233,6 +243,13 @@ export async function runCloudflareTunnel(
|
|
|
233
243
|
console.log("");
|
|
234
244
|
|
|
235
245
|
saveIngressUrl(workspaceDir, publicUrl, opts.assistantId, "cloudflare");
|
|
246
|
+
if (child.pid)
|
|
247
|
+
stampRootProcess("tunnel", {
|
|
248
|
+
pid: child.pid,
|
|
249
|
+
provider: "cloudflare",
|
|
250
|
+
assistantId: opts.assistantId,
|
|
251
|
+
startedAt: new Date().toISOString(),
|
|
252
|
+
});
|
|
236
253
|
console.log("Ingress URL saved to config.");
|
|
237
254
|
console.log("");
|
|
238
255
|
console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
|
|
@@ -251,3 +268,21 @@ export const CLOUDFLARE_TUNNEL_META: ProviderMeta = {
|
|
|
251
268
|
notes: ["No Cloudflare account required for quick tunnels."],
|
|
252
269
|
supportsReservedDomain: false,
|
|
253
270
|
};
|
|
271
|
+
|
|
272
|
+
export const CLOUDFLARE_PROVIDER: TunnelProvider = {
|
|
273
|
+
run: runCloudflareTunnel,
|
|
274
|
+
stop: (opts) =>
|
|
275
|
+
stopOwnedTunnelAgent(
|
|
276
|
+
opts,
|
|
277
|
+
(pid) => {
|
|
278
|
+
try {
|
|
279
|
+
return /(?:^|[\\/\s])cloudflared(?:\.exe)?(?:\s|$)/i.test(
|
|
280
|
+
readProcessCommandLine(pid),
|
|
281
|
+
);
|
|
282
|
+
} catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
"cloudflared",
|
|
287
|
+
),
|
|
288
|
+
};
|
package/src/lib/tunnels/ngrok.ts
CHANGED
|
@@ -25,7 +25,12 @@ import { getCurrentEnvironment } from "../environments/resolve.js";
|
|
|
25
25
|
import { loopbackSafeFetch } from "../loopback-fetch.js";
|
|
26
26
|
import { readProcessCommandLine } from "../process.js";
|
|
27
27
|
import { z } from "zod";
|
|
28
|
-
import
|
|
28
|
+
import { stopOwnedTunnelAgent } from "./process-lifecycle.js";
|
|
29
|
+
import type {
|
|
30
|
+
TunnelProvider,
|
|
31
|
+
ProviderMeta,
|
|
32
|
+
ProviderTunnelResult,
|
|
33
|
+
} from "../tunnel.js";
|
|
29
34
|
import { notifyGatewayOfPublicUrl } from "./gateway-notify.js";
|
|
30
35
|
|
|
31
36
|
const NGROK_API_URL = "http://127.0.0.1:4040/api/tunnels";
|
|
@@ -631,8 +636,12 @@ export async function runNgrokTunnel(
|
|
|
631
636
|
console.log("Ingress URL saved to config.");
|
|
632
637
|
console.log("");
|
|
633
638
|
console.log(
|
|
634
|
-
|
|
639
|
+
`An ngrok agent from an earlier run is still up serving 127.0.0.1:${port} (${existingUrl}) — this run adopts it instead of starting a new agent.`,
|
|
635
640
|
);
|
|
641
|
+
console.log(
|
|
642
|
+
"Adopted agents do not appear in `cadet ps` because this run does not own the process.",
|
|
643
|
+
);
|
|
644
|
+
console.log("Press Ctrl+C to detach (tunnel stays active).");
|
|
636
645
|
|
|
637
646
|
// Block until SIGINT/SIGTERM
|
|
638
647
|
const waitForExit = new Promise<void>((resolve) => {
|
|
@@ -783,3 +792,8 @@ export async function runNgrokTunnel(
|
|
|
783
792
|
// Unreachable: the loop only exits via return, process.exit, or a throw.
|
|
784
793
|
throw new Error("ngrok tunnel did not start.");
|
|
785
794
|
}
|
|
795
|
+
|
|
796
|
+
export const NGROK_PROVIDER: TunnelProvider = {
|
|
797
|
+
run: runNgrokTunnel,
|
|
798
|
+
stop: (opts) => stopOwnedTunnelAgent(opts, isNgrokProcess, "ngrok"),
|
|
799
|
+
};
|