@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.
Files changed (55) 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 +4 -3
  8. package/src/__tests__/client-web-config.test.ts +0 -1
  9. package/src/__tests__/cloud-login-poll.test.ts +69 -0
  10. package/src/__tests__/devices.test.ts +62 -0
  11. package/src/__tests__/ensure-binary.test.ts +52 -0
  12. package/src/__tests__/hatch-next-steps.test.ts +38 -0
  13. package/src/__tests__/multi-local.test.ts +3 -3
  14. package/src/__tests__/nginx-ingress.test.ts +58 -26
  15. package/src/__tests__/pair.test.ts +149 -78
  16. package/src/__tests__/ps-cloud-status.test.ts +6 -4
  17. package/src/__tests__/setup.test.ts +14 -4
  18. package/src/__tests__/teleport.test.ts +0 -15
  19. package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
  20. package/src/__tests__/tunnel-stop.test.ts +226 -0
  21. package/src/__tests__/tunnel.test.ts +265 -70
  22. package/src/__tests__/uninstall.test.ts +140 -8
  23. package/src/commands/client.ts +4 -6
  24. package/src/commands/devices.ts +66 -30
  25. package/src/commands/hatch.ts +14 -53
  26. package/src/commands/pair.ts +147 -38
  27. package/src/commands/ps.ts +10 -20
  28. package/src/commands/teleport.ts +2 -21
  29. package/src/commands/tunnel.ts +67 -28
  30. package/src/commands/uninstall.ts +106 -71
  31. package/src/commands/upgrade.ts +6 -1
  32. package/src/lib/__tests__/local-gateway-cli.test.ts +42 -0
  33. package/src/lib/assistant-config.ts +20 -5
  34. package/src/lib/cloud-client.ts +0 -3
  35. package/src/lib/cloud-login-poll.ts +75 -0
  36. package/src/lib/cloud-login.ts +3 -2
  37. package/src/lib/color.ts +4 -0
  38. package/src/lib/docker.ts +46 -70
  39. package/src/lib/ensure-binary.ts +65 -6
  40. package/src/lib/environments/__tests__/paths.test.ts +27 -1
  41. package/src/lib/environments/paths.ts +16 -8
  42. package/src/lib/environments/resolve.ts +2 -4
  43. package/src/lib/hatch-local.ts +16 -59
  44. package/src/lib/hatch-next-steps.ts +18 -9
  45. package/src/lib/live-voice/tunnel.ts +2 -2
  46. package/src/lib/local.ts +42 -9
  47. package/src/lib/nginx-ingress.ts +124 -24
  48. package/src/lib/step-runner.ts +6 -1
  49. package/src/lib/tunnel.ts +86 -34
  50. package/src/lib/tunnels/cadet.ts +12 -1
  51. package/src/lib/tunnels/cloudflare.ts +36 -1
  52. package/src/lib/tunnels/ngrok.ts +16 -2
  53. package/src/lib/tunnels/process-lifecycle.ts +26 -0
  54. package/src/lib/tunnels/tailscale.ts +79 -1
  55. package/src/lib/xdg-log.ts +11 -0
@@ -3,9 +3,12 @@
3
3
  *
4
4
  * Print the pairing link for another device, and the same link rendered as a
5
5
  * QR code. Runs on the machine hosting the assistant: it mints a remote-web
