@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
@@ -1,4 +1,11 @@
1
- import type { ProviderMeta, ProviderTunnelResult } from "../tunnel.js";
1
+ import { clearRootProcess, stampRootProcess } from "../assistant-config.js";
2
+ import { readProcessCommandLine } from "../process.js";
3
+ import { stopOwnedTunnelAgent } from "./process-lifecycle.js";
4
+ import type {
5
+ TunnelProvider,
6
+ ProviderMeta,
7
+ ProviderTunnelResult,
8
+ } from "../tunnel.js";
2
9
 
3
10
  import { execFileSync, spawn, type ChildProcess } from "node:child_process";
4
11
 
@@ -106,6 +113,7 @@ export function waitForCloudflareTunnelUrl(
106
113
  });
107
114
 
108
115
  child.on("exit", (code) => {
116
+ if (child.pid) clearRootProcess("tunnel", child.pid);
109
117
  if (resolved) return;
110
118
  clearTimeout(timer);
111
119
  reject(
@@ -177,6 +185,7 @@ export async function runCloudflareTunnel(
177
185
 
178
186
  const cleanup = (): void => {
179
187
  if (!child.killed) child.kill("SIGTERM");
188
+ if (child.pid) clearRootProcess("tunnel", child.pid);
180
189
  if (publicUrl) {
181
190
  console.log("\nClearing ingress URL from config...");
182
191
  clearIngressUrl(workspaceDir, opts.assistantId);
@@ -198,6 +207,7 @@ export async function runCloudflareTunnel(
198
207
  });
199
208
 
200
209
  child.on("exit", (code) => {
210
+ if (child.pid) clearRootProcess("tunnel", child.pid);
201
211
  // Always clear the saved ingress URL when the tunnel process ends so
202
212
  // webhook integrations don't keep hitting a dead endpoint.
203
213
  if (publicUrl !== undefined) {
@@ -233,6 +243,13 @@ export async function runCloudflareTunnel(
233
243
  console.log("");
234
244
 
235
245
  saveIngressUrl(workspaceDir, publicUrl, opts.assistantId, "cloudflare");
246
+ if (child.pid)
247
+ stampRootProcess("tunnel", {
248
+ pid: child.pid,
249
+ provider: "cloudflare",
250
+ assistantId: opts.assistantId,
251
+ startedAt: new Date().toISOString(),
252
+ });
236
253
  console.log("Ingress URL saved to config.");
237
254
  console.log("");
238
255
  console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
@@ -251,3 +268,21 @@ export const CLOUDFLARE_TUNNEL_META: ProviderMeta = {
251
268
  notes: ["No Cloudflare account required for quick tunnels."],
252
269
  supportsReservedDomain: false,
253
270
  };
271
+
272
+ export const CLOUDFLARE_PROVIDER: TunnelProvider = {
273
+ run: runCloudflareTunnel,
274
+ stop: (opts) =>
275
+ stopOwnedTunnelAgent(
276
+ opts,
277
+ (pid) => {
278
+ try {
279
+ return /(?:^|[\\/\s])cloudflared(?:\.exe)?(?:\s|$)/i.test(
280
+ readProcessCommandLine(pid),
281
+ );
282
+ } catch {
283
+ return false;
284
+ }
285
+ },
286
+ "cloudflared",
287
+ ),
288
+ };
@@ -25,7 +25,12 @@ import { getCurrentEnvironment } from "../environments/resolve.js";
25
25
  import { loopbackSafeFetch } from "../loopback-fetch.js";
26
26
  import { readProcessCommandLine } from "../process.js";
27
27
  import { z } from "zod";
28
- import type { ProviderMeta, ProviderTunnelResult } from "../tunnel.js";
28
+ import { stopOwnedTunnelAgent } from "./process-lifecycle.js";
29
+ import type {
30
+ TunnelProvider,
31
+ ProviderMeta,
32
+ ProviderTunnelResult,
33
+ } from "../tunnel.js";
29
34
  import { notifyGatewayOfPublicUrl } from "./gateway-notify.js";
30
35
 
31
36
  const NGROK_API_URL = "http://127.0.0.1:4040/api/tunnels";
@@ -631,8 +636,12 @@ export async function runNgrokTunnel(
631
636
  console.log("Ingress URL saved to config.");
632
637
  console.log("");
633
638
  console.log(
634
- "Tunnel is already running. Press Ctrl+C to detach (tunnel stays active).",
639
+ `An ngrok agent from an earlier run is still up serving 127.0.0.1:${port} (${existingUrl}) — this run adopts it instead of starting a new agent.`,
635
640
  );
641
+ console.log(
642
+ "Adopted agents do not appear in `cadet ps` because this run does not own the process.",
643
+ );
644
+ console.log("Press Ctrl+C to detach (tunnel stays active).");
636
645
 
637
646
  // Block until SIGINT/SIGTERM
638
647
  const waitForExit = new Promise<void>((resolve) => {
@@ -783,3 +792,8 @@ export async function runNgrokTunnel(
783
792
  // Unreachable: the loop only exits via return, process.exit, or a throw.
784
793
  throw new Error("ngrok tunnel did not start.");
785
794
  }
795
+
796
+ export const NGROK_PROVIDER: TunnelProvider = {
797
+ run: runNgrokTunnel,
798
+ stop: (opts) => stopOwnedTunnelAgent(opts, isNgrokProcess, "ngrok"),
799
+ };
@@ -0,0 +1,26 @@
1
+ import { clearIngressUrl } from "../ingress-config.js";
2
+ import { stopProcess } from "../process.js";
3
+ import type { ProviderStopOptions } from "../tunnel.js";
4
+
5
+ /** Stop only an identified provider process and retain state on termination failure. */
6
+ export async function stopOwnedTunnelAgent(
7
+ opts: ProviderStopOptions,
8
+ matches: (pid: number) => boolean,
9
+ label: string,
10
+ ): Promise<boolean> {
11
+ let stopped = false;
12
+ if (matches(opts.record.pid)) {
13
+ stopped = await stopProcess(opts.record.pid, "tunnel");
14
+ if (!stopped && matches(opts.record.pid)) {
15
+ throw new Error(
16
+ `Could not stop ${label} process ${opts.record.pid}; saved tunnel state was retained.`,
17
+ );
18
+ }
19
+ }
20
+ if (opts.workspaceDir)
21
+ clearIngressUrl(
22
+ opts.workspaceDir,
23
+ opts.assistantId ?? opts.record.assistantId,
24
+ );
25
+ return stopped;
26
+ }
@@ -1,4 +1,16 @@
1
- import type { ProviderMeta, ProviderTunnelResult } from "../tunnel.js";
1
+ import { z } from "zod";
2
+ import {
3
+ clearRootProcess,
4
+ readRootProcesses,
5
+ stampRootProcess,
6
+ } from "../assistant-config.js";
7
+ import { readProcessCommandLine, stopProcess } from "../process.js";
8
+ import type {
9
+ TunnelProvider,
10
+ ProviderStopOptions,
11
+ ProviderMeta,
12
+ ProviderTunnelResult,
13
+ } from "../tunnel.js";
2
14
 
3
15
  import { spawnSync } from "node:child_process";
4
16
  import { win32 } from "node:path";
@@ -7,6 +19,7 @@ import { GATEWAY_PORT } from "../constants.js";
7
19
  import { ensureBinaryInstalled } from "../ensure-binary.js";
8
20
  import {
9
21
  clearIngressUrl,
22
+ loadRawConfig,
10
23
  getDefaultWorkspaceDir,
11
24
  readLockfileIngressUrl,
12
25
  saveIngressUrl,
@@ -418,6 +431,16 @@ export async function runTailscaleTunnel(
418
431
  );
419
432
  console.log("or stop it later with: tailscale serve --https=443 off");
420
433
 
434
+ const previous = opts.preserveIngressUrl
435
+ ? readRootProcesses().tunnel
436
+ : undefined;
437
+ stampRootProcess("tunnel", {
438
+ ...(previous?.provider !== "tailscale" ? { previous } : {}),
439
+ pid: process.pid,
440
+ provider: "tailscale",
441
+ assistantId: opts.assistantId,
442
+ startedAt: new Date().toISOString(),
443
+ });
421
444
  const teardown = (): void => {
422
445
  console.log(
423
446
  `\nStopping tailscale serve and clearing the ${publishedLabel}...`,
@@ -441,6 +464,7 @@ export async function runTailscaleTunnel(
441
464
  );
442
465
  }
443
466
  if (shouldClearIngressUrl(result)) {
467
+ clearRootProcess("tunnel", process.pid);
444
468
  retractServeUrl(opts, workspaceDir, previousLockfileIngressUrl);
445
469
  } else {
446
470
  console.error(
@@ -475,3 +499,57 @@ export const TAILSCALE_TUNNEL_META: ProviderMeta = {
475
499
  ],
476
500
  supportsReservedDomain: false,
477
501
  };
502
+
503
+ const stoppedTailscaleStateSchema = z.object({
504
+ ingress: z
505
+ .object({
506
+ publicBaseUrl: z.string().optional(),
507
+ pairingTunnel: z.object({ provider: z.string() }).optional(),
508
+ })
509
+ .optional(),
510
+ });
511
+
512
+ export async function stopTailscaleTunnel(
513
+ opts: ProviderStopOptions,
514
+ deps: TailscaleDeps = {
515
+ findBinary: async () => probeTailscaleBinary(),
516
+ run: realRun,
517
+ },
518
+ ): Promise<boolean> {
519
+ const binary = await deps.findBinary();
520
+ if (!binary) throw new Error(getTailscaleInstallMessage());
521
+ const result = stopTailscaleServe(binary, deps);
522
+ if (result.status !== 0)
523
+ throw new Error(`Could not stop tailscale serve: ${joinOutput(result)}`);
524
+ // The serve belongs to tailscaled; only the Cadet supervisor may be signaled.
525
+ let supervisor = false;
526
+ try {
527
+ const command = readProcessCommandLine(opts.record.pid);
528
+ supervisor =
529
+ /cadet/i.test(command) && /(?:^|\s)tunnel(?:\s|$)/.test(command);
530
+ } catch {
531
+ /* The supervisor may already have exited. */
532
+ }
533
+ if (supervisor && opts.record.pid !== process.pid)
534
+ await stopProcess(opts.record.pid, "tunnel supervisor");
535
+ if (opts.workspaceDir) {
536
+ const state = stoppedTailscaleStateSchema.parse(
537
+ loadRawConfig(opts.workspaceDir),
538
+ ).ingress;
539
+ const assistantId = opts.assistantId ?? opts.record.assistantId;
540
+ retractServeUrl(
541
+ {
542
+ assistantId,
543
+ preserveIngressUrl: state?.pairingTunnel?.provider === "tailscale",
544
+ },
545
+ opts.workspaceDir,
546
+ state?.publicBaseUrl ?? null,
547
+ );
548
+ }
549
+ return true;
550
+ }
551
+
552
+ export const TAILSCALE_PROVIDER: TunnelProvider = {
553
+ run: runTailscaleTunnel,
554
+ stop: stopTailscaleTunnel,
555
+ };
@@ -142,3 +142,14 @@ export function pipeToLogFile(
142
142
  stream.on("error", onDone);
143
143
  }
144
144
  }
145
+
146
+ /** Runtime output belongs to the instance, alongside its service state. */
147
+ export function openRuntimeLogFile(instanceDir: string, service: "assistant" | "gateway"): number | "ignore" {
148
+ try {
149
+ const dir = join(instanceDir, "logs");
150
+ mkdirSync(dir, { recursive: true });
151
+ return openSync(join(dir, `${service}-stdout.log`), "a", 0o600);
152
+ } catch {
153
+ return "ignore";
154
+ }
155
+ }