@mycadet/cli 0.12.7-dev.202610111049.d2b52a8 → 0.12.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/node_modules/@mycadet/assistant-api/events/cloud-disconnected.ts +2 -2
  2. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
  3. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
  4. package/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
  5. package/package.json +4 -3
  6. package/src/__tests__/{cloudflare-tunnel.test.ts → cloudflare.test.ts} +1 -1
  7. package/src/__tests__/ps-cloud-status.test.ts +53 -0
  8. package/src/__tests__/retire.test.ts +37 -178
  9. package/src/__tests__/root-process-lockfile.test.ts +99 -0
  10. package/src/__tests__/{tailscale-tunnel.test.ts → tailscale.test.ts} +1 -1
  11. package/src/__tests__/tunnel.test.ts +127 -80
  12. package/src/__tests__/upgrade-local.test.ts +3 -3
  13. package/src/__tests__/wake.test.ts +16 -9
  14. package/src/commands/client.ts +1 -1
  15. package/src/commands/ps.ts +181 -72
  16. package/src/commands/retire.ts +28 -137
  17. package/src/commands/tunnel.ts +76 -118
  18. package/src/commands/upgrade.ts +2 -9
  19. package/src/commands/wake.ts +5 -9
  20. package/src/lib/assistant-config.ts +53 -0
  21. package/src/lib/detached-process.ts +1 -1
  22. package/src/lib/ingress-config.ts +7 -34
  23. package/src/lib/local.ts +6 -27
  24. package/src/lib/nginx-ingress.ts +29 -8
  25. package/src/lib/{tunnel-edge.ts → tunnel.ts} +199 -11
  26. package/src/lib/tunnels/cadet.ts +29 -0
  27. package/src/lib/{cloudflare-tunnel.ts → tunnels/cloudflare.ts} +15 -4
  28. package/src/lib/tunnels/gateway-notify.ts +61 -0
  29. package/src/lib/{ngrok.ts → tunnels/ngrok.ts} +200 -29
  30. package/src/lib/{tailscale-tunnel.ts → tunnels/tailscale.ts} +19 -5
@@ -14,41 +14,36 @@ import {
14
14
  type AssistantEntry,
15
15
  } from "../lib/assistant-config";
16
16
  import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
17
- import { runCloudflareTunnel } from "../lib/cloudflare-tunnel.js";
18
17
  import { relaunchDetached } from "../lib/detached-process.js";
19
18
  import {
20
19
  getDefaultWorkspaceDir,
21
20
  isLocalContainerEntry,
22
21
  parseGatewayPortFromEntryUrls,
23
- saveNgrokDomain,
24
22
  } from "../lib/ingress-config.js";
25
23
  import {
26
24
  ensureTunnelEdge,
27
25
  formatEdgeMode,
28
26
  type TunnelEdge,
29
27
  } from "../lib/nginx-ingress.js";
30
- import { hasWebhookIntegrationsConfigured, runNgrokTunnel } from "../lib/ngrok";
31
- import { shellArg } from "../lib/shell-arg.js";
32
28
  import { STALE_CLI_UPDATE_HINT } from "../lib/stale-cli-hint.js";
33
- import { runTailscaleTunnel } from "../lib/tailscale-tunnel.js";
34
-
35
- // `cadet` is the cloud option this command owns; the local providers come
36
- // from the shared registry so validation here cannot drift from what the
37
- // workspace config accepts.
38
- const VALID_PROVIDERS = ["cadet", ...TUNNEL_PROVIDERS] as const;
39
- type TunnelProvider = (typeof VALID_PROVIDERS)[number];
40
-
41
- // How far each provider's URL reaches, for the chooser a missing `--provider`
42
- // prints. Keyed off the registry so a new provider cannot ship unoffered.
43
- const PROVIDER_REACH: Record<TunnelProviderName, string> = {
44
- ngrok: "Public tunnel that webhook integrations can reach",
45
- cloudflare: "Public tunnel that webhook integrations can reach",
46
- tailscale: "Tailnet-only tunnel, reachable from your own devices",
47
- };
29
+ import {
30
+ ADOPTED_TUNNEL_RE,
31
+ DEFAULT_TUNNEL_PROVIDER,
32
+ PROVIDER_META,
33
+ adoptedTunnelStopNotice,
34
+ cadetTunnelComingSoonError,
35
+ clearReservedDomain,
36
+ hasWebhookIntegrationsConfigured,
37
+ providerSupportsReservedDomain,
38
+ runProviderTunnel,
39
+ } from "../lib/tunnel.js";
40
+
41
+ // The accepted `--provider` values ARE the shared registry, so validation
42
+ // here cannot drift from what the workspace config accepts.
48
43
 