6
- * pairing challenge over the loopback gateway and approves it immediately, so
7
- * running this command IS the local-presence proof and one scan (or one paste
8
- * into `cadet connect import`) completes the pairing.
6
+ * pairing challenge through the gateway CLI (`gateway pairing mint <url>` —
7
+ * locally, or docker-exec'd into the gateway container) which approves it
8
+ * immediately, so running this command IS the local-presence proof and one
9
+ * scan (or one paste into `cadet connect import`) completes the pairing.
10
+ * local and docker assistants mint without any HTTP; other topologies keep
11
+ * the loopback HTTP mint.
9
12
  *
10
13
  * `--web-approve` is the reverse direction: approve a code a device already
11
14
  * shows, for pairings that device started on its own.
@@ -38,8 +41,11 @@ import {
38
41
  import { GATEWAY_PORT } from "../lib/constants.js";
39
42
  import { getCurrentEnvironment } from "../lib/environments/resolve.js";
40
43
  import { isLoopbackUrl, loopbackSafeFetch } from "../lib/loopback-fetch.js";
44
+ import { dockerResourceNames } from "../lib/docker.js";
45
+ import { gatewayCliInvocation } from "../lib/local.js";
41
46
  import { formatWebApproveFailure, parseGatewayErrorCode } from "../lib/pair.js";
42
47
  import { STALE_CLI_UPDATE_HINT } from "../lib/stale-cli-hint.js";
48
+ import { execOutput } from "../lib/step-runner.js";
43
49
 
44
50
  function assistantDisplayName(entry: AssistantEntry): string {
45
51
  return entry.name || entry.assistantName || entry.assistantId;
@@ -89,9 +95,10 @@ function printUsage(): void {
89
95
  USAGE:
90
96
  cadet pair [assistant] [options]
91
97
 
92
- Mints a pairing challenge on the assistant's gateway over loopback and
93
- approves it on the spot (running this command on the host machine is the
94
- approval), then prints the pairing link and the same link as a QR code.
98
+ Mints a pairing challenge on the assistant's gateway (via the gateway CLI
99
+ for local and docker assistants, over loopback HTTP otherwise) and approves
100
+ it on the spot (running this command on the host machine is the approval),
101
+ then prints the pairing link and the same link as a QR code.
95
102
  Scanning the QR, opening the link, or passing it to 'cadet connect import'
96
103
  on the other device completes the pairing in one step.
97
104
 
@@ -214,26 +221,122 @@ async function exitOnHttpError(response: Response): Promise<Response> {
214
221
  return response;
215
222
  }
216
223
 
217
- /** Create a remote-web pairing challenge (RFC 8628 device-code flow). */
218
- async function createRemoteWebPairingChallenge(
219
- gatewayUrl: string,
224
+ /**
225
+ * Mint (and approve) a remote-web pairing challenge. `local` and `docker`
226
+ * remotes mint through the gateway CLI — `gateway pairing mint <url>` run on
227
+ * the assistant's host (locally, or docker-exec'd into the gateway container)
228
+ * — replacing the old loopback HTTP mint whose loopback-peer gate
229
+ * containerized gateways could never satisfy (published-port NAT rewrites
230
+ * host-originated peers to the docker bridge address). The mint executes the
231
+ * pairing logic directly against the file-backed challenge store, so the
232
+ * running gateway's verification routes see it with no IPC round-trip.
233
+ * Process execution is the local-presence proof; the mint command approves
234
+ * on the spot, so the returned challenge is already paired-ready. Other
235
+ * topologies keep the loopback HTTP mint they used before.
236
+ */
237
+ async function mintPairingChallengeOrExit(
238
+ entry: AssistantEntry,
220
239
  publicBaseUrl: string,
221
240
  ): Promise<RemoteWebPairingChallengeResponse> {
241
+ if (entry.cloud !== "local" && entry.cloud !== "docker") {
242
+ return mintPairingChallengeOverLoopbackOrExit(entry, publicBaseUrl);
243
+ }
244
+
245
+ let stdout: string;
246
+ try {
247
+ if (entry.cloud === "docker") {
248
+ stdout = await execOutput(
249
+ "docker",
250
+ [
251
+ "exec",
252
+ dockerResourceNames(entry.assistantId).gatewayContainer,
253
+ "bun",
254
+ "run",
255
+ "src/cli/index.ts",
256
+ "pairing",
257
+ "mint",
258
+ publicBaseUrl,
259
+ ],
260
+ { timeoutMs: 30_000 },
261
+ );
262
+ } else {
263
+ const invocation = gatewayCliInvocation(entry.resources);
264
+ stdout = await execOutput(
265
+ invocation.command,
266
+ [...invocation.args, "pairing", "mint", publicBaseUrl],
267
+ {
268
+ cwd: invocation.cwd,
269
+ env: invocation.env,
270
+ timeoutMs: 30_000,
271
+ },
272
+ );
273
+ }
274
+ } catch (err) {
275
+ console.error(
276
+ `Error: could not mint a pairing challenge on the gateway ` +
277
+ `(${err instanceof Error ? err.message : String(err)}).`,
278
+ );
279
+ console.error("Is the assistant running? Try `cadet wake`.");
280
+ process.exit(1);
281
+ }
282
+
283
+ const lastLine = stdout.split("\n").filter(Boolean).pop() ?? "";
284
+ let challenge: RemoteWebPairingChallengeResponse;
285
+ try {
286
+ challenge = JSON.parse(lastLine) as RemoteWebPairingChallengeResponse;
287
+ } catch {
288
+ console.error(
289
+ "Error: could not parse the gateway's pairing mint output.",
290
+ );
291
+ process.exit(1);
292
+ }
293
+ if (!challenge.deviceCode || !challenge.userCode) {
294
+ console.error(
295
+ "Error: the gateway's pairing mint output is missing the challenge codes.",
296
+ );
297
+ process.exit(1);
298
+ }
299
+ return challenge;
300
+ }
301
+
302
+ /**
303
+ * Mint a challenge over the gateway's loopback HTTP route — the path every
304
+ * topology took before the gateway-CLI mint existed. Kept for remotes that
305
+ * are neither `local` nor `docker` (apple containers, SSH-managed hosts,
306
+ * cloud-managed), where no gateway-CLI execution surface is defined.
307
+ */
308
+ async function mintPairingChallengeOverLoopbackOrExit(
309
+ entry: AssistantEntry,
310
+ publicBaseUrl: string,
311
+ ): Promise<RemoteWebPairingChallengeResponse> {
312
+ // Mint over loopback (localUrl avoids mDNS for same-machine calls), then
313
+ // approve immediately: running this CLI on the host IS the local-presence
314
+ // proof, so the other device completes pairing in one step from the link.
315
+ const mintUrl = (
316
+ entry.localUrl ||
317
+ entry.runtimeUrl ||
318
+ `http://127.0.0.1:${GATEWAY_PORT}`
319
+ ).replace(/\/+$/, "");
222
320
  const response = await gatewayPostOrExit(
223
- gatewayUrl,
321
+ mintUrl,
224
322
  "/v1/remote-web/pairing-challenge",
225
323
  { publicBaseUrl } satisfies RemoteWebPairingChallengeRequest,
226
324
  );
227
- return (await response.json()) as RemoteWebPairingChallengeResponse;
325
+ const challenge: RemoteWebPairingChallengeResponse =
326
+ await response.json();
327
+ await gatewayPostOrExit(mintUrl, "/v1/remote-web/pairing-verification", {
328
+ userCode: challenge.userCode,
329
+ } satisfies RemoteWebPairingVerificationRequest);
330
+ return challenge;
228
331
  }
229
332
 
230
333
  /**
231
334
  * Approve a pending pairing challenge by its user code, the local-presence
232
335
  * proof for the device-code flow. Single owner of the pairing-verification
233
- * route and request body: the link flow approves via
234
- * {@link approveRemoteWebPairing} (generic exit on non-2xx), while
235
- * `--web-approve` calls this directly to inspect rejections and print mismatch
236
- * diagnostics (see {@link formatWebApproveFailure}).
336
+ * route and request body: `--web-approve` calls this directly to inspect
337
+ * rejections and print mismatch diagnostics (see
338
+ * {@link formatWebApproveFailure}); the link flow needs no separate
339
+ * verification call — the gateway-CLI mint approves on the spot.
237
340
  */
238
341
  async function postPairingVerification(
239
342
  gatewayUrl: string,
@@ -244,20 +347,6 @@ async function postPairingVerification(
244
347
  } satisfies RemoteWebPairingVerificationRequest);
245
348
  }
246
349
 
247
- /**
248
- * Approve the challenge this command just minted so one scan completes
249
- * pairing, exiting with a generic message on non-2xx.
250
- */
251
- async function approveRemoteWebPairing(
252
- gatewayUrl: string,
253
- userCode: string,
254
- ): Promise<RemoteWebPairingVerificationResponse> {
255
- const response = await exitOnHttpError(
256
- await postPairingVerification(gatewayUrl, userCode),
257
- );
258
- return (await response.json()) as RemoteWebPairingVerificationResponse;
259
- }
260
-
261
350
  export const pair = Object.assign(
262
351
  async function pair(): Promise<void> {
263
352
  const rawArgs = process.argv.slice(3);
@@ -355,6 +444,24 @@ export const pair = Object.assign(
355
444
  }
356
445
  }
357
446
 
447
+ // Minting requires host presence on this machine. Native and docker
448
+ // topologies qualify (the latter via docker exec); an assistant this
449
+ // machine only knows through `connect import` has no host presence here
450
+ // at all, and `cadet pair` never worked against it (its loopback gate
451
+ // denied remote mints). Refuse clearly instead of a confusing mint
452
+ // failure.
453
+ if (entry.cloud === "paired") {
454
+ console.error(
455
+ `Error: ${formatAssistantReference(entry)} was paired as a remote ` +
456
+ "assistant (cadet connect import), so this machine does not host it.",
457
+ );
458
+ console.error(
459
+ "Run `cadet pair` on the machine hosting the assistant, or approve " +
460
+ "a code it shows with `cadet pair --web-approve <code>`.",
461
+ );
462
+ process.exit(1);
463
+ }
464
+
358
465
  // Mint over loopback (localUrl avoids mDNS for same-machine calls), but
359
466
  // advertise a REACHABLE url in the link, since the loopback url would point
360
467
  // the other device at its own localhost. Prefer an explicit --url, then the
@@ -437,14 +544,10 @@ export const pair = Object.assign(
437
544
  }
438
545
  const publicBaseUrl = resolved.url;
439
546
 
440
- // Mint a challenge and immediately approve it: running this CLI on the host
441
- // IS the local-presence proof, so the other device completes pairing in one
442
- // step from the link alone.
443
- const challenge = await createRemoteWebPairingChallenge(
444
- mintUrl,
445
- publicBaseUrl,
446
- );
447
- await approveRemoteWebPairing(mintUrl, challenge.userCode);
547
+ // Mint a challenge (approved on the spot by the gateway-CLI invocation):
548
+ // running this CLI on the host IS the local-presence proof, so the other
549
+ // device completes pairing in one step from the link alone.
550
+ const challenge = await mintPairingChallengeOrExit(entry, publicBaseUrl);
448
551
  const pairUrl = buildRemoteWebPairingUrl(challenge);
449
552
  const displayName = assistantDisplayName(entry);
450
553
  const appUrl = appVariant
@@ -483,6 +586,10 @@ export const pair = Object.assign(
483
586
  console.log(qr);
484
587
  });
485
588
  console.log("");
589
+ console.log("Or enter this code on the pairing page:");
590
+ console.log("");
591
+ console.log(` ${challenge.deviceCode}`);
592
+ console.log("");
486
593
  if (appUrl) {
487
594
  console.log("The QR opens the Cadet app. App link:");
488
595
  console.log("");
@@ -495,7 +602,9 @@ export const pair = Object.assign(
495
602
  console.log("");
496
603
  console.log(` ${pairUrl}`);
497
604
  console.log("");
498
- console.log(`On another computer, run: cadet connect import "${pairUrl}"`);
605
+ console.log("Or on another computer, run:");
606
+ console.log("");
607
+ console.log(` cadet connect import "${pairUrl}"`);
499
608
  console.log("");
500
609
  console.log(`Expires: ${challenge.expiresAt}`);
501
610
  },
@@ -1,3 +1,4 @@
1
+ import { getNginxIngressPort } from "../lib/nginx-ingress.js";
1
2
  import { isCloudHome } from "../lib/cloud-values.js";
2
3
  import { join } from "path";
3
4
 
@@ -649,10 +650,10 @@ async function getRootManagedRows(): Promise<RootManagedRow[]> {
649
650
  rows.push({
650
651
  assistantId: app.assistantId,
651
652
  row: {
652
- name: "nginx edge",
653
+ name: "nginx",
653
654
  type: APP_TYPE,
654
655
  status: withStatusEmoji(alive ? "running" : "not running"),
655
- info: alive ? `PID ${app.pid}` : `PID ${app.pid} (exited)`,
656
+ info: `port ${app.port ?? getNginxIngressPort()}, PID ${app.pid}${alive ? "" : " (exited)"}`,
656
657
  },
657
658
  });
658
659
  }
@@ -660,35 +661,23 @@ async function getRootManagedRows(): Promise<RootManagedRow[]> {
660
661
  }
661
662
 
662
663
  /**
663
- * Fold the CLI-managed process rows into ONE table with the assistant rows:
664
- * a process fronts an assistant when it recorded that assistant's id, and
665
- * those rows nest under it; anything unattributed (or fronting an assistant
666
- * this environment no longer lists) renders at the bottom, machine-level.
664
+ * Fold the CLI-managed process rows into ONE table with the assistant rows.
665
+ * Tunnel agents and the nginx edge are root resources of the machine, not
666
+ * children of whichever assistant they front — especially as the web bundle
667
+ * splits into /app and /ui — so every CLI-managed row renders machine-level.
667
668
  */
668
669
  async function assistantRowsWithRootProcesses(
669
670
  assistants: AssistantEntry[],
670
671
  healths: AssistantHealth[],
671
672
  activeAssistantId: string | null,
672
673
  ): Promise<TableRow[]> {
673
- const nestedByAssistant = new Map<string, TableRow[]>();
674
- const unattributed: TableRow[] = [];
675
- const knownIds = new Set(assistants.map((entry) => entry.assistantId));
676
- for (const managed of await getRootManagedRows()) {
677
- if (managed.assistantId && knownIds.has(managed.assistantId)) {
678
- const nested = nestedByAssistant.get(managed.assistantId) ?? [];
679
- nested.push({ ...managed.row, name: ` └ ${managed.row.name}` });
680
- nestedByAssistant.set(managed.assistantId, nested);
681
- } else {
682
- unattributed.push(managed.row);
683
- }
684
- }
674
+ const rootRows = (await getRootManagedRows()).map((managed) => managed.row);
685
675
 
686
676
  const rows: TableRow[] = [];
687
677
  assistants.forEach((entry, i) => {
688
678
  rows.push(formatAssistantListRow(entry, activeAssistantId, healths[i]!));
689
- rows.push(...(nestedByAssistant.get(entry.assistantId) ?? []));
690
679
  });
691
- rows.push(...unattributed);
680
+ rows.push(...rootRows);
692
681
  return rows;
693
682
  }
694
683
 
@@ -825,6 +814,7 @@ export async function listAllAssistants(verbose: boolean): Promise<void> {
825
814
  ),
826
815
  { name: "NAME", type: "TYPE", status: "STATUS", info: "INFO" },
827
816
  );
817
+ console.log("");
828
818
  }
829
819
 
830
820
  // ── Entry point ─────────────────────────────────────────────────
@@ -185,7 +185,6 @@ function printHelp(): void {
185
185
 
186
186
  export function parseArgs(argv: string[]): {
187
187
  from: string | undefined;
188
- to: string | undefined;
189
188
  targetEnv: "local" | "docker" | "platform" | undefined;
190
189
  targetName: string | undefined;
191
190
  keepSource: boolean;
@@ -193,7 +192,6 @@ export function parseArgs(argv: string[]): {
193
192
  help: boolean;
194
193
  } {
195
194
  let from: string | undefined;
196
- let to: string | undefined;
197
195
  let targetEnv: "local" | "docker" | "platform" | undefined;
198
196
  let targetName: string | undefined;
199
197
  let keepSource = false;
@@ -209,11 +207,6 @@ export function parseArgs(argv: string[]): {
209
207
  continue;
210
208
  }
211
209
  from = argv[++i];
212
- } else if (arg === "--to" && i + 1 < argv.length) {
213
- if (argv[i + 1].startsWith("--")) {
214
- continue;
215
- }
216
- to = argv[++i];
217
210
  } else if (
218
211
  arg === "--local" ||
219
212
  arg === "--docker" ||
@@ -242,7 +235,7 @@ export function parseArgs(argv: string[]): {
242
235
  process.exit(1);
243
236
  }
244
237
 
245
- return { from, to, targetEnv, targetName, keepSource, dryRun, help };
238
+ return { from, targetEnv, targetName, keepSource, dryRun, help };
246
239
  }
247
240
 
248
241
  // ---------------------------------------------------------------------------
@@ -1253,7 +1246,6 @@ async function tryInjectCloudCredentials(
1253
1246
  bearerToken: entry.bearerToken,
1254
1247
  assistantApiKey,
1255
1248
  cloudAssistantId: registration.assistant.id,
1256
- cloudBaseUrl: cloudUrl,
1257
1249
  organizationId: orgId,
1258
1250
  userId: user.id,
1259
1251
  webhookSecret: registration.webhook_secret,
@@ -1277,7 +1269,7 @@ async function tryInjectCloudCredentials(
1277
1269
  export const teleport = Object.assign(
1278
1270
  async function teleport(): Promise<void> {
1279
1271
  const args = process.argv.slice(3);
1280
- const { from, to, targetEnv, targetName, keepSource, dryRun, help } =
1272
+ const { from, targetEnv, targetName, keepSource, dryRun, help } =
1281
1273
  parseArgs(args);
1282
1274
 
1283
1275
  if (help) {
@@ -1285,17 +1277,6 @@ export const teleport = Object.assign(
1285
1277
  process.exit(0);
1286
1278
  }
1287
1279
 
1288
- // Legacy --to flag deprecation
1289
- if (to) {
1290
- console.error("Error: --to is deprecated. Use environment flags instead:");
1291
- console.error(
1292
- " cadet teleport --from <source> --local|--docker|--platform [name]",
1293
- );
1294
- console.error("");
1295
- console.error("Run 'cadet teleport --help' for details.");
1296
- process.exit(1);
1297
- }
1298
-
1299
1280
  if (!from) {
1300
1281
  printHelp();
1301
1282
  process.exit(1);
@@ -10,6 +10,7 @@ import {
10
10
  import {
11
11
  formatAssistantReference,
12
12
  loadAllAssistants,
13
+ readRootProcesses,
13
14
  resolveTargetAssistant,
14
15
  type AssistantEntry,
15
16
  } from "../lib/assistant-config";
@@ -37,6 +38,8 @@ import {
37
38
  hasWebhookIntegrationsConfigured,
38
39
  providerSupportsReservedDomain,
39
40
  runProviderTunnel,
41
+ stopTunnelProcess,
42
+ summarizeStartupFailure,
40
43
  } from "../lib/tunnel.js";
41
44
 
42
45
  // The accepted `--provider` values ARE the shared registry, so validation
@@ -47,7 +50,7 @@ interface TunnelArgs {
47
50
  provider: TunnelProviderName;
48
51
  domain: string | null;
49
52
  clearDomain: boolean;
50
- detach: boolean;
53
+ foreground: boolean;
51
54
  }
52
55
 
53
56
  const FLAGS_WITH_VALUES = ["--provider", "--domain"] as const;
@@ -57,13 +60,13 @@ function parseArgs(): TunnelArgs {
57
60
  let provider: TunnelProviderName | null = null;
58
61
  let domain: string | null = null;
59
62
  let clearDomain = false;
60
- let detach = false;
63
+ let foreground = false;
61
64
 
62
65
  for (let i = 0; i < args.length; i++) {
63
66
  const arg = args[i];
64
67
  if (arg === "--help" || arg === "-h") {
65
68
  console.log(
66
- "Usage: cadet tunnel [<name>] --provider <provider> [options]",
69
+ "Usage: cadet tunnel [<name>] --provider <provider> [options]\n cadet tunnel stop [<name>]",
67
70
  );
68
71
  console.log("");
69
72
  console.log("Front a locally running assistant with an HTTPS tunnel.");
@@ -112,19 +115,22 @@ function parseArgs(): TunnelArgs {
112
115
  " tunnel without one. Cannot be combined with --domain.",
113
116
  );
114
117
  console.log(
115
- ` -d, --detach Run the tunnel in the background. Waits up to ${TUNNEL_BACKGROUND_START_TIMEOUT_MS / 1000}s for the`,
118
+ ` --foreground Stream the tunnel's logs to this terminal. By default the tunnel`,
116
119
  );
117
120
  console.log(
118
- " initial established/failed status before returning; logs go to",
121
+ " runs in the background: the command returns after printing the public",
119
122
  );
120
123
  console.log(
121
- " the CLI log directory. Stop it with `kill <pid>`, except when it",
124
+ ` URL, and logs stream to tunnel.log in the CLI log directory. A background`,
122
125
  );
123
126
  console.log(
124
- " adopted an already-running tunnel (started outside this",
127
+ " tunnel stops with `cadet tunnel stop`, except when it adopted an already-running",
125
128
  );
126
129
  console.log(
127
- " command): killing the supervisor then leaves that tunnel running.",
130
+ " tunnel (started outside this command): killing the supervisor then",
131
+ );
132
+ console.log(
133
+ " leaves that tunnel running.",
128
134
  );
129
135
  console.log("");
130
136
  console.log("Providers:");
@@ -151,9 +157,6 @@ function parseArgs(): TunnelArgs {
151
157
  }
152
158
  console.log(" $ cadet tunnel --provider cloudflare");
153
159
  console.log(" $ cadet tunnel my-assistant --provider tailscale");
154
- console.log(
155
- ` $ cadet tunnel --provider ${DEFAULT_TUNNEL_PROVIDER} --detach`,
156
- );
157
160
  process.exit(0);
158
161
  } else if (arg === "--provider") {
159
162
  const next = args[i + 1];
@@ -188,8 +191,8 @@ function parseArgs(): TunnelArgs {
188
191
  i++;
189
192
  } else if (arg === "--clear-domain") {
190
193
  clearDomain = true;
191
- } else if (arg === "-d" || arg === "--detach") {
192
- detach = true;
194
+ } else if (arg === "--foreground") {
195
+ foreground = true;
193
196
  } else if (arg.startsWith("-")) {
194
197
  console.error(`Error: Unknown option '${arg}'.`);
195
198
  process.exit(1);
@@ -227,7 +230,7 @@ function parseArgs(): TunnelArgs {
227
230
  process.exit(1);
228
231
  }
229
232
 
230
- return { assistantName, provider, domain, clearDomain, detach };
233
+ return { assistantName, provider, domain, clearDomain, foreground };
231
234
  }
232
235
 
233
236
  /** A tunnelable assistant plus the gateway port and workspace the edge fronts. */
@@ -345,31 +348,31 @@ const TUNNEL_BACKGROUND_START_TIMEOUT_MS = 35_000;
345
348
  const TUNNEL_ESTABLISHED_RE = /Tunnel established: (\S+)/;
346
349
 
347
350
  /**
348
- * Re-invoke `cadet tunnel` (without `-d`/`--detach`) as a detached background
349
- * process via `relaunchDetached`, waiting for the child to either print its
350
- * "Tunnel established" or adopted-tunnel line (readiness) or
351
- * exit early (failure), so a misconfigured provider still fails loudly in
352
- * the terminal that ran `-d`.
351
+ * Re-invoke `cadet tunnel --foreground` as a detached background process via
352
+ * `relaunchDetached`, waiting for the child to either print its
353
+ * "Tunnel established" or adopted-tunnel line (readiness) or exit early
354
+ * (failure), so a misconfigured provider still fails loudly in the terminal
355
+ * that ran `cadet tunnel`.
353
356
  *
354
- * The log filename is scoped to this (parent) process's pid so concurrent
355
- * `-d` runs (e.g. tunneling two different local assistants at once) don't
356
- * truncate and read each other's readiness output.
357
+ * The log file is the fixed `tunnel.log` so there is one stable path to tail;
358
+ * concurrent tunnel runs share (and truncate) it.
357
359
  */
358
360
  async function spawnDetachedTunnel(): Promise<void> {
359
361
  const childArgs: string[] = ["tunnel"];
360
362
  for (const arg of process.argv.slice(3)) {
361
- if (arg === "-d" || arg === "--detach") {
363
+ if (arg === "--foreground") {
362
364
  continue;
363
365
  }
364
366
  childArgs.push(arg);
365
367
  }
368
+ childArgs.push("--foreground");
366
369
 
367
370
  let publicUrl: string | undefined;
368
371
  let adopted = false;
369
372
 
370
373
  const { child, logPath, ready, exitCode } = await relaunchDetached({
371
374
  args: childArgs,
372
- logFile: `tunnel-${process.pid}.log`,
375
+ logFile: "tunnel.log",
373
376
  timeoutMs: TUNNEL_BACKGROUND_START_TIMEOUT_MS,
374
377
  isReady: (logPath) => {
375
378
  let logText = "";
@@ -394,9 +397,17 @@ async function spawnDetachedTunnel(): Promise<void> {
394
397
  });
395
398
 
396
399
  if (exitCode !== undefined) {
400
+ let logText = "";
401
+ try {
402
+ logText = readFileSync(logPath, "utf-8");
403
+ } catch {
404
+ // Log file not readable — the summary falls back to none.
405
+ }
406
+ const reason = summarizeStartupFailure(logText);
397
407
  console.error(
398
- `Error: tunnel process exited during startup${exitCode !== null ? ` (exit code ${exitCode})` : ""}. Logs: ${logPath}`,
408
+ `Error (${exitCode ?? "unknown"}): tunnel process exited during startup${reason ? `: ${reason}` : "."}`,
399
409
  );
410
+ console.error(`Logs: ${logPath}`);
400
411
  process.exit(1);
401
412
  }
402
413
 
@@ -411,16 +422,44 @@ async function spawnDetachedTunnel(): Promise<void> {
411
422
  if (adopted) {
412
423
  console.log(adoptedTunnelStopNotice(child.pid));
413
424
  } else {
414
- console.log(`Stop with: kill ${child.pid}`);
425
+ console.log("Stop with: cadet tunnel stop");
415
426
  }
416
427
  }
417
428
 
418
429
  export const tunnel = Object.assign(
419
430
  async function tunnel(): Promise<void> {
420
- const { assistantName, provider, domain, clearDomain, detach } =
431
+ if (process.argv[3] === "stop") {
432
+ const args = process.argv.slice(4);
433
+ if (args.includes("--help") || args.includes("-h")) {
434
+ console.log("Usage: cadet tunnel stop [<name>]");
435
+ console.log(
436
+ "Stop the recorded Cadet-managed tunnel, keeping nginx and the saved domain.",
437
+ );
438
+ return;
439
+ }
440
+ if (args.some((arg) => arg.startsWith("-")))
441
+ throw new Error("Usage: cadet tunnel stop [<name>]");
442
+ const { entry, workspaceDir } = resolveLocalTunnelTarget(
443
+ args.join(" ") || null,
444
+ );
445
+ if (!readRootProcesses().tunnel) {
446
+ console.log(
447
+ "No Cadet-managed tunnel is recorded. Externally started tunnels must be stopped directly.",
448
+ );
449
+ return;
450
+ }
451
+ const stopped = await stopTunnelProcess(entry.assistantId, workspaceDir);
452
+ console.log(
453
+ stopped
454
+ ? "Tunnel stopped. nginx and the saved domain are unchanged."
455
+ : "No running tunnel found; cleared stale tunnel state.",
456
+ );
457
+ return;
458
+ }
459
+ const { assistantName, provider, domain, clearDomain, foreground } =
421
460
  parseArgs();
422
461
 
423
- if (detach) {
462
+ if (!foreground) {
424
463
  await spawnDetachedTunnel();
425
464
  return;
426
465
  }