@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
@@ -10,7 +10,7 @@ import { green } from "../lib/color";
10
10
  import { HATCH_ART, HATCHED_EMOJI, VALID_REMOTE_HOSTS } from "../lib/constants";
11
11
  import type { RemoteHost } from "../lib/constants";
12
12
  import { hatchDocker } from "../lib/docker";
13
- import { parseFeatureFlagArgs, readAmbientFlagEnvVars } from "../lib/flag-args";
13
+ import { readAmbientFlagEnvVars } from "../lib/flag-args";
14
14
  import { hatchLocal } from "../lib/hatch-local";
15
15
  import {
16
16
  getCloudUrl,
@@ -23,37 +23,26 @@ const DEFAULT_REMOTE: RemoteHost = "local";
23
23
 
24
24
  interface HatchArgs {
25
25
  verbose: boolean;
26
- keepAlive: boolean;
27
26
  name: string | null;
28
27
  remote: RemoteHost;
29
28
  watch: boolean;
30
29
  sourcePath: string | null;
31
30
  configValues: Record<string, string>;
32
31
  flagEnvVars: Record<string, string>;
33
- analyze: boolean;
34
- disableCloud: boolean;
35
32
  netnsContainer: string | null;
36
33
  gatewayPort: number | null;
37
34
  assistantCaCert: string | null;
38
35
  }
39
36
 
40
37
  function parseArgs(): HatchArgs {
41
- const { envVars: cliFlagVars, remaining: args } = parseFeatureFlagArgs(
42
- process.argv.slice(3),
43
- );
44
- const flagEnvVars = { ...readAmbientFlagEnvVars(), ...cliFlagVars };
45
- const disableCloudAmbient =
46
- process.env.CADET_DISABLE_CLOUD?.trim().toLowerCase();
47
- let disableCloud =
48
- disableCloudAmbient === "true" || disableCloudAmbient === "1";
38
+ const args = process.argv.slice(3);
39
+ const flagEnvVars = readAmbientFlagEnvVars();
49
40
  let verbose = false;
50
- let keepAlive = false;
51
41
  let name: string | null = null;
52
42
  let remote: RemoteHost = DEFAULT_REMOTE;
53
43
  let watch = false;
54
44
  let sourcePath: string | null = null;
55
45
  const configValues: Record<string, string> = {};
56
- let analyze = false;
57
46
  let netnsContainer: string | null = null;
58
47
  let gatewayPort: number | null = null;
59
48
  let assistantCaCert: string | null = null;
@@ -69,47 +58,33 @@ function parseArgs(): HatchArgs {
69
58
  console.log(
70
59
  " -v, --verbose Show service startup logs and extended next steps",
71
60
  );
72
- console.log(" --name <name> Custom instance name");
61
+ console.log(" --name <name> Custom name for this assistant");
73
62
  console.log(
74
63
  " --remote <host> Remote host (local, docker, custom, cloud)",
75
64
  );
76
65
  console.log(
77
- " --watch Run assistant and gateway in watch mode (hot reload on source changes)",
78
- );
79
- console.log(
80
- " --source <path> Build images from a local source tree at <path>. Useful for callers that want each run to pick up local CLI changes.",
66
+ " --watch Run Cadet in watch mode with hot reload on source changes",
81
67
  );
82
68
  console.log(
83
- " --keep-alive Stay alive after hatch, exit when gateway stops",
69
+ " --source <path> Build images from a local source tree at <path>",
84
70
  );
85
71
  console.log(
86
72
  " --config <key=value> Set a workspace config value (repeatable)",
87
73
  );
88
74
  console.log(
89
- " --flag <key=value> Set a feature flag override as CADET_FLAG_<KEY> env var (repeatable)",
75
+ " --netns-container <name> Join an existing container's network namespace instead of creating a per-instance network.",
90
76
  );
91
77
  console.log(
92
- " --analyze Emit a structured hatch-timing log line on stdout",
78
+ " --gateway-port <port> Use an explicit host port for accessing the assistant.",
93
79
  );
94
80
  console.log(
95
- " --disable-cloud Suppress all outbound platform API calls",
96
- );
97
- console.log(
98
- " --netns-container <name> Join an existing container's network namespace (docker target only) instead of creating a per-instance network. The namespace owner publishes host ports, so --gateway-port is required.",
99
- );
100
- console.log(
101
- " --gateway-port <port> Use an explicit host port for the gateway runtime URL instead of auto-allocating. Required with --netns-container.",
102
- );
103
- console.log(
104
- " --assistant-ca-cert <path> Trust an extra PEM CA bundle in the assistant container (NODE_EXTRA_CA_CERTS) from process start. Useful behind a TLS-terminating egress proxy.",
81
+ " --assistant-ca-cert <path> Trust an extra PEM CA bundle in the assistant container from process start.",
105
82
  );
106
83
  process.exit(0);
107
84
  } else if (arg === "-v" || arg == "--verbose") {
108
85
  verbose = true;
109
86
  } else if (arg === "--watch") {
110
87
  watch = true;
111
- } else if (arg === "--analyze") {
112
- analyze = true;
113
88
  } else if (arg === "--source") {
114
89
  const next = args[i + 1];
115
90
  if (!next || next.startsWith("-")) {
@@ -118,8 +93,6 @@ function parseArgs(): HatchArgs {
118
93
  }
119
94
  sourcePath = next;
120
95
  i++;
121
- } else if (arg === "--keep-alive") {
122
- keepAlive = true;
123
96
  } else if (arg === "--name") {
124
97
  const next = args[i + 1];
125
98
  if (!next || next.startsWith("-")) {
@@ -163,8 +136,6 @@ function parseArgs(): HatchArgs {
163
136
  const value = next.slice(eqIndex + 1);
164
137
  configValues[key] = value;
165
138
  i++;
166
- } else if (arg === "--disable-cloud") {
167
- disableCloud = true;
168
139
  } else if (arg === "--netns-container") {
169
140
  const next = args[i + 1];
170
141
  if (!next || next.startsWith("-")) {
@@ -194,7 +165,7 @@ function parseArgs(): HatchArgs {
194
165
  i++;
195
166
  } else {
196
167
  console.error(
197
- `Error: Unknown argument '${arg}'. Valid options: -v, --verbose, --watch, --source <path>, --keep-alive, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>, --config <key=value>, --flag <key=value>, --analyze, --disable-cloud, --netns-container <name>, --gateway-port <port>, --assistant-ca-cert <path>`,
168
+ `Error: Unknown argument '${arg}'. Valid options: -v, --verbose, --watch, --source <path>, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>, --config <key=value>, --netns-container <name>, --gateway-port <port>, --assistant-ca-cert <path>`,
198
169
  );
199
170
  process.exit(1);
200
171
  }
@@ -202,15 +173,12 @@ function parseArgs(): HatchArgs {
202
173
 
203
174
  return {
204
175
  verbose,
205
- keepAlive,
206
176
  name,
207
177
  remote,
208
178
  watch,
209
179
  sourcePath,
210
180
  configValues,
211
181
  flagEnvVars,
212
- analyze,
213
- disableCloud,
214
182
  netnsContainer,
215
183
  gatewayPort,
216
184
  assistantCaCert,
@@ -230,25 +198,17 @@ export const hatch = Object.assign(
230
198
 
231
199
  const {
232
200
  verbose,
233
- keepAlive,
234
201
  name,
235
202
  remote,
236
203
  watch,
237
204
  sourcePath,
238
205
  configValues,
239
206
  flagEnvVars,
240
- analyze,
241
- disableCloud,
242
207
  netnsContainer,
243
208
  gatewayPort,
244
209
  assistantCaCert,
245
210
  } = parseArgs();
246
211
 
247
- if (disableCloud) {
248
- process.env.CADET_DISABLE_CLOUD = "true";
249
- flagEnvVars.CADET_DISABLE_CLOUD = "true";
250
- }
251
-
252
212
  if (watch && remote !== "local" && remote !== "docker") {
253
213
  console.error(
254
214
  "Error: --watch is only supported for local and docker hatch targets.",
@@ -286,7 +246,6 @@ export const hatch = Object.assign(
286
246
  await hatchLocal({
287
247
  name,
288
248
  watch,
289
- keepAlive,
290
249
  configValues,
291
250
  flagEnvVars,
292
251
  verbose,
@@ -301,7 +260,6 @@ export const hatch = Object.assign(
301
260
  configValues,
302
261
  flagEnvVars,
303
262
  sourcePath,
304
- analyze,
305
263
  netnsContainer: netnsContainer ?? undefined,
306
264
  gatewayPort: gatewayPort ?? undefined,
307
265
  assistantCaCertPath: assistantCaCert ?? undefined,
@@ -586,6 +586,10 @@ export const pair = Object.assign(
586
586
  console.log(qr);
587
587
  });
588
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("");
589
593
  if (appUrl) {
590
594
  console.log("The QR opens the Cadet app. App link:");
591
595
  console.log("");
@@ -598,7 +602,9 @@ export const pair = Object.assign(
598
602
  console.log("");
599
603
  console.log(` ${pairUrl}`);
600
604
  console.log("");
601
- 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}"`);
602
608
  console.log("");
603
609
  console.log(`Expires: ${challenge.expiresAt}`);
604
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
  }
@@ -813,6 +814,7 @@ export async function listAllAssistants(verbose: boolean): Promise<void> {
813
814
  ),
814
815
  { name: "NAME", type: "TYPE", status: "STATUS", info: "INFO" },
815
816
  );
817
+ console.log("");
816
818
  }
817
819
 
818
820
  // ── Entry point ─────────────────────────────────────────────────
@@ -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
@@ -63,7 +66,7 @@ function parseArgs(): TunnelArgs {
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.");
@@ -121,7 +124,7 @@ function parseArgs(): TunnelArgs {
121
124
  ` URL, and logs stream to tunnel.log in the CLI log directory. A background`,
122
125
  );
123
126
  console.log(
124
- " tunnel stops with `kill <pid>`, except when it adopted an already-running",
127
+ " tunnel stops with `cadet tunnel stop`, except when it adopted an already-running",
125
128
  );
126
129
  console.log(
127
130
  " tunnel (started outside this command): killing the supervisor then",
@@ -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,12 +422,40 @@ 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> {
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
+ }
420
459
  const { assistantName, provider, domain, clearDomain, foreground } =
421
460
  parseArgs();
422
461
 
@@ -76,7 +76,10 @@ import {
76
76
  startLocalDaemon,
77
77
  stopLocalProcesses,
78
78
  } from "../lib/local.js";
79
- import { restoreTunnelEdgeAndAutoTunnel } from "../lib/tunnel.js";
79
+ import {
80
+ restoreContainerTunnelEdge,
81
+ restoreTunnelEdgeAndAutoTunnel,
82
+ } from "../lib/tunnel.js";
80
83
  import {
81
84
  leaseGuardianToken,
82
85
  resetGuardianBootstrap,
@@ -516,6 +519,8 @@ async function upgradeDocker(
516
519
  preUpgradeBackupPath: backupPath ?? undefined,
517
520
  };
518
521
  saveAssistantEntry(updatedEntry);
522
+ // Refresh the host-served web bundle before clients reload on completion.
523
+ await restoreContainerTunnelEdge(updatedEntry);
519
524
 
520
525
  // Notify clients on the new service group that the upgrade succeeded.
521
526
  await broadcastUpgradeEvent(
@@ -170,6 +170,10 @@ export interface RootProcessRecord {
170
170
  /** The assistant the process fronts, so `cadet ps` can nest the row. */
171
171
  assistantId?: string;
172
172
  startedAt: string;
173
+ /** Listening port for the nginx edge. */
174
+ port?: number;
175
+ /** Managed process temporarily displaced by a pairing-only tunnel. */
176
+ previous?: RootProcessRecord;
173
177
  }
174
178
 
175
179
  export type RootProcessKind = "tunnel" | "app";
@@ -247,12 +251,21 @@ export function stampRootProcess(
247
251
  });
248
252
  }
249
253
 
250
- /** Drop the recorded root process entry, e.g. once the process is stopped. */
251
- export function clearRootProcess(kind: RootProcessKind): void {
254
+ /** Release a recorded process, preserving a replacement or restoring a displaced record. */
255
+ export function clearRootProcess(
256
+ kind: RootProcessKind,
257
+ expectedPid?: number,
258
+ ): void {
252
259
  withCliLockfileLock(() => {
253
260
  const data = readLockfile();
254
- if (data[kind] === undefined) return;
255
- delete data[kind];
261
+ const record = data[kind];
262
+ if (
263
+ record === undefined ||
264
+ (expectedPid !== undefined && record.pid !== expectedPid)
265
+ )
266
+ return;
267
+ if (record.previous) data[kind] = record.previous;
268
+ else delete data[kind];
256
269
  writeLockfileUnlocked(data);
257
270
  });
258
271
  }
@@ -442,7 +455,9 @@ export function loadLatestAssistant(): AssistantEntry | null {
442
455
  }
443
456
 
444
457
  export function findAssistantByName(name: string): AssistantEntry | null {
445
- return loadAllAssistants().find((entry) => entry.assistantId === name) ?? null;
458
+ return (
459
+ loadAllAssistants().find((entry) => entry.assistantId === name) ?? null
460
+ );
446
461
  }
447
462
 
448
463
  export function getAssistantDisplayName(entry: AssistantEntry): string {
@@ -0,0 +1,75 @@
1
+ import { z } from "zod";
2
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
3
+
4
+ const requestSchema = z.object({
5
+ request_id: z.string().regex(/^[a-f0-9]{64}$/),
6
+ poll_secret: z.string().regex(/^[a-f0-9]{64}$/),
7
+ verification_uri: z.string().url(),
8
+ user_code: z.string().min(1),
9
+ interval: z.number().min(1).max(10),
10
+ });
11
+ const resultSchema = z.object({ session_token: z.string().min(1) });
12
+
13
+ /** Browser approval and polling work when the terminal and browser are on different hosts. */
14
+ export async function pollingCloudLogin(
15
+ cloudUrl: string,
16
+ options: {
17
+ timeoutMs?: number;
18
+ fetch?: (
19
+ input: string | URL | Request,
20
+ init?: RequestInit,
21
+ ) => Promise<Response>;
22
+ log?: (message: string) => void;
23
+ } = {},
24
+ ): Promise<string> {
25
+ const request = options.fetch ?? loopbackSafeFetch;
26
+ const log = options.log ?? console.log;
27
+ const signal = AbortSignal.timeout(options.timeoutMs ?? 300_000);
28
+ const origin = new URL(cloudUrl).origin;
29
+ try {
30
+ const started = await request(`${origin}/v1/auth/cli/start`, {
31
+ method: "POST",
32
+ signal,
33
+ });
34
+ if (!started.ok)
35
+ throw new Error(`Could not start terminal login (${started.status}).`);
36
+ const login = requestSchema.parse(await started.json());
37
+ log(`Open this URL to log in: ${login.verification_uri}`);
38
+ log(
39
+ `Confirm code ${login.user_code} in your browser. Waiting up to 5 minutes...`,
40
+ );
41
+ while (!signal.aborted) {
42
+ const response = await request(`${origin}/v1/auth/cli/poll`, {
43
+ method: "POST",
44
+ signal,
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify({
47
+ request_id: login.request_id,
48
+ poll_secret: login.poll_secret,
49
+ }),
50
+ });
51
+ if (response.status === 200)
52
+ return resultSchema.parse(await response.json()).session_token;
53
+ if (response.status === 410)
54
+ throw new Error("Login request expired. Run cadet login again.");
55
+ if (response.status !== 202)
56
+ throw new Error(`Login polling failed (${response.status}).`);
57
+ await new Promise<void>((resolve, reject) => {
58
+ const abort = () => {
59
+ clearTimeout(timer);
60
+ reject(new Error("Login timed out after 5 minutes."));
61
+ };
62
+ const timer = setTimeout(() => {
63
+ signal.removeEventListener("abort", abort);
64
+ resolve();
65
+ }, login.interval * 1000);
66
+ signal.addEventListener("abort", abort, { once: true });
67
+ if (signal.aborted) abort();
68
+ });
69
+ }
70
+ throw new Error("Login timed out after 5 minutes.");
71
+ } catch (error) {
72
+ if (signal.aborted) throw new Error("Login timed out after 5 minutes.");
73
+ throw error;
74
+ }
75
+ }
@@ -6,6 +6,7 @@
6
6
  * login when the user picks that provider).
7
7
  */
8
8
 
9
+ import { pollingCloudLogin } from "./cloud-login-poll.js";
9
10
  import { randomBytes } from "crypto";
10
11
  import { createServer } from "http";
11
12
  import type { AddressInfo } from "net";
@@ -37,7 +38,7 @@ import {
37
38
  generatePkcePair,
38
39
  } from "./workos-pkce.js";
39
40
 
40
- const LOGIN_TIMEOUT_MS = 120_000; // 2 minutes
41
+ const LOGIN_TIMEOUT_MS = 300_000;
41
42
 
42
43
  function escapeHtml(s: string): string {
43
44
  return s
@@ -258,6 +259,7 @@ export function startLoopbackListener(
258
259
 
259
260
  /** App-held WorkOS PKCE login */
260
261
  export async function workosPkceLogin(cloudUrl: string): Promise<string> {
262
+ if (!process.stdin.isTTY) return pollingCloudLogin(cloudUrl);
261
263
  const clientId = await fetchWorkosClientId(cloudUrl);
262
264
  const { verifier, challenge } = generatePkcePair();
263
265
  const state = randomBytes(32).toString("hex");
package/src/lib/docker.ts CHANGED
@@ -305,7 +305,6 @@ export interface HatchDockerParams {
305
305
  * also enables hot-reload.
306
306
  */
307
307
  sourcePath?: string | null;
308
- analyze?: boolean;
309
308
  /**
310
309
  * Name of an existing container whose network namespace the assistant,
311
310
  * gateway, and credential-executor join (`--network=container:<name>`),
@@ -1719,7 +1718,6 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1719
1718
  logFd,
1720
1719
  runtimeUrl,
1721
1720
  containersUpAt,
1722
- analyze: params.analyze ?? false,
1723
1721
  });
1724
1722
 
1725
1723
  if (!ready && !(watch && repoRoot)) {
@@ -1828,7 +1826,6 @@ async function waitForGatewayAndLease(opts: {
1828
1826
  logFd: number | "ignore";
1829
1827
  runtimeUrl: string;
1830
1828
  containersUpAt: number;
1831
- analyze: boolean;
1832
1829
  }): Promise<{
1833
1830
  ready: boolean;
1834
1831
  guardianAccessToken?: string;
@@ -1844,7 +1841,6 @@ async function waitForGatewayAndLease(opts: {
1844
1841
  logFd,
1845
1842
  runtimeUrl,
1846
1843
  containersUpAt,
1847
- analyze,
1848
1844
  } = opts;
1849
1845
 
1850
1846
  const log = (msg: string): void => {
@@ -1964,14 +1960,12 @@ async function waitForGatewayAndLease(opts: {
1964
1960
  const containersUpToReadyMs = readyAt - start;
1965
1961
  const elapsedSec = (containersUpToReadyMs / 1000).toFixed(1);
1966
1962
  log(`Assistant ready after ${elapsedSec}s`);
1967
- if (analyze) {
1968
- console.info(
1969
- `[cadet-hatch-timing] ${JSON.stringify({
1970
- containers_up_to_ready_ms: containersUpToReadyMs,
1971
- instance: instanceName,
1972
- })}`,
1973
- );
1974
- }
1963
+ console.info(
1964
+ `[cadet-hatch-timing] ${JSON.stringify({
1965
+ containers_up_to_ready_ms: containersUpToReadyMs,
1966
+ instance: instanceName,
1967
+ })}`,
1968
+ );
1975
1969
 
1976
1970
  // Lease guardian token. The /readyz check confirms both gateway and
1977
1971
  // assistant are reachable. Retry with backoff in case there is a brief
@@ -31,6 +31,8 @@ export interface EnsureBinaryOptions {
31
31
  runInstall?: (argv: string[]) => boolean;
32
32
  /** Confirm the install. Default: promptLine "[Y/n]" on Enter-as-yes. */
33
33
  confirmInstall?: () => Promise<boolean>;
34
+ platform?: NodeJS.Platform;
35
+ confirmHomebrewInstall?: () => Promise<boolean>;
34
36
  streams?: {
35
37
  input?: NodeJS.ReadStream | Readable;
36
38
  output?: NodeJS.WriteStream | Writable;
@@ -69,9 +71,60 @@ export async function ensureBinaryInstalled(
69
71
  }
70
72
 
71
73
  const pmAvailable = opts.pmAvailable ?? probePackageManager;
72
- const argv = Object.entries(opts.packages).find(([pm]) =>
73
- pmAvailable(pm),
74
- )?.[1];
74
+ let argv = Object.entries(opts.packages).find(([pm]) => pmAvailable(pm))?.[1];
75
+ if (
76
+ !argv &&
77
+ (opts.platform ?? process.platform) === "darwin" &&
78
+ opts.packages.brew
79
+ ) {
80
+ const brew = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"].find(
81
+ pmAvailable,
82
+ );
83
+ if (brew) {
84
+ const prefix = brew.slice(0, -"/bin/brew".length);
85
+ process.env.PATH = `${prefix}/bin:${prefix}/sbin:${process.env.PATH ?? ""}`;
86
+ argv = [brew, ...opts.packages.brew.slice(1)];
87
+ }
88
+ }
89
+ if (
90
+ !argv &&
91
+ (opts.platform ?? process.platform) === "darwin" &&
92
+ opts.packages.brew
93
+ ) {
94
+ output.write(
95
+ "Homebrew is required to install this dependency, but it isn't installed.\n",
96
+ );
97
+ const confirmHomebrew =
98
+ opts.confirmHomebrewInstall ?? defaultConfirm(opts, "Homebrew");
99
+ let approved = false;
100
+ try {
101
+ approved = await confirmHomebrew();
102
+ } catch {
103
+ /* No interactive approval. */
104
+ }
105
+ if (approved) {
106
+ const bootstrap = [
107
+ "/bin/bash",
108
+ "-c",
109
+ '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/7a3f48c7e498d7df3237fd41c30f4d492857edf4/install.sh)"',
110
+ ];
111
+ const installed = opts.runInstall
112
+ ? opts.runInstall(bootstrap)
113
+ : runInstallArgv(bootstrap);
114
+ const brew = installed
115
+ ? ["brew", "/opt/homebrew/bin/brew", "/usr/local/bin/brew"].find(
116
+ pmAvailable,
117
+ )
118
+ : undefined;
119
+ if (brew) {
120
+ const prefix =
121
+ brew === "brew" ? "" : brew.slice(0, -"/bin/brew".length);
122
+ if (prefix)
123
+ process.env.PATH = `${prefix}/bin:${prefix}/sbin:${process.env.PATH ?? ""}`;
124
+ argv = [brew, ...opts.packages.brew.slice(1)];
125
+ }
126
+ }
127
+ }
75
128
  if (!argv) {
76
129
  output.write(`⚠ No supported package manager found. ${opts.manualHint}\n`);
77
130
  return null;
@@ -90,7 +143,10 @@ export async function ensureBinaryInstalled(
90
143
  }
91
144
 
92
145
  /** Default confirm: visible prompt, Enter = yes, non-TTY throws. */
93
- function defaultConfirm(opts: EnsureBinaryOptions): () => Promise<boolean> {
146
+ function defaultConfirm(
147
+ opts: EnsureBinaryOptions,
148
+ binary = opts.binary,
149
+ ): () => Promise<boolean> {
94
150
  return async () => {
95
151
  const input = opts.streams?.input ?? process.stdin;
96
152
  const output = opts.streams?.output ?? process.stdout;
@@ -99,7 +155,7 @@ function defaultConfirm(opts: EnsureBinaryOptions): () => Promise<boolean> {
99
155
  throw new Error("Install confirm requires an interactive terminal.");
100
156
  }
101
157
  const answer = (
102
- await readLineText(`Install ${opts.binary} now? [Y/n] `, {
158
+ await readLineText(`Install ${binary} now? [Y/n] `, {
103
159
  input,
104
160
  output,
105
161
  })
@@ -114,7 +170,10 @@ function defaultConfirm(opts: EnsureBinaryOptions): () => Promise<boolean> {
114
170
  function runInstallArgv(argv: string[]): boolean {
115
171
  const [command, ...args] = argv;
116
172
  if (!command) return false;
117
- const result = spawnSync(command, args, { stdio: "inherit" });
173
+ const result = spawnSync(command, args, {
174
+ stdio: "inherit",
175
+ windowsHide: true,
176
+ });
118
177
  return !result.error && result.status === 0;
119
178
  }
120
179