49
44
  interface TunnelArgs {
50
45
  assistantName: string | null;
51
- provider: TunnelProvider;
46
+ provider: TunnelProviderName;
52
47
  domain: string | null;
53
48
  clearDomain: boolean;
54
49
  detach: boolean;
@@ -58,7 +53,7 @@ const FLAGS_WITH_VALUES = ["--provider", "--domain"] as const;
58
53
 
59
54
  function parseArgs(): TunnelArgs {
60
55
  const args = process.argv.slice(3);
61
- let provider: TunnelProvider | null = null;
56
+ let provider: TunnelProviderName | null = null;
62
57
  let domain: string | null = null;
63
58
  let clearDomain = false;
64
59
  let detach = false;
@@ -76,14 +71,13 @@ function parseArgs(): TunnelArgs {
76
71
  );
77
72
  console.log("");
78
73
  console.log(
79
- "How far that URL reaches depends on the provider, so --provider is required.",
80
- );
81
- console.log(
82
- "A tailscale tunnel is reachable only from your own tailnet; webhook",
74
+ "How far that URL reaches depends on the provider; omit --provider for",
83
75
  );
76
+ console.log("the default. A tailscale tunnel is reachable only from your");
84
77
  console.log(
85
- "integrations need a public tunnel: --provider ngrok or --provider cloudflare.",
78
+ "own tailnet; webhook integrations need a public tunnel (see Providers",
86
79
  );
80
+ console.log("below).");
87
81
  console.log("");
88
82
  console.log(
89
83
  "The tunnel always fronts the local nginx edge, which is started automatically.",
@@ -99,19 +93,21 @@ function parseArgs(): TunnelArgs {
99
93
  console.log("");
100
94
  console.log("Options:");
101
95
  console.log(
102
- ` --provider <provider> Required. Tunnel provider: ${VALID_PROVIDERS.join(", ")}`,
96
+ ` --provider <provider> Tunnel provider: ${TUNNEL_PROVIDERS.join(", ")}`,
103
97
  );
98
+ console.log(` (default: ${DEFAULT_TUNNEL_PROVIDER})`);
104
99
  console.log(
105
- " --domain <domain> Reserved ngrok domain to bind (ngrok provider only).",
100
+ " --domain <domain> Reserved domain to bind, for providers that support",
106
101
  );
107
102
  console.log(
108
- " Saved to the workspace config so `cadet wake` restores reuse it.",
103
+ " one. Saved to the workspace config so `cadet wake` restores",
109
104
  );
105
+ console.log(" reuse it.");
110
106
  console.log(
111
- " --clear-domain Clear the saved ngrok domain (ngrok provider only) and tunnel",
107
+ " --clear-domain Clear the saved reserved domain (same providers) and",
112
108
  );
113
109
  console.log(
114
- " without one. Cannot be combined with --domain.",
110
+ " tunnel without one. Cannot be combined with --domain.",
115
111
  );
116
112
  console.log(
117
113
  ` -d, --detach Run the tunnel in the background. Waits up to ${TUNNEL_BACKGROUND_START_TIMEOUT_MS / 1000}s for the`,
@@ -123,72 +119,64 @@ function parseArgs(): TunnelArgs {
123
119
  " the CLI log directory. Stop it with `kill <pid>`, except when it",
124
120
  );
125
121
  console.log(
126
- " adopted an already-running ngrok tunnel (started outside this",
122
+ " adopted an already-running tunnel (started outside this",
127
123
  );
128
124
  console.log(
129
125
  " command): killing the supervisor then leaves that tunnel running.",
130
126
  );
131
127
  console.log("");
132
128
  console.log("Providers:");
133
- console.log(
134
- " cadet Managed tunnel via Cadet Cloud (not yet available)",
135
- );
136
- console.log(
137
- " ngrok ngrok tunnel — install: brew install ngrok/ngrok/ngrok",
138
- );
139
- console.log(
140
- " cloudflare Cloudflare quick tunnel — install: brew install cloudflare/cloudflare/cloudflared",
141
- );
142
- console.log(
143
- " No Cloudflare account required for quick tunnels.",
144
- );
145
- console.log(
146
- " tailscale Tailscale serve. Install: https://tailscale.com/download, then run `tailscale up`",
147
- );
148
- console.log(
149
- " Reachable only from your own tailnet (private; LetsEncrypt cert).",
150
- );
151
- console.log(
152
- " When webhook integrations are configured it records its URL for device",
153
- );
154
- console.log(
155
- " pairing only, leaving the saved webhook callback base as it is.",
156
- );
129
+ for (const name of TUNNEL_PROVIDERS) {
130
+ const meta = PROVIDER_META[name];
131
+ console.log(` ${name.padEnd(12)}${meta.reach}`);
132
+ console.log(` ${" ".repeat(12)}${meta.install}`);
133
+ for (const note of meta.notes ?? []) {
134
+ console.log(` ${" ".repeat(12)}${note}`);
135
+ }
136
+ }
157
137
  console.log("");
158
138
  console.log("Examples:");
159
- console.log(" $ cadet tunnel --provider ngrok");
160
- console.log(
161
- " $ cadet tunnel --provider ngrok --domain my-assistant.ngrok.app",
162
- );
163
- console.log(" $ cadet tunnel --provider ngrok --clear-domain");
139
+ console.log(` $ cadet tunnel --provider ${DEFAULT_TUNNEL_PROVIDER}`);
140
+ const domainExample = PROVIDER_META[DEFAULT_TUNNEL_PROVIDER].domainExample;
141
+ if (domainExample) {
142
+ console.log(
143
+ ` $ cadet tunnel --provider ${DEFAULT_TUNNEL_PROVIDER} --domain ${domainExample}`,
144
+ );
145
+ console.log(
146
+ ` $ cadet tunnel --provider ${DEFAULT_TUNNEL_PROVIDER} --clear-domain`,
147
+ );
148
+ }
164
149
  console.log(" $ cadet tunnel --provider cloudflare");
165
150
  console.log(" $ cadet tunnel my-assistant --provider tailscale");
166
- console.log(" $ cadet tunnel --provider ngrok --detach");
151
+ console.log(
152
+ ` $ cadet tunnel --provider ${DEFAULT_TUNNEL_PROVIDER} --detach`,
153
+ );
167
154
  process.exit(0);
168
155
  } else if (arg === "--provider") {
169
156
  const next = args[i + 1];
170
157
  if (!next || next.startsWith("-")) {
171
158
  console.error(
172
- `Error: --provider requires one of: ${VALID_PROVIDERS.join(", ")}`,
159
+ `Error: --provider requires one of: ${TUNNEL_PROVIDERS.join(", ")}`,
173
160
  );
174
161
  process.exit(1);
175
162
  }
176
- if (!VALID_PROVIDERS.includes(next as TunnelProvider)) {
163
+ if (!TUNNEL_PROVIDERS.includes(next as TunnelProviderName)) {
177
164
  console.error(
178
- `Error: unknown tunnel provider '${next}'. Valid providers: ${VALID_PROVIDERS.join(", ")}.`,
165
+ `Error: unknown tunnel provider '${next}'. Valid providers: ${TUNNEL_PROVIDERS.join(", ")}.`,
179
166
  );
180
167
  console.error(
181
168
  `If this provider is documented, ${STALE_CLI_UPDATE_HINT}`,
182
169
  );
183
170
  process.exit(1);
184
171
  }
185
- provider = next as TunnelProvider;
172
+ provider = next as TunnelProviderName;
186
173
  i++;
187
174
  } else if (arg === "--domain") {
188
175
  const next = args[i + 1];
189
176
  if (!next || next.startsWith("-")) {
177
+ const domainExample = PROVIDER_META[DEFAULT_TUNNEL_PROVIDER].domainExample;
190
178
  console.error(
191
- "Error: --domain requires a value, e.g. --domain my-assistant.ngrok.app",
179
+ `Error: --domain requires a value${domainExample ? `, e.g. --domain ${domainExample}` : ""}`,
192
180
  );
193
181
  process.exit(1);
194
182
  }
@@ -210,32 +198,22 @@ function parseArgs(): TunnelArgs {
210
198
  parseAssistantTargetArg(args, FLAGS_WITH_VALUES) ?? null;
211
199
 
212
200
  if (provider === null) {
213
- console.error(
214
- "Error: --provider is required. How far the tunnel URL reaches depends on it, so there is no default.",
201
+ provider = DEFAULT_TUNNEL_PROVIDER;
202
+ console.log(
203
+ `Using the default provider: ${provider} (pass --provider to choose another).`,
215
204
  );
216
- console.error("");
217
- console.error("Run one of:");
218
- // Carries the assistant through: dropping it would suggest a command that
219
- // tunnels the active assistant instead of the one that was asked for.
220
- const target = assistantName ? ` ${shellArg(assistantName)}` : "";
221
- for (const name of TUNNEL_PROVIDERS) {
222
- console.error(
223
- ` cadet tunnel${target} --provider ${name.padEnd(13)}${PROVIDER_REACH[name]}`,
224
- );
225
- }
226
- process.exit(1);
227
205
  }
228
206
 
229
- if (domain && provider !== "ngrok") {
207
+ if (domain && !providerSupportsReservedDomain(provider)) {
230
208
  console.error(
231
- `Error: --domain is only supported with --provider ngrok (got '${provider}').`,
209
+ `Error: --domain is not supported with --provider ${provider} (reserved domains have no equivalent there).`,
232
210
  );
233
211
  process.exit(1);
234
212
  }
235
213
 
236
- if (clearDomain && provider !== "ngrok") {
214
+ if (clearDomain && !providerSupportsReservedDomain(provider)) {
237
215
  console.error(
238
- `Error: --clear-domain is only supported with --provider ngrok (got '${provider}').`,
216
+ `Error: --clear-domain is not supported with --provider ${provider} (reserved domains have no equivalent there).`,
239
217
  );
240
218
  process.exit(1);
241
219
  }
@@ -350,8 +328,8 @@ function guardTailnetOnlyWebhookIngress(workspaceDir: string): boolean {
350
328
  console.warn(
351
329
  "⚠ Webhook integrations are configured, and the tailscale tunnel is reachable only from your own tailnet, " +
352
330
  "so it cannot serve their callbacks. The saved ingress base URL stays as it is and the tailnet URL is " +
353
- "recorded for device pairing only. For a tunnel those integrations can reach too, use --provider ngrok " +
354
- "or --provider cloudflare.",
331
+ "recorded for device pairing only. For a tunnel those integrations can reach too, run `cadet tunnel --help` " +
332
+ "to see the public providers.",
355
333
  );
356
334
  return true;
357
335
  }
@@ -359,16 +337,15 @@ function guardTailnetOnlyWebhookIngress(workspaceDir: string): boolean {
359
337
  // Generous cap so a slow provider handshake (e.g. cloudflared's own ~30s
360
338
  // quick-tunnel timeout) still counts as a successful, if slow, start.
361
339
  const TUNNEL_BACKGROUND_START_TIMEOUT_MS = 35_000;
362
- // Matches the "just established" line every provider prints, or ngrok's
363
- // "found an already-running agent" adoption line (captured separately since
364
- // that case does not own the tunnel it adopted).
340
+ // Matches the "just established" line every provider prints, or the adoption
341
+ // line printed when a run adopts an already-running agent's tunnel (captured
342
+ // separately since that case does not own the tunnel it adopted).
365
343
  const TUNNEL_ESTABLISHED_RE = /Tunnel established: (\S+)/;
366
- const TUNNEL_ADOPTED_RE = /Found existing ngrok tunnel: (\S+)/;
367
344
 
368
345
  /**
369
346
  * Re-invoke `cadet tunnel` (without `-d`/`--detach`) as a detached background
370
347
  * process via `relaunchDetached`, waiting for the child to either print its
371
- * "Tunnel established"/"Found existing ngrok tunnel" line (readiness) or
348
+ * "Tunnel established" or adopted-tunnel line (readiness) or
372
349
  * exit early (failure), so a misconfigured provider still fails loudly in
373
350
  * the terminal that ran `-d`.
374
351
  *
@@ -404,7 +381,7 @@ async function spawnDetachedTunnel(): Promise<void> {
404
381
  publicUrl = established[1];
405
382
  return true;
406
383
  }
407
- const adoptedMatch = TUNNEL_ADOPTED_RE.exec(logText);
384
+ const adoptedMatch = ADOPTED_TUNNEL_RE.exec(logText);
408
385
  if (adoptedMatch) {
409
386
  publicUrl = adoptedMatch[1];
410
387
  adopted = true;
@@ -430,11 +407,7 @@ async function spawnDetachedTunnel(): Promise<void> {
430
407
  }
431
408
  console.log(`Running in background (pid ${child.pid}). Logs: ${logPath}`);
432
409
  if (adopted) {
433
- console.log(
434
- `That ngrok agent was already running outside this command, so \`kill ${child.pid}\` only ` +
435
- "stops this supervisor, not the ngrok tunnel or its saved ingress URL. Stop the ngrok agent " +
436
- "directly to end the tunnel.",
437
- );
410
+ console.log(adoptedTunnelStopNotice(child.pid));
438
411
  } else {
439
412
  console.log(`Stop with: kill ${child.pid}`);
440
413
  }
@@ -449,11 +422,8 @@ export const tunnel = Object.assign(
449
422
  return;
450
423
  }
451
424
 
452
- if (provider === "cadet") {
453
- throw new Error(
454
- `Tunnel provider '${provider}' is not yet implemented. ` +
455
- `If this provider is documented, ${STALE_CLI_UPDATE_HINT}`,
456
- );
425
+ if (PROVIDER_META[provider].comingSoon) {
426
+ throw cadetTunnelComingSoonError(provider);
457
427
  }
458
428
 
459
429
  const { entry, gatewayPort, workspaceDir } =
@@ -463,8 +433,8 @@ export const tunnel = Object.assign(
463
433
  provider === "tailscale" && guardTailnetOnlyWebhookIngress(workspaceDir);
464
434
 
465
435
  if (clearDomain) {
466
- saveNgrokDomain(workspaceDir, null);
467
- console.log("Cleared the saved ngrok domain from the workspace config.");
436
+ clearReservedDomain();
437
+ console.log("Cleared the saved reserved domain.");
468
438
  }
469
439
 
470
440
  let edge: TunnelEdge;
@@ -490,21 +460,9 @@ export const tunnel = Object.assign(
490
460
  workspaceDir,
491
461
  };
492
462
 
493
- if (provider === "ngrok") {
494
- await runNgrokTunnel({
495
- ...baseTunnelOpts,
496
- ...(domain ? { domain } : {}),
497
- });
498
- return;
499
- }
500
-
501
- if (provider === "cloudflare") {
502
- await runCloudflareTunnel(baseTunnelOpts);
503
- return;
504
- }
505
-
506
- await runTailscaleTunnel({
463
+ await runProviderTunnel(provider, {
507
464
  ...baseTunnelOpts,
465
+ ...(domain ? { domain } : {}),
508
466
  ...(preserveIngressUrl ? { preserveIngressUrl } : {}),
509
467
  });
510
468
  },
@@ -1,7 +1,6 @@
1
1
  import { resolveExistingLocalSigningKey } from "@mycadet/local-mode";
2
2
  import { isCloudHome } from "../lib/cloud-values.js";
3
3
  import { spawnSync } from "child_process";
4
- import { writeFileSync } from "fs";
5
4
  import { join } from "path";
6
5
 
7
6
  import cliPkg from "../../package.json";
@@ -77,7 +76,7 @@ import {
77
76
  startLocalDaemon,
78
77
  stopLocalProcesses,
79
78
  } from "../lib/local.js";
80
- import { restoreTunnelEdgeAndAutoTunnel } from "../lib/tunnel-edge.js";
79
+ import { restoreTunnelEdgeAndAutoTunnel } from "../lib/tunnel.js";
81
80
  import {
82
81
  leaseGuardianToken,
83
82
  resetGuardianBootstrap,
@@ -1018,17 +1017,11 @@ async function upgradeLocal(
1018
1017
  console.log("✅ Local assistant processes started\n");
1019
1018
 
1020
1019
  const workspaceDir = join(entry.resources.instanceDir, "workspace");
1021
- const ngrokChild = await restoreTunnelEdgeAndAutoTunnel(
1020
+ await restoreTunnelEdgeAndAutoTunnel(
1022
1021
  entry.assistantId,
1023
1022
  entry.resources.gatewayPort,
1024
1023
  workspaceDir,
1025
1024
  );
1026
- if (ngrokChild?.pid) {
1027
- writeFileSync(
1028
- join(entry.resources.instanceDir, "ngrok.pid"),
1029
- String(ngrokChild.pid),
1030
- );
1031
- }
1032
1025
 
1033
1026
  console.log("Waiting for assistant to become ready...");
1034
1027
  const ready = await waitForReady(adminUrl);
@@ -2,7 +2,6 @@ import {
2
2
  SigningKeyContinuityError,
3
3
  resolveExistingLocalSigningKey,
4
4
  } from "@mycadet/local-mode";
5
- import { writeFileSync } from "fs";
6
5
  import { join } from "path";
7
6
 
8
7
  import {
@@ -47,7 +46,7 @@ import {
47
46
  DOCKER_GATEWAY_READY_TIMEOUT_MS,
48
47
  restoreContainerTunnelEdge,
49
48
  restoreTunnelEdgeAndAutoTunnel,
50
- } from "../lib/tunnel-edge.js";
49
+ } from "../lib/tunnel.js";
51
50
 
52
51
  export const wake = Object.assign(
53
52
  async function wake(): Promise<void> {
@@ -582,18 +581,15 @@ async function wakeAssistant(
582
581
  }
583
582
  }
584
583
 
585
- // Restore the nginx edge and point the webhook auto-tunnel at it. Non-fatal:
586
- // a down edge is a degraded remote-web/webhook path, not a broken assistant.
584
+ // Restore the nginx edge and point the auto-tunnel at it. The spawned agent
585
+ // records itself in the root lockfile under `tunnel`. Non-fatal: a down
586
+ // edge is a degraded remote-web/webhook path, not a broken assistant.
587
587
  const workspaceDir = join(resources.instanceDir, "workspace");
588
- const ngrokChild = await restoreTunnelEdgeAndAutoTunnel(
588
+ await restoreTunnelEdgeAndAutoTunnel(
589
589
  entry.assistantId,
590
590
  resources.gatewayPort,
591
591
  workspaceDir,
592
592
  );
593
- if (ngrokChild?.pid) {
594
- const ngrokPidFile = join(resources.instanceDir, "ngrok.pid");
595
- writeFileSync(ngrokPidFile, String(ngrokChild.pid));
596
- }
597
593
 
598
594
  if (daemonMigrationsFailed) {
599
595
  console.log(
@@ -152,9 +152,28 @@ interface LockfileData {
152
152
  assistants?: Record<string, unknown>[];
153
153
  activeAssistant?: string;
154
154
  cloudBaseUrl?: string;
155
+ tunnel?: RootProcessRecord;
156
+ app?: RootProcessRecord;
155
157
  [key: string]: unknown;
156
158
  }
157
159
 
160
+ /**
161
+ * A CLI-managed OS process recorded at the lockfile root, keyed by what it is
162
+ * for — `tunnel` (the auto-tunnel agent fronting the edge) and `app` (the
163
+ * nginx ingress edge). `cadet ps` renders these alongside the per-assistant
164
+ * process trees, and the stop paths verify the pid before signaling it.
165
+ */
166
+ export interface RootProcessRecord {
167
+ pid: number;
168
+ /** Which tunnel provider owns the process; "tunnel" entries only. */
169
+ provider?: string;
170
+ /** The assistant the process fronts, so `cadet ps` can nest the row. */
171
+ assistantId?: string;
172
+ startedAt: string;
173
+ }
174
+
175
+ export type RootProcessKind = "tunnel" | "app";
176
+
158
177
  /**
159
178
  * Derive the daemon PID file path from a resources object. The PID file
160
179
  * lives inside the instance's workspace directory. When no resources are
@@ -216,6 +235,40 @@ function withCliLockfileLock<T>(fn: () => T): T {
216
235
  return locked.value;
217
236
  }
218
237
 
238
+ /** Record a CLI-managed root process (tunnel agent, app edge) by pid. */
239
+ export function stampRootProcess(
240
+ kind: RootProcessKind,
241
+ record: RootProcessRecord,
242
+ ): void {
243
+ withCliLockfileLock(() => {
244
+ const data = readLockfile();
245
+ data[kind] = record;
246
+ writeLockfileUnlocked(data);
247
+ });
248
+ }
249
+
250
+ /** Drop the recorded root process entry, e.g. once the process is stopped. */
251
+ export function clearRootProcess(kind: RootProcessKind): void {
252
+ withCliLockfileLock(() => {
253
+ const data = readLockfile();
254
+ if (data[kind] === undefined) return;
255
+ delete data[kind];
256
+ writeLockfileUnlocked(data);
257
+ });
258
+ }
259
+
260
+ /**
261
+ * Read the root-level process entries for `cadet ps`. Read-only: no lock, so
262
+ * a concurrently-updating lockfile is fine — the pid is verified separately.
263
+ */
264
+ export function readRootProcesses(): {
265
+ tunnel?: RootProcessRecord;
266
+ app?: RootProcessRecord;
267
+ } {
268
+ const data = readLockfile();
269
+ return { tunnel: data.tunnel, app: data.app };
270
+ }
271
+
219
272
  /**
220
273
  * Try to extract a port number from a URL string (e.g. `http://localhost:7830`).
221
274
  * Returns undefined if the URL is malformed or has no explicit port.
@@ -6,7 +6,7 @@ import { isCompiledCli } from "./local.js";
6
6
  import { getLogDir, openLogFile, resetLogFile } from "./xdg-log.js";
7
7
 
8
8
  export interface RelaunchDetachedOptions {
9
- /** argv for the re-invoked CLI command, e.g. ["tunnel", "--provider", "ngrok"]. */
9
+ /** argv for the re-invoked CLI command, e.g. ["tunnel", "--provider", "cloudflare"]. */
10
10
  args: string[];
11
11
  /** Log file basename under the XDG log dir; truncated before spawn. */
12
12
  logFile: string;
@@ -18,14 +18,13 @@ import { getStateDir } from "./environments/paths.js";
18
18
  import { getCurrentEnvironment } from "./environments/resolve.js";
19
19
 
20
20
  /**
21
- * Shared workspace-config helpers for tunnel providers (ngrok, cloudflare,
22
- * tailscale) and the nginx ingress proxy. Each provider fronts the local edge
23
- * and records the resulting public URL under `ingress.publicBaseUrl` so webhook
24
- * integrations can reach the assistant. The workspace config is the
25
- * gateway-facing contract; when an `assistantId` is supplied, the URL is also
26
- * mirrored onto the lockfile entry (`ingressUrl`) — the CLI-owned contract
27
- * that CLI features (e.g. remote-web pairing defaults) read, per the
28
- * no-`.cadet/`-reads boundary in cli/AGENTS.md.
21
+ * Shared workspace-config helpers for tunnel providers and the nginx ingress
22
+ * proxy. Each provider fronts the local edge and records the resulting public
23
+ * URL under `ingress.publicBaseUrl` so webhook integrations can reach the
24
+ * assistant. The workspace config is the gateway-facing contract; when an
25
+ * `assistantId` is supplied, the URL is also mirrored onto the lockfile entry
26
+ * (`ingressUrl`) — the CLI-owned contract that CLI features (e.g. remote-web
27
+ * pairing defaults) read, per the no-`.cadet/`-reads boundary in cli/AGENTS.md.
29
28
  */
30
29
 
31
30
  function parsePortFromUrl(url: unknown): number | undefined {
@@ -186,32 +185,6 @@ export function savePairingTunnel(
186
185
  saveRawConfig(workspaceDir, config);
187
186
  }
188
187
 
189
- /** Persist a reserved ngrok domain under `ingress.ngrok.domain`; null clears it. */
190
- export function saveNgrokDomain(
191
- workspaceDir: string,
192
- domain: string | null,
193
- ): void {
194
- const config = loadRawConfig(workspaceDir);
195
- const ingress = (config.ingress ?? {}) as Record<string, unknown>;
196
- if (domain) {
197
- ingress.ngrok = { domain };
198
- } else {
199
- delete ingress.ngrok;
200
- }
201
- config.ingress = ingress;
202
- saveRawConfig(workspaceDir, config);
203
- }
204
-
205
- /** Read the reserved ngrok domain from the workspace config, if saved. */
206
- export function loadNgrokDomain(workspaceDir: string): string | null {
207
- const ingress = loadRawConfig(workspaceDir).ingress as
208
- | Record<string, unknown>
209
- | undefined;
210
- const ngrok = ingress?.ngrok as Record<string, unknown> | undefined;
211
- const domain = ngrok?.domain;
212
- return typeof domain === "string" && domain.trim() ? domain : null;
213
- }
214
-
215
188
  /** Clear the ingress public base URL from the workspace config. */
216
189
  export function clearIngressUrl(
217
190
  workspaceDir: string,
package/src/lib/local.ts CHANGED
@@ -74,11 +74,10 @@ import {
74
74
  executableName,
75
75
  isProcessAlive,
76
76
  pathListDelimiter,
77
- readProcessCommandLine,
78
77
  resolveProcessState,
79
- stopProcess,
80
78
  stopProcessByPidFile,
81
79
  } from "./process.js";
80
+ import { stopTunnelProcess } from "./tunnel.js";
82
81
  import { stripVersionPrefix } from "./version-compat.js";
83
82
  import { openLogFile, pipeToLogFile } from "./xdg-log.js";
84
83
 
@@ -1931,16 +1930,6 @@ export async function startGateway(
1931
1930
  return gatewayUrl;
1932
1931
  }
1933
1932
 
1934
- /** Check whether a PID belongs to an ngrok process via its command line. */
1935
- function isNgrokProcess(pid: number): boolean {
1936
- try {
1937
- const output = readProcessCommandLine(pid);
1938
- return /ngrok/i.test(output);
1939
- } catch {
1940
- return false;
1941
- }
1942
- }
1943
-
1944
1933
  /**
1945
1934
  * Stop any locally-running daemon and gateway processes
1946
1935
  * and clean up PID files. Called when hatch fails partway through
@@ -1969,22 +1958,12 @@ export async function stopLocalProcesses(
1969
1958
  const cesPidFile = join(cadetDir, "ces.pid");
1970
1959
  await stopProcessByPidFile(cesPidFile, "credential-executor");
1971
1960
 
1972
- // Kill ngrok directly by PID rather than using stopProcessByPidFile, because
1973
- // isCadetProcess() won't match the ngrok binary — resulting in a no-op that
1974
- // leaves ngrok running. Verify the PID still belongs to ngrok before killing
1975
- // to avoid hitting an unrelated process if the OS has reused the PID.
1976
- const ngrokPidFile = join(cadetDir, "ngrok.pid");
1977
- if (existsSync(ngrokPidFile)) {
1978
- try {
1979
- const pid = parseInt(readFileSync(ngrokPidFile, "utf-8").trim(), 10);
1980
- if (!isNaN(pid) && isNgrokProcess(pid)) {
1981
- await stopProcess(pid, "ngrok");
1982
- }
1983
- unlinkSync(ngrokPidFile);
1984
- } catch {}
1985
- }
1961
+ // Stop the tunnel agent this CLI spawned (root lockfile `tunnel` entry),
1962
+ // verifying the pid still belongs to the provider's binary before signaling
1963
+ // it — PID reuse must never send an unrelated process a signal.
1964
+ await stopTunnelProcess();
1986
1965
 
1987
1966
  // Stop the nginx ingress if one is fronting this gateway (it guards against
1988
- // PID reuse itself, mirroring the ngrok handling above).
1967
+ // PID reuse itself).
1989
1968
  await stopIngressNginx(join(cadetDir, "workspace"));
1990
1969
  }