@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
@@ -19,8 +19,10 @@ import { join } from "node:path";
19
19
  import { cloudAssistantHubUrl } from "@mycadet/environments";
20
20
 
21
21
  import {
22
+ clearRootProcess,
22
23
  getAssistantDisplayName,
23
24
  lookupAssistantByIdentifier,
25
+ stampRootProcess,
24
26
  type AssistantEntry,
25
27
  } from "./assistant-config.js";
26
28
  import type { RemoteHost } from "./constants.js";
@@ -360,7 +362,7 @@ http {
360
362
  # remote client cannot smuggle one. Every caller reaches this loopback-only
361
363
  # listener through the TLS-terminating front (tunnel agent), so the raw peer
362
364
  # is always 127.0.0.1; the front records the real client as the RIGHTMOST
363
- # X-Forwarded-For entry (ngrok/cloudflared append, tailscale serve sets it),
365
+ # X-Forwarded-For entry (tunnel providers append one; tailscale serve sets it),
364
366
  # which the remote client cannot control. Fall back to the raw peer when the
365
367
  # front sets no X-Forwarded-For.
366
368
  map $http_x_forwarded_for $cadet_edge_client_ip {
@@ -494,7 +496,8 @@ export function getNginxVersion(): string | null {
494
496
  * process.ts helpers: stopProcessByPidFile's isCadetProcess() guard only
495
497
  * matches command lines containing a cadet path, which fails for a custom
496
498
  * CADET_WORKSPACE_DIR and would silently leave nginx running (the same
497
- * reason local.ts kills ngrok directly). This module is also imported by
499
+ * reason local.ts stops the tunnel agent directly). This module is also
500
+ * imported by
498
501
  * sleep/retire, whose tests mock.module() process.js process-globally —
499
502
  * depending on it here would couple this lib's behavior to those mocks.
500
503
  */
@@ -625,13 +628,15 @@ function clearIngressState(workspaceDir: string): void {
625
628
  function clearStoppedIngress(workspaceDir: string, pidPath: string): void {
626
629
  clearIngressState(workspaceDir);
627
630
  rmSync(pidPath, { force: true });
631
+ // The edge is gone; drop the root lockfile's process entry with it.
632
+ clearRootProcess("app");
628
633
  }
629
634
 
630
635
  /**
631
- * Write the nginx config and spawn nginx detached (same idiom as the ngrok
632
- * spawn in ngrok.ts: stdout/stderr to a log file, fd closed after spawn,
633
- * caller unrefs). nginx runs with `daemon off` so the spawned process is the
634
- * master; it writes its pid to nginx.pid under the prefix dir.
636
+ * Write the nginx config and spawn nginx detached (stdout/stderr to a log
637
+ * file, fd closed after spawn, caller unrefs). nginx runs with `daemon off`
638
+ * so the spawned process is the master; it writes its pid to nginx.pid under
639
+ * the prefix dir.
635
640
  */
636
641
  export function startIngressNginx(opts: {
637
642
  workspaceDir: string;
@@ -1035,8 +1040,22 @@ export async function stopContainerTunnelEdge(
1035
1040
  return stopIngressNginx(workspaceDir);
1036
1041
  }
1037
1042
 
1038
- /** Resolved edge a tunnel (or bring-your-own HTTPS front) should target. */
1039
- export interface TunnelEdge {
1043
+ /** Record the running edge's pid in the root lockfile for `cadet ps`. */
1044
+ function stampAppProcessEntry(
1045
+ workspaceDir: string,
1046
+ assistantId?: string,
1047
+ ): void {
1048
+ const pid = getIngressPid(workspaceDir);
1049
+ if (pid !== null) {
1050
+ stampRootProcess("app", {
1051
+ pid,
1052
+ startedAt: new Date().toISOString(),
1053
+ assistantId,
1054
+ });
1055
+ }
1056
+ }
1057
+
1058
+ /** Resolved edge a tunnel (or bring-your-own HTTPS front) should target. */ export interface TunnelEdge {
1040
1059
  /** Loopback listen port the HTTPS front should forward to. */
1041
1060
  port: number;
1042
1061
  /** True when this call started the edge, false when a running one was reused. */
@@ -1099,6 +1118,7 @@ export async function ensureTunnelEdge(opts: {
1099
1118
 
1100
1119
  switch (result.status) {
1101
1120
  case "started":
1121
+ stampAppProcessEntry(opts.workspaceDir, opts.assistantId);
1102
1122
  return {
1103
1123
  port: result.listenPort,
1104
1124
  started: true,
@@ -1132,6 +1152,7 @@ export async function ensureTunnelEdge(opts: {
1132
1152
  stuckEdgeRecoveryHint(opts.workspaceDir),
1133
1153
  );
1134
1154
  }
1155
+ stampAppProcessEntry(opts.workspaceDir, opts.assistantId);
1135
1156
  return {
1136
1157
  port: result.listenPort,
1137
1158
  started: false,
@@ -1,11 +1,10 @@
1
- import type { ChildProcess } from "child_process";
2
-
3
- import { selectRestartTunnelRecord } from "@mycadet/service-contracts/ingress";
4
-
5
1
  import {
2
+ clearRootProcess,
6
3
  loadAllAssistants,
7
4
  loadAllAssistantsAcrossEnvs,
5
+ readRootProcesses,
8
6
  type AssistantEntry,
7
+ type RootProcessRecord,
9
8
  } from "./assistant-config.js";
10
9
 
11
10
  import {
@@ -14,7 +13,6 @@ import {
14
13
  loadRawConfig,
15
14
  parseGatewayPortFromEntryUrls,
16
15
  } from "./ingress-config.js";
17
- import { IngressConfigSchema } from "./ngrok.js";
18
16
  import {
19
17
  ensureTunnelEdge,
20
18
  formatEdgeMode,
@@ -23,8 +21,187 @@ import {
23
21
  type TunnelEdge,
24
22
  } from "./nginx-ingress.js";
25
23
  import { waitForDaemonReady } from "./http-client.js";
26
- import { hasWebhookIntegrations, maybeStartNgrokTunnel } from "./ngrok.js";
24
+ import {
25
+ hasWebhookIntegrations,
26
+ IngressConfigSchema,
27
+ isNgrokProcess,
28
+ maybeStartNgrokTunnel,
29
+ NGROK_TUNNEL_META,
30
+ runNgrokTunnel,
31
+ saveNgrokDomain,
32
+ } from "./tunnels/ngrok.js";
33
+ import { stopProcess } from "./process.js";
27
34
  import { shellArg } from "./shell-arg.js";
35
+ import { notifyGatewayOfPublicUrl } from "./tunnels/gateway-notify.js";
36
+ import {
37
+ selectRestartTunnelRecord,
38
+ type TunnelProviderName,
39
+ } from "@mycadet/service-contracts/ingress";
40
+ import { runCadetTunnel, CADET_TUNNEL_META } from "./tunnels/cadet.js";
41
+ import {
42
+ runCloudflareTunnel,
43
+ CLOUDFLARE_TUNNEL_META,
44
+ } from "./tunnels/cloudflare.js";
45
+ import {
46
+ runTailscaleTunnel,
47
+ TAILSCALE_TUNNEL_META,
48
+ } from "./tunnels/tailscale.js";
49
+
50
+ /**
51
+ * The provider the automatic tunnel paths start when no provider is named.
52
+ * ngrok today; the north star has the managed `cadet` tunnel as the eventual
53
+ * default, at which point only this constant flips.
54
+ */
55
+ export const DEFAULT_TUNNEL_PROVIDER: TunnelProviderName = "ngrok";
56
+
57
+ /** Per-provider copy `cadet tunnel`'s help renders. */
58
+ export interface ProviderMeta {
59
+ /** How far the tunnel URL reaches. */
60
+ reach: string;
61
+ /** Install instruction line, rendered under the provider entry. */
62
+ install: string;
63
+ /** Extra behavior notes, rendered under the install line. */
64
+ notes?: string[];
65
+ /** Example reserved-domain value, for providers that support `--domain`. */
66
+ domainExample?: string;
67
+ /** Whether `--domain`/`--clear-domain` can bind a reserved domain. */
68
+ supportsReservedDomain: boolean;
69
+ /**
70
+ * Listed but not yet implemented: the command refuses it before starting
71
+ * any infrastructure, so `--provider <name>` fails fast with the
72
+ * coming-soon copy instead of building an edge nothing will use.
73
+ */
74
+ comingSoon?: boolean;
75
+ }
76
+
77
+ /**
78
+ * The registry: each provider's copy lives in its own `tunnels/<provider>.ts`
79
+ * module and is assembled here, so `cadet tunnel`'s help, `--provider`
80
+ * validation, and dispatch all read one shape.
81
+ */
82
+ export const PROVIDER_META: Record<TunnelProviderName, ProviderMeta> = {
83
+ cadet: CADET_TUNNEL_META,
84
+ ngrok: NGROK_TUNNEL_META,
85
+ cloudflare: CLOUDFLARE_TUNNEL_META,
86
+ tailscale: TAILSCALE_TUNNEL_META,
87
+ };
88
+
89
+ /**
90
+ * Registry check that tolerates any provider spelling: only providers whose
91
+ * meta claims reserved-domain support accept `--domain`/`--clear-domain`.
92
+ */
93
+ export function providerSupportsReservedDomain(provider: string): boolean {
94
+ for (const [name, meta] of Object.entries(PROVIDER_META)) {
95
+ if (name === provider) return meta.supportsReservedDomain;
96
+ }
97
+ return false;
98
+ }
99
+
100
+ /** Options every provider tunnel accepts; `runProviderTunnel` maps them. */
101
+ export interface ProviderTunnelOptions {
102
+ port: number;
103
+ assistantId?: string;
104
+ workspaceDir: string;
105
+ /** Reserved domain to bind (providers that support one only). */
106
+ domain?: string;
107
+ /** Tailnet-only runs keep the webhook callback base as it is. */
108
+ preserveIngressUrl?: boolean;
109
+ }
110
+
111
+ /**
112
+ * What every tunnel provider's run hands back: the public URL it established
113
+ * or adopted, and the promise that settles when the run ends. Conformance is
114
+ * the point — the tunnel level can notify the gateway the same way no matter
115
+ * which provider ran.
116
+ */
117
+ export interface ProviderTunnelResult {
118
+ publicUrl: string;
119
+ waitForExit: Promise<void>;
120
+ }
121
+
122
+ /**
123
+ * Run a tunnel provider by name — the dispatch `cadet tunnel` uses, so the
124
+ * command layer never names a provider implementation directly. Notifies the
125
+ * gateway of the resulting public URL identically for every provider, then
126
+ * holds until the run ends.
127
+ */
128
+ export async function runProviderTunnel(
129
+ provider: TunnelProviderName,
130
+ opts: ProviderTunnelOptions,
131
+ ): Promise<{ publicUrl: string }> {
132
+ const result = await runProvider(provider, opts);
133
+ await notifyGatewayOfPublicUrl(result.publicUrl, opts.assistantId);
134
+ await result.waitForExit;
135
+ return { publicUrl: result.publicUrl };
136
+ }
137
+
138
+ /** Map the provider name onto its run — no gateway notification here. */
139
+ function runProvider(
140
+ provider: TunnelProviderName,
141
+ opts: ProviderTunnelOptions,
142
+ ): 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
+ });
166
+ }
167
+
168
+ /** Drop the saved reserved-domain record (providers that keep one). */
169
+ export function clearReservedDomain(): void {
170
+ saveNgrokDomain(null);
171
+ }
172
+
173
+ /**
174
+ * Stop a tunnel agent this CLI started, per the root lockfile's `tunnel`
175
+ * entry. The pid is verified against the provider's process identity before
176
+ * signaling — a stale entry after PID reuse must not kill an unrelated
177
+ * process.
178
+ */
179
+ export async function stopTunnelProcess(): Promise<void> {
180
+ const recorded: RootProcessRecord | undefined = readRootProcesses().tunnel;
181
+ if (!recorded) return;
182
+ if (isNgrokProcess(recorded.pid)) {
183
+ await stopProcess(recorded.pid, "tunnel");
184
+ }
185
+ clearRootProcess("tunnel");
186
+ }
187
+
188
+ /**
189
+ * The `cadet tunnel --detach` copy for a run that adopted an already-running
190
+ * agent: killing the supervisor does not stop a tunnel it did not spawn.
191
+ */
192
+ export function adoptedTunnelStopNotice(pid: number | undefined): string {
193
+ return (
194
+ `That ngrok agent was already running outside this command, so \`kill ${pid}\` only ` +
195
+ "stops this supervisor, not the ngrok tunnel or its saved ingress URL. Stop the ngrok agent " +
196
+ "directly to end the tunnel."
197
+ );
198
+ }
199
+
200
+ export {
201
+ ADOPTED_TUNNEL_RE,
202
+ hasWebhookIntegrationsConfigured,
203
+ } from "./tunnels/ngrok.js";
204
+ export { cadetTunnelComingSoonError } from "./tunnels/cadet.js";
28
205
 
29
206
  /** Matches the Docker hatch path's service-readiness allowance. */
30
207
  export const DOCKER_GATEWAY_READY_TIMEOUT_MS = 5 * 60_000;
@@ -221,8 +398,9 @@ function ownsSharedIngress(
221
398
  * survived across sleep (a tailnet serve, a reserved ngrok domain) reaches the
222
399
  * gateway again without a manual `cadet tunnel`.
223
400
  *
224
- * No webhook auto-tunnel here. Container wakes have never tracked a spawned
225
- * ngrok PID, so starting one would leak it past the next sleep.
401
+ * The auto-tunnel runs here too, matching the local wake path: the spawned
402
+ * agent records itself in the root lockfile under `tunnel`, so `cadet ps` and
403
+ * the stop paths can see it instead of it leaking past the next sleep.
226
404
  */
227
405
  export async function restoreContainerTunnelEdge(
228
406
  entry: AssistantEntry,
@@ -257,20 +435,30 @@ export async function restoreContainerTunnelEdge(
257
435
  );
258
436
  return;
259
437
  }
260
- await restoreTunnelEdge(entry.assistantId, gatewayPort, workspaceDir, false);
438
+ await restoreTunnelEdgeAndAutoTunnel(
439
+ entry.assistantId,
440
+ gatewayPort,
441
+ workspaceDir,
442
+ );
261
443
  }
262
444
 
445
+ /**
446
+ * Restore the nginx edge, then start the default provider's tunnel at it
447
+ * (the gateway port when the edge is down, keeping webhook delivery alive).
448
+ * The spawned agent records itself in the root lockfile under `tunnel`;
449
+ * adopting an already-running one clears any stale entry.
450
+ */
263
451
  export async function restoreTunnelEdgeAndAutoTunnel(
264
452
  assistantId: string,
265
453
  gatewayPort: number,
266
454
  workspaceDir: string,
267
- ): Promise<ChildProcess | null> {
455
+ ): Promise<void> {
268
456
  const edgePort = await restoreTunnelEdge(
269
457
  assistantId,
270
458
  gatewayPort,
271
459
  workspaceDir,
272
460
  );
273
- return maybeStartNgrokTunnel(
461
+ await maybeStartNgrokTunnel(
274
462
  edgePort ?? gatewayPort,
275
463
  workspaceDir,
276
464
  assistantId,
@@ -0,0 +1,29 @@
1
+ import { STALE_CLI_UPDATE_HINT } from "../stale-cli-hint.js";
2
+ import type { ProviderMeta, ProviderTunnelOptions } from "../tunnel.js";
3
+
4
+ /**
5
+ * The managed `cadet` tunnel. It is listed in the shared registry so the
6
+ * command's help and `--provider` validation speak its name, but the tunnel
7
+ * itself ships with Cadet Cloud — until then every run refuses loudly.
8
+ */
9
+ export const CADET_TUNNEL_META: ProviderMeta = {
10
+ reach: "Managed tunnel via Cadet Cloud (coming soon)",
11
+ install: "no install needed — included with Cadet Cloud",
12
+ supportsReservedDomain: false,
13
+ comingSoon: true,
14
+ };
15
+
16
+ /** Copy every refusing path shares, so the wording cannot drift apart. */
17
+ export function cadetTunnelComingSoonError(provider: string): Error {
18
+ return new Error(
19
+ `Tunnel provider '${provider}' is not yet implemented. ` +
20
+ `If this provider is documented, ${STALE_CLI_UPDATE_HINT}`,
21
+ );
22
+ }
23
+
24
+ /** Refuse the run: the managed tunnel is not something the CLI can start. */
25
+ export async function runCadetTunnel(
26
+ _opts: ProviderTunnelOptions,
27
+ ): Promise<never> {
28
+ throw cadetTunnelComingSoonError("cadet");
29
+ }
@@ -1,11 +1,13 @@
1
+ import type { ProviderMeta, ProviderTunnelResult } from "../tunnel.js";
2
+
1
3
  import { execFileSync, spawn, type ChildProcess } from "node:child_process";
2
4
 
3
- import { GATEWAY_PORT } from "./constants.js";
5
+ import { GATEWAY_PORT } from "../constants.js";
4
6
  import {
5
7
  clearIngressUrl,
6
8
  getDefaultWorkspaceDir,
7
9
  saveIngressUrl,
8
- } from "./ingress-config.js";
10
+ } from "../ingress-config.js";
9
11
 
10
12
  // ── Cloudflare Tunnel ─────────────────────────────────────────────────────────
11
13
 
@@ -135,7 +137,7 @@ export interface RunCloudflareTunnelOptions {
135
137
 
136
138
  export async function runCloudflareTunnel(
137
139
  opts: RunCloudflareTunnelOptions = {},
138
- ): Promise<void> {
140
+ ): Promise<ProviderTunnelResult> {
139
141
  const version = getCloudflareTunnelVersion();
140
142
  if (!version) {
141
143
  console.error("Error: cloudflared is not installed.");
@@ -226,7 +228,16 @@ export async function runCloudflareTunnel(
226
228
  console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
227
229
 
228
230
  // Keep running until cloudflared exits (e.g., network error or user Ctrl+C)
229
- await new Promise<void>((resolve) => {
231
+ const waitForExit = new Promise<void>((resolve) => {
230
232
  child.on("exit", () => resolve());
231
233
  });
234
+ return { publicUrl, waitForExit };
232
235
  }
236
+
237
+ /** Per-provider copy `cadet tunnel`'s help renders for cloudflare. */
238
+ export const CLOUDFLARE_TUNNEL_META: ProviderMeta = {
239
+ reach: "Public tunnel that webhook integrations can reach",
240
+ install: "install: brew install cloudflare/cloudflare/cloudflared",
241
+ notes: ["No Cloudflare account required for quick tunnels."],
242
+ supportsReservedDomain: false,
243
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Tunnel-level gateway notification: once ANY tunnel provider has a public
3
+ * URL, the gateway learns it here — ngrok-agnostic on purpose, so every
4
+ * provider conforms by returning its `publicUrl` and the dispatch (`cadet
5
+ * tunnel`'s tunnel level, or the daemon's auto-tunnel) calls this.
6
+ */
7
+
8
+ import { lookupAssistantByIdentifier } from "../assistant-config.js";
9
+ import { GATEWAY_PORT } from "../constants.js";
10
+ import { loadGuardianToken } from "../guardian-token.js";
11
+ import { loopbackSafeFetch } from "../loopback-fetch.js";
12
+
13
+ /**
14
+ * Where the gateway for this run's assistant answers. The entry's recorded
15
+ * loopback URL when one resolves; the global gateway port otherwise.
16
+ */
17
+ function gatewayUrlFor(assistantId: string | undefined): string {
18
+ if (assistantId) {
19
+ const result = lookupAssistantByIdentifier(assistantId);
20
+ if (result.status === "found") {
21
+ const url = result.entry.localUrl ?? result.entry.runtimeUrl;
22
+ if (url) return url;
23
+ }
24
+ }
25
+ return `http://127.0.0.1:${GATEWAY_PORT}`;
26
+ }
27
+
28
+ /**
29
+ * Tell the gateway the tunnel's public URL so it records the ingress domain
30
+ * in its own config, the same way its own tunnel machinery does. Best-effort:
31
+ * a gateway that is down never fails the tunnel that fronts it.
32
+ */
33
+ export async function notifyGatewayOfPublicUrl(
34
+ publicUrl: string,
35
+ assistantId: string | undefined,
36
+ ): Promise<void> {
37
+ try {
38
+ const token = assistantId
39
+ ? loadGuardianToken(assistantId)?.accessToken
40
+ : undefined;
41
+ const res = await loopbackSafeFetch(
42
+ `${gatewayUrlFor(assistantId)}/v1/ingress/domains`,
43
+ {
44
+ method: "POST",
45
+ headers: {
46
+ "Content-Type": "application/json",
47
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
48
+ },
49
+ body: JSON.stringify({ publicBaseUrl: publicUrl }),
50
+ signal: AbortSignal.timeout(5_000),
51
+ },
52
+ );
53
+ if (!res.ok) {
54
+ throw new Error(`gateway responded ${res.status}`);
55
+ }
56
+ } catch (err) {
57
+ console.warn(
58
+ ` ⚠ Could not record the ingress domain with the gateway: ${err instanceof Error ? err.message : String(err)}. The tunnel still runs.`,
59
+ );
60
+ }
61
+ }