@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.
Files changed (41) hide show
  1. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
  2. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
  3. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
  4. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
  5. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
  6. package/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/cloud-login-poll.test.ts +69 -0
  9. package/src/__tests__/devices.test.ts +62 -0
  10. package/src/__tests__/ensure-binary.test.ts +52 -0
  11. package/src/__tests__/hatch-next-steps.test.ts +38 -0
  12. package/src/__tests__/multi-local.test.ts +3 -3
  13. package/src/__tests__/nginx-ingress.test.ts +55 -17
  14. package/src/__tests__/ps-cloud-status.test.ts +2 -1
  15. package/src/__tests__/setup.test.ts +14 -4
  16. package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
  17. package/src/__tests__/tunnel-stop.test.ts +226 -0
  18. package/src/__tests__/tunnel.test.ts +12 -2
  19. package/src/commands/devices.ts +66 -30
  20. package/src/commands/hatch.ts +10 -52
  21. package/src/commands/pair.ts +7 -1
  22. package/src/commands/ps.ts +4 -2
  23. package/src/commands/tunnel.ts +43 -4
  24. package/src/commands/upgrade.ts +6 -1
  25. package/src/lib/assistant-config.ts +20 -5
  26. package/src/lib/cloud-login-poll.ts +75 -0
  27. package/src/lib/cloud-login.ts +3 -1
  28. package/src/lib/docker.ts +6 -12
  29. package/src/lib/ensure-binary.ts +65 -6
  30. package/src/lib/hatch-local.ts +1 -51
  31. package/src/lib/hatch-next-steps.ts +15 -2
  32. package/src/lib/live-voice/tunnel.ts +2 -2
  33. package/src/lib/local.ts +6 -6
  34. package/src/lib/nginx-ingress.ts +123 -21
  35. package/src/lib/tunnel.ts +86 -34
  36. package/src/lib/tunnels/cadet.ts +12 -1
  37. package/src/lib/tunnels/cloudflare.ts +36 -1
  38. package/src/lib/tunnels/ngrok.ts +16 -2
  39. package/src/lib/tunnels/process-lifecycle.ts +26 -0
  40. package/src/lib/tunnels/tailscale.ts +79 -1
  41. package/src/lib/xdg-log.ts +11 -0
@@ -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
- " - Run `cadet tunnel` to make your cadet publicly accessible and interact with it via the browser",
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 { tunnelHostForPlatformHost } from "@mycadet/service-contracts/ingress";
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
- tunnelHostForPlatformHost(new URL(cloudUrl).host) ??
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 = openLogFile("hatch.log");
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 = openLogFile("hatch.log");
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 = openLogFile("hatch.log");
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 = openLogFile("hatch.log");
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 = openLogFile("hatch.log");
1924
+ const gwLogFd = openRuntimeLogFile(resources?.instanceDir ?? spawnStateRoot(), "gateway");
1925
1925
  gateway = spawn(resolveBunExecutable(), bunArgs, {
1926
1926
  cwd: spawnStateRoot(),
1927
1927
  detached: true,
@@ -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
- const EDGE_SERVED_REMOTE: RemoteHost = "custom";
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: EDGE_SERVED_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 injected into the served index and
196
- * `/assistant/__config`, plus the template that renders them. Recorded
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(config: Record<string, unknown>): string {
219
+ function remoteWebConfigFingerprint(
220
+ config: Record<string, unknown>,
221
+ webDistDir: string,
222
+ ): string {
201
223
  return createHash("sha256")
202
- .update(JSON.stringify({ template: EDGE_TEMPLATE_VERSION, config }))
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 injected into the served index. Undefined
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
- const listenPort = opts.listenPort ?? getNginxIngressPort();
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 requestedConfigHash = spaOptions
881
- ? remoteWebConfigFingerprint(remoteWebIngressConfig(spaOptions))
882
- : undefined;
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(opts.workspaceDir, opts.assistantId);
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(opts.workspaceDir, opts.assistantId);
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
- runNgrokTunnel,
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 { runCadetTunnel, CADET_TUNNEL_META } from "./tunnels/cadet.js";
40
+ import { CADET_PROVIDER, CADET_TUNNEL_META } from "./tunnels/cadet.js";
41
41
  import {
42
- runCloudflareTunnel,
42
+ CLOUDFLARE_PROVIDER,
43
43
  CLOUDFLARE_TUNNEL_META,
44
44
  } from "./tunnels/cloudflare.js";
45
45
  import {
46
- runTailscaleTunnel,
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
- const base = {
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(): Promise<void> {
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 (isNgrokProcess(recorded.pid)) {
183
- await stopProcess(recorded.pid, "tunnel");
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
- clearRootProcess("tunnel");
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,
@@ -1,5 +1,9 @@
1
1
  import { STALE_CLI_UPDATE_HINT } from "../stale-cli-hint.js";
2
- import type { ProviderMeta, ProviderTunnelOptions } from "../tunnel.js";
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
+ };