@mycadet/cli 0.12.8 → 0.12.9

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 (53) hide show
  1. package/node_modules/@mycadet/assistant-api/events/show-cloud-login.ts +2 -2
  2. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/package.json +1 -1
  3. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/package.json +1 -1
  4. package/node_modules/@mycadet/service-contracts/package.json +1 -1
  5. package/package.json +2 -2
  6. package/src/__tests__/cloud-releases.test.ts +23 -13
  7. package/src/__tests__/ensure-binary.test.ts +182 -0
  8. package/src/__tests__/nginx-ingress.test.ts +3 -2
  9. package/src/__tests__/ngrok-spawn.test.ts +49 -0
  10. package/src/__tests__/ps-table.test.ts +79 -0
  11. package/src/__tests__/tailscale.test.ts +1 -1
  12. package/src/__tests__/teleport.test.ts +1 -1
  13. package/src/__tests__/tunnel.test.ts +21 -14
  14. package/src/__tests__/uninstall.test.ts +1 -1
  15. package/src/__tests__/wake.test.ts +2 -2
  16. package/src/commands/backup.ts +3 -3
  17. package/src/commands/completions.test.ts +104 -0
  18. package/src/commands/completions.ts +87 -0
  19. package/src/commands/exec.ts +7 -7
  20. package/src/commands/flags.ts +2 -2
  21. package/src/commands/hatch.ts +1 -1
  22. package/src/commands/logs.ts +7 -7
  23. package/src/commands/ps.ts +57 -8
  24. package/src/commands/restore.ts +2 -2
  25. package/src/commands/retire.ts +2 -2
  26. package/src/commands/rollback.ts +2 -2
  27. package/src/commands/sleep.ts +2 -2
  28. package/src/commands/teleport.ts +16 -16
  29. package/src/commands/terminal.ts +1 -1
  30. package/src/commands/tunnel.ts +78 -55
  31. package/src/commands/uninstall.ts +4 -4
  32. package/src/commands/upgrade.ts +8 -8
  33. package/src/commands/use.ts +1 -1
  34. package/src/commands/wake.ts +6 -6
  35. package/src/index.ts +15 -0
  36. package/src/lib/__tests__/docker.test.ts +168 -20
  37. package/src/lib/__tests__/job-polling.test.ts +1 -1
  38. package/src/lib/cloud-releases.ts +6 -4
  39. package/src/lib/docker.ts +150 -44
  40. package/src/lib/ensure-binary.ts +171 -0
  41. package/src/lib/flag-args.test.ts +1 -1
  42. package/src/lib/flag-args.ts +1 -1
  43. package/src/lib/guardian-token.ts +1 -1
  44. package/src/lib/hatch-local.ts +1 -1
  45. package/src/lib/job-polling.ts +1 -1
  46. package/src/lib/local.ts +1 -1
  47. package/src/lib/nginx-ingress.ts +2 -2
  48. package/src/lib/pair.ts +1 -1
  49. package/src/lib/sync-cloud-assistants.ts +2 -2
  50. package/src/lib/tunnels/cloudflare.ts +21 -11
  51. package/src/lib/tunnels/ngrok.ts +198 -108
  52. package/src/lib/tunnels/tailscale.ts +34 -4
  53. package/src/lib/upgrade-lifecycle.ts +5 -4
@@ -85,7 +85,9 @@ export async function fetchLatestStableVersion(): Promise<string | null> {
85
85
  }
86
86
 
87
87
  /**
88
- * Image refs for a target version: the tag is the target version, the repo
88
+ * Image refs for a target version: the tag is the target version exactly as
89
+ * the release workflows push it — bare ("0.12.8", "0.12.8-dev…"); a
90
+ * git-tag-style "v" prefix on the input is tolerated and stripped. The repo
89
91
  * follows the CLI's own version shape (dev CLI → `-dev` repos).
90
92
  */
91
93
  export function resolveImageRefs(
@@ -93,8 +95,8 @@ export function resolveImageRefs(
93
95
  cliVersion: string,
94
96
  ): Record<ServiceName, string> {
95
97
  const versionTag = targetVersion.startsWith("v")
96
- ? targetVersion
97
- : `v${targetVersion}`;
98
+ ? targetVersion.slice(1)
99
+ : targetVersion;
98
100
  const images = dockerhubImages(cliVersion);
99
101
  return {
100
102
  assistant: `${images.assistant}:${versionTag}`,
@@ -107,5 +109,5 @@ export function resolveImageRefs(
107
109
  export function resolveOwnImageRefs(
108
110
  cliVersion: string,
109
111
  ): Record<ServiceName, string> {
110
- return resolveImageRefs(cliVersion ? `v${cliVersion}` : "latest", cliVersion);
112
+ return resolveImageRefs(cliVersion || "latest", cliVersion);
111
113
  }
package/src/lib/docker.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  import { resolveOwnImageRefs } from "./cloud-releases.js";
41
41
  import { isDevVersion } from "./version-compat.js";
42
42
  import {
43
+ collectProviderApiKey,
43
44
  configureHatchProviderApiKey,
44
45
  formatProviderName,
45
46
  promptProviderChoice,
@@ -340,14 +341,96 @@ export function resolveDockerProviderCredentialSetupAction(options: {
340
341
  provider: string | null | undefined;
341
342
  guardianAccessToken?: string;
342
343
  detached: boolean;
344
+ /**
345
+ * Key — or an explicit skip ("") — collected from the user right after
346
+ * they picked the provider. When present (even empty), the user already
347
+ * answered at the prompt, so detached hatches configure (or report the
348
+ * missing key) instead of deferring to a second `cadet setup` run.
349
+ */
350
+ precollectedKey?: string;
343
351
  }): DockerProviderCredentialSetupAction {
344
352
  if (options.provider === undefined) return "skip";
345
353
  if (options.provider === null) return options.detached ? "skip" : "configure";
346
- if (options.detached) return "defer";
354
+ if (options.detached) {
355
+ if (options.precollectedKey !== undefined) {
356
+ return options.guardianAccessToken ? "configure" : "missing-token";
357
+ }
358
+ return "defer";
359
+ }
347
360
  if (!options.guardianAccessToken) return "missing-token";
348
361
  return "configure";
349
362
  }
350
363
 
364
+ /** Time to wait for the Docker daemon to answer `docker info` after a Colima start. */
365
+ const COLIMA_SETTLE_TIMEOUT_MS = 15_000;
366
+
367
+ /** Poll `docker info` until the daemon answers or the timeout elapses. */
368
+ async function waitForDockerDaemon(
369
+ execFn: typeof exec,
370
+ timeoutMs: number,
371
+ ): Promise<boolean> {
372
+ const deadline = Date.now() + timeoutMs;
373
+ for (;;) {
374
+ try {
375
+ await execFn("docker", ["info"]);
376
+ return true;
377
+ } catch {
378
+ if (Date.now() >= deadline) return false;
379
+ await new Promise((resolve) => setTimeout(resolve, 1000));
380
+ }
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Start Colima and wait for the Docker daemon to actually answer.
386
+ *
387
+ * `colima start` exiting 0 only means Colima's own state file says the VM is
388
+ * up — that state can be stale (zombie VM after a host sleep or crash), in
389
+ * which case start is a no-op ("already running, ignoring") while the daemon
390
+ * inside stays dead. So success is decided by re-probing `docker info`, and a
391
+ * failed probe triggers the manual-fix cycle: force-stop and start fresh.
392
+ * Never `colima delete` — the VM disk holds every container image and volume.
393
+ * `startArgs` carries extra flags (e.g. `--memory`) for a first start; the
394
+ * wake path starts an existing instance bare.
395
+ */
396
+ async function startColimaAndAwaitDaemon(
397
+ execFn: typeof exec,
398
+ log: (msg: string) => void,
399
+ opts: { startArgs?: string[]; settleTimeoutMs?: number } = {},
400
+ ): Promise<void> {
401
+ const startArgs = opts.startArgs ?? [];
402
+ const settleTimeoutMs = opts.settleTimeoutMs ?? COLIMA_SETTLE_TIMEOUT_MS;
403
+
404
+ try {
405
+ await execFn("colima", ["start", ...startArgs]);
406
+ } catch (err) {
407
+ // exec captures the child's output and only surfaces it in the error —
408
+ // print it so a failed start is never silent.
409
+ log(`⚠️ colima start failed: ${err instanceof Error ? err.message : err}`);
410
+ }
411
+
412
+ if (await waitForDockerDaemon(execFn, settleTimeoutMs)) return;
413
+
414
+ log(
415
+ "🔄 Colima state looks stale — restarting the VM (colima stop --force, then start)...",
416
+ );
417
+ await execFn("colima", ["stop", "--force"]).catch(() => {});
418
+ try {
419
+ await execFn("colima", ["start", ...startArgs]);
420
+ } catch (err) {
421
+ const message = err instanceof Error ? err.message : String(err);
422
+ throw new Error(
423
+ `Failed to restart Colima. Please run 'colima stop --force && colima start' manually.\n${message}`,
424
+ );
425
+ }
426
+ if (!(await waitForDockerDaemon(execFn, settleTimeoutMs))) {
427
+ throw new Error(
428
+ "Docker daemon is still not reachable after restarting Colima. " +
429
+ "Check `colima status` and `docker info`, then try again.",
430
+ );
431
+ }
432
+ }
433
+
351
434
  /**
352
435
  * Checks whether the `docker` CLI and daemon are available on the system.
353
436
  * Installs Colima and Docker via direct binary download if missing (no sudo
@@ -418,41 +501,9 @@ async function ensureDockerInstalled(): Promise<void> {
418
501
  }
419
502
 
420
503
  console.log("🚀 Docker daemon not running. Starting Colima...");
421
- try {
422
- await exec("colima", [
423
- "start",
424
- "--memory",
425
- String(COLIMA_DEFAULT_MEMORY_GIB),
426
- ]);
427
- } catch {
428
- // Colima may fail if a previous VM instance is in a corrupt state.
429
- // Attempt to delete the stale instance and retry once.
430
- console.log(
431
- "⚠️ Colima start failed — attempting to reset stale VM state...",
432
- );
433
- try {
434
- await exec("colima", ["stop", "--force"]).catch(() => {});
435
- await exec("colima", ["delete", "--force"]);
436
- } catch {
437
- // If delete also fails, fall through to the retry which will
438
- // produce a clear error message.
439
- }
440
-
441
- try {
442
- console.log("🔄 Retrying colima start...");
443
- await exec("colima", [
444
- "start",
445
- "--memory",
446
- String(COLIMA_DEFAULT_MEMORY_GIB),
447
- ]);
448
- } catch (retryErr) {
449
- const message =
450
- retryErr instanceof Error ? retryErr.message : String(retryErr);
451
- throw new Error(
452
- `Failed to start Colima after resetting stale VM state. Please run 'colima start' manually.\n${message}`,
453
- );
454
- }
455
- }
504
+ await startColimaAndAwaitDaemon(exec, console.log, {
505
+ startArgs: ["--memory", String(COLIMA_DEFAULT_MEMORY_GIB)],
506
+ });
456
507
  }
457
508
  }
458
509
 
@@ -486,6 +537,56 @@ export async function removeContainer(containerName: string): Promise<void> {
486
537
  }
487
538
  }
488
539
 
540
+ /**
541
+ * Error signatures Docker Hub returns when the host has STALE Docker Hub
542
+ * credentials: an expired personal access token saved by a past
543
+ * `docker login` gets attached to the pull, and Hub rejects the request
544
+ * instead of falling back to anonymous access for a public image.
545
+ */
546
+ export function isExpiredDockerHubCredentialError(err: unknown): boolean {
547
+ const message = err instanceof Error ? err.message : String(err);
548
+ return /personal access token is expired|authentication required|unauthorized/i.test(
549
+ message,
550
+ );
551
+ }
552
+
553
+ /**
554
+ * Pull an image, retrying once without stale credentials when Docker Hub
555
+ * rejects the pull with an expired-token/auth error — public pulls need no
556
+ * login, so `docker logout` (clears the stored Docker Hub credentials the
557
+ * docker CLI attaches to pulls) costs nothing. Other failures propagate raw;
558
+ * a still-failing auth retry is rethrown with actionable copy.
559
+ */
560
+ export async function pullImage(
561
+ ref: string,
562
+ log: (msg: string) => void = console.log,
563
+ execFn: typeof exec = exec,
564
+ ): Promise<void> {
565
+ try {
566
+ await execFn("docker", ["pull", ref]);
567
+ return;
568
+ } catch (err) {
569
+ if (!isExpiredDockerHubCredentialError(err)) throw err;
570
+ log(
571
+ `⚠️ Docker Hub rejected the pull of ${ref} with an expired-credential ` +
572
+ `error — clearing stale Docker Hub login and retrying (public images ` +
573
+ `need no credentials)...`,
574
+ );
575
+ await execFn("docker", ["logout"]).catch(() => {});
576
+ }
577
+ try {
578
+ await execFn("docker", ["pull", ref]);
579
+ } catch (err) {
580
+ const message = err instanceof Error ? err.message : String(err);
581
+ throw new Error(
582
+ `Failed to pull ${ref}.\n${message}\n` +
583
+ `If the Docker daemon is not running, start it, e.g. 'colima start' on macOS. ` +
584
+ `If the error mentions authentication, run 'docker logout' and check your ` +
585
+ `Docker Hub credentials — public image pulls need none.`,
586
+ );
587
+ }
588
+ }
589
+
489
590
  export async function retireDocker(name: string): Promise<void> {
490
591
  console.log(`\u{1F5D1}\ufe0f Stopping Docker containers for '${name}'...\n`);
491
592
 
@@ -846,7 +947,7 @@ export async function recreateDockerContainers(entry: AssistantEntry): Promise<v
846
947
  // already-present image is a cheap no-op.
847
948
  for (const image of [info.assistantImage, info.gatewayImage, info.cesImage]) {
848
949
  log(`📦 Pulling ${image}...`);
849
- await exec("docker", ["pull", image]);
950
+ await pullImage(image, log);
850
951
  }
851
952
 
852
953
  const gatewayPort = parseGatewayPort(entry.runtimeUrl) ??
@@ -912,6 +1013,7 @@ function parseGatewayPort(runtimeUrl: string): number | null {
912
1013
  */
913
1014
  export async function ensureDockerDaemonRunning(
914
1015
  execFn: typeof exec = exec,
1016
+ opts: { settleTimeoutMs?: number } = {},
915
1017
  ): Promise<void> {
916
1018
  ensureLocalBinOnPath();
917
1019
 
@@ -924,12 +1026,10 @@ export async function ensureDockerDaemonRunning(
924
1026
  // No daemon reachable — fall through to the Colima fallback below.
925
1027
  }
926
1028
 
927
- try {
928
- await execFn("colima", ["status"]);
929
- } catch {
930
- console.log("🚀 Docker daemon not running. Starting Colima...");
931
- await execFn("colima", ["start"]);
932
- }
1029
+ // Deliberately no `colima status` probe: it trusts Colima's own state file,
1030
+ // which can be stale (zombie VM) — daemon reachability is the oracle.
1031
+ console.log("🚀 Docker daemon not running. Starting Colima...");
1032
+ await startColimaAndAwaitDaemon(execFn, console.log, opts);
933
1033
  }
934
1034
 
935
1035
  /**
@@ -1234,6 +1334,7 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1234
1334
  let provider = setupProviderCredentials
1235
1335
  ? resolveHatchProvider(configValues)
1236
1336
  : undefined;
1337
+ let precollectedApiKey: string | undefined;
1237
1338
 
1238
1339
  if (
1239
1340
  shouldPromptForHatchProvider({
@@ -1246,6 +1347,9 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1246
1347
  const picked = await promptProviderChoice();
1247
1348
  if (picked) {
1248
1349
  provider = picked;
1350
+ // Collect the key immediately, while the choice is fresh — the
1351
+ // startup logs are quiet now, so nothing separates the two prompts.
1352
+ precollectedApiKey = await collectProviderApiKey(picked);
1249
1353
  }
1250
1354
  }
1251
1355
 
@@ -1427,7 +1531,7 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1427
1531
  const MAX_PULL_RETRIES = 3;
1428
1532
  for (let attempt = 1; attempt <= MAX_PULL_RETRIES; attempt++) {
1429
1533
  try {
1430
- await exec("docker", ["pull", ref]);
1534
+ await pullImage(ref, log);
1431
1535
  break;
1432
1536
  } catch (err) {
1433
1537
  if (attempt === MAX_PULL_RETRIES) throw err;
@@ -1631,6 +1735,7 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1631
1735
  provider,
1632
1736
  guardianAccessToken,
1633
1737
  detached: waitDetached,
1738
+ precollectedKey: precollectedApiKey,
1634
1739
  });
1635
1740
 
1636
1741
  if (providerSetupAction === "defer" && provider !== null) {
@@ -1657,6 +1762,7 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1657
1762
  provider,
1658
1763
  bearerToken: guardianAccessToken,
1659
1764
  env: process.env,
1765
+ precollectedKey: precollectedApiKey,
1660
1766
  log,
1661
1767
  });
1662
1768
  }
@@ -0,0 +1,171 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import type { Readable, Writable } from "node:stream";
3
+
4
+ /**
5
+ * Consolidated missing-dependency flow for interactive commands: probe for a
6
+ * binary, and when it is absent, offer to install it — naming no commands —
7
+ * then run the right package manager for the platform and verify. Callers
8
+ * supply the recipes; everything else (prompt, package-manager discovery,
9
+ * fallback copy) lives here so every command behaves the same.
10
+ */
11
+
12
+ export interface EnsureBinaryOptions {
13
+ /** The binary as invoked on PATH, e.g. "nginx". */
14
+ binary: string;
15
+ /** What the running command needs it for, e.g. "the tunnel edge". */
16
+ purpose: string;
17
+ /**
18
+ * Package-manager name → full install argv (sudo included where needed),
19
+ * e.g. `{ brew: ["brew", "install", "nginx"] }`. The first entry whose
20
+ * package manager is available wins. Leave out a platform rather than
21
+ * guessing at an unreliable package ID — the manual hint covers it.
22
+ */
23
+ packages: Record<string, string[]>;
24
+ /** Shown when the user declines or auto-install fails. */
25
+ manualHint: string;
26
+ /** Probe: a version-ish string when the binary is usable, null otherwise. */
27
+ probe: () => string | null;
28
+ /** Check that a package manager exists. Default: `<pm> --version` probe. */
29
+ pmAvailable?: (pm: string) => boolean;
30
+ /** Run an install argv; true on success. Default: inherit-stdio spawnSync. */
31
+ runInstall?: (argv: string[]) => boolean;
32
+ /** Confirm the install. Default: promptLine "[Y/n]" on Enter-as-yes. */
33
+ confirmInstall?: () => Promise<boolean>;
34
+ streams?: {
35
+ input?: NodeJS.ReadStream | Readable;
36
+ output?: NodeJS.WriteStream | Writable;
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Prompt-then-install a missing binary. Returns the probe result — the
42
+ * existing version when the binary was already present, the fresh one after a
43
+ * successful install — or null when the binary is missing and the user
44
+ * declines, no package manager matches, or the install fails. Non-interactive
45
+ * callers (no TTY) never hang: they get the manual hint and null.
46
+ */
47
+ export async function ensureBinaryInstalled(
48
+ opts: EnsureBinaryOptions,
49
+ ): Promise<string | null> {
50
+ const output = opts.streams?.output ?? process.stdout;
51
+
52
+ const existing = opts.probe();
53
+ if (existing) return existing;
54
+
55
+ output.write(
56
+ `\n${opts.binary} is required for ${opts.purpose}, but it isn't installed.\n`,
57
+ );
58
+
59
+ const confirm = opts.confirmInstall ?? defaultConfirm(opts);
60
+ let install = false;
61
+ try {
62
+ install = await confirm();
63
+ } catch {
64
+ install = false;
65
+ }
66
+ if (!install) {
67
+ output.write(`Skipping install. ${opts.manualHint}\n`);
68
+ return null;
69
+ }
70
+
71
+ const pmAvailable = opts.pmAvailable ?? probePackageManager;
72
+ const argv = Object.entries(opts.packages).find(([pm]) =>
73
+ pmAvailable(pm),
74
+ )?.[1];
75
+ if (!argv) {
76
+ output.write(`⚠ No supported package manager found. ${opts.manualHint}\n`);
77
+ return null;
78
+ }
79
+
80
+ const ok = opts.runInstall ? opts.runInstall(argv) : runInstallArgv(argv);
81
+ const version = ok ? opts.probe() : null;
82
+ if (!version) {
83
+ output.write(
84
+ `⚠ Couldn't install ${opts.binary} automatically. ${opts.manualHint}\n`,
85
+ );
86
+ return null;
87
+ }
88
+ output.write(`✅ Installed ${opts.binary} (${version}).\n`);
89
+ return version;
90
+ }
91
+
92
+ /** Default confirm: visible prompt, Enter = yes, non-TTY throws. */
93
+ function defaultConfirm(opts: EnsureBinaryOptions): () => Promise<boolean> {
94
+ return async () => {
95
+ const input = opts.streams?.input ?? process.stdin;
96
+ const output = opts.streams?.output ?? process.stdout;
97
+ // Readable has no isTTY; only a real terminal answers the prompt.
98
+ if (!("isTTY" in input) || !input.isTTY) {
99
+ throw new Error("Install confirm requires an interactive terminal.");
100
+ }
101
+ const answer = (
102
+ await readLineText(`Install ${opts.binary} now? [Y/n] `, {
103
+ input,
104
+ output,
105
+ })
106
+ )
107
+ .trim()
108
+ .toLowerCase();
109
+ return answer === "" || answer === "y" || answer === "yes";
110
+ };
111
+ }
112
+
113
+ /** Default installer: blocking spawn with inherited stdio (passwords flow). */
114
+ function runInstallArgv(argv: string[]): boolean {
115
+ const [command, ...args] = argv;
116
+ if (!command) return false;
117
+ const result = spawnSync(command, args, { stdio: "inherit" });
118
+ return !result.error && result.status === 0;
119
+ }
120
+
121
+ /** Default package-manager check: the manager answers `--version`. */
122
+ function probePackageManager(pm: string): boolean {
123
+ const result = spawnSync(pm, ["--version"], {
124
+ stdio: "ignore",
125
+ shell: process.platform === "win32",
126
+ windowsHide: true,
127
+ });
128
+ return !result.error && result.status === 0;
129
+ }
130
+
131
+ /**
132
+ * Read one visible line from stdin (Enter submits). Shared by the binary
133
+ * install confirm and the ngrok authtoken paste-in, so both degrade the same
134
+ * way on a non-interactive terminal (the caller decides what empty means).
135
+ */
136
+ export async function readLineText(
137
+ prompt: string,
138
+ streams: { input?: Readable; output?: Writable } = {},
139
+ ): Promise<string> {
140
+ const input = streams.input ?? process.stdin;
141
+ const output = streams.output ?? process.stdout;
142
+
143
+ output.write(prompt);
144
+
145
+ return new Promise((resolve, reject) => {
146
+ let line = "";
147
+ const onData = (chunk: Buffer | string): void => {
148
+ line += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
149
+ const newline = line.indexOf("\n");
150
+ if (newline !== -1) {
151
+ done(line.slice(0, newline).replace(/\r$/, ""));
152
+ }
153
+ };
154
+ const cleanup = (): void => {
155
+ input.removeListener("data", onData);
156
+ input.removeListener("error", onError);
157
+ input.pause();
158
+ };
159
+ const done = (value: string): void => {
160
+ cleanup();
161
+ resolve(value);
162
+ };
163
+ const onError = (err: Error): void => {
164
+ cleanup();
165
+ reject(err);
166
+ };
167
+ input.resume();
168
+ input.on("data", onData);
169
+ input.on("error", onError);
170
+ });
171
+ }
@@ -75,7 +75,7 @@ describe("parseFeatureFlagArgs", () => {
75
75
  "process.exit",
76
76
  );
77
77
  expect(errorSpy).toHaveBeenCalledWith(
78
- 'Error: invalid flag key "UPPER". Keys must be kebab-case (e.g. "browser")',
78
+ 'Error: invalid flag key "UPPER". Keys must be kebab-case, e.g. "browser"',
79
79
  );
80
80
 
81
81
  exitSpy.mockRestore();
@@ -38,7 +38,7 @@ export function parseFeatureFlagArgs(args: string[]): {
38
38
 
39
39
  if (!ALLOWED_KEY_RE.test(key)) {
40
40
  console.error(
41
- `Error: invalid flag key "${key}". Keys must be kebab-case (e.g. "browser")`,
41
+ `Error: invalid flag key "${key}". Keys must be kebab-case, e.g. "browser"`,
42
42
  );
43
43
  process.exit(1);
44
44
  }
@@ -429,7 +429,7 @@ export async function refreshGuardianTokenResult(
429
429
  console.warn(
430
430
  `Refusing to refresh the guardian token over an insecure URL (${gatewayUrl}). ` +
431
431
  "The refresh token is only sent over https or a loopback address: " +
432
- "use an https URL (e.g. a tunnel) or connect over loopback.",
432
+ "use an https URL, e.g. a tunnel, or connect over loopback.",
433
433
  );
434
434
  return {
435
435
  ok: false,
@@ -384,7 +384,7 @@ export async function hatchLocal(
384
384
  await new Promise((r) => setTimeout(r, delayMs));
385
385
  } else {
386
386
  reporter.error(
387
- `⚠️ Guardian token lease failed${err instanceof GuardianLeasePermanentError ? " (not retrying — a malformed 200 will not fix itself)" : ` after ${maxLeaseAttempts} attempts`}: ${err}\n` +
387
+ `⚠️ Guardian token lease failed${err instanceof GuardianLeasePermanentError ? ", not retrying — a malformed 200 will not fix itself" : ` after ${maxLeaseAttempts} attempts`}: ${err}\n` +
388
388
  ` The assistant is running but guardian-token.json was not written.\n` +
389
389
  ` Run \`cadet restart ${JSON.stringify(instanceName)} --repair-guardian\` to re-lease a token.`,
390
390
  );
@@ -166,7 +166,7 @@ export async function pollJobUntilDone(
166
166
  throw err;
167
167
  }
168
168
  const msg = err instanceof Error ? err.message : String(err);
169
- console.warn(`${options.label} polling failed, retrying... (${msg})`);
169
+ console.warn(`${options.label} polling failed: ${msg}. Retrying...`);
170
170
  if (Date.now() >= deadline) {
171
171
  throw new Error(
172
172
  `Timed out waiting for ${options.label} after ${Math.round(
package/src/lib/local.ts CHANGED
@@ -937,7 +937,7 @@ async function startDaemonWatchFromSource(
937
937
  }
938
938
  writeFileSync(pidFile, String(daemonPid), "utf-8");
939
939
 
940
- console.log(" Assistant started in watch mode (bun --watch)");
940
+ console.log(" Assistant started in watch mode with bun --watch");
941
941
  return trackDaemonSpawn(child);
942
942
  }
943
943
 
@@ -1162,13 +1162,13 @@ export async function ensureTunnelEdge(opts: {
1162
1162
  case "nginx-missing":
1163
1163
  throw new Error(
1164
1164
  "nginx is not installed, so the tunnel edge cannot start. " +
1165
- "Install it (macOS: `brew install nginx`, Linux: `sudo apt install nginx`) " +
1165
+ "See https://nginx.org/en/docs/install.html for how to install, " +
1166
1166
  "or point NGINX_BIN at an existing binary.",
1167
1167
  );
1168
1168
  case "web-dist-missing":
1169
1169
  throw new Error(
1170
1170
  "Unable to locate built web assets for the remote web edge. " +
1171
- "Build the SPA (`cd clients/web && VITE_CLOUD_MODE=false bun run build`) " +
1171
+ "Build the SPA with `cd clients/web && VITE_CLOUD_MODE=false bun run build`, " +
1172
1172
  "or install @mycadet/web so its packaged dist directory is available.",
1173
1173
  );
1174
1174
  case "unreachable":
package/src/lib/pair.ts CHANGED
@@ -50,6 +50,6 @@ export function formatWebApproveFailure(
50
50
  return [
51
51
  `${rejection} ${gatewayUrl} (assistant "${assistantReference}", environment "${envName}").`,
52
52
  `Codes are minted by the gateway that serves the pair page, expire after ${CODE_TTL_MINUTES} minutes, and are single-use.`,
53
- "If the pair page came from a different assistant or environment (e.g. the desktop app's), re-run with that environment's CADET_ENVIRONMENT and that assistant's name.",
53
+ "If the pair page came from a different assistant or environment, such as the desktop app's, re-run with that environment's CADET_ENVIRONMENT and that assistant's name.",
54
54
  ].join("\n");
55
55
  }
@@ -88,7 +88,7 @@ export async function syncCloudAssistants(
88
88
  log?.("Fetching Cadet Cloud assistants…");
89
89
  cloudAssistants = await fetchCloudAssistants(token);
90
90
  log?.(
91
- `Cadet Cloud returned ${cloudAssistants.length} assistant(s): ${cloudAssistants.map((a) => a.name || a.id).join(", ") || "(none)"}`,
91
+ `Cadet Cloud returned ${cloudAssistants.length} ${cloudAssistants.length === 1 ? "assistant" : "assistants"}: ${cloudAssistants.map((a) => a.name || a.id).join(", ") || "(none)"}`,
92
92
  );
93
93
  } catch (err) {
94
94
  const msg = err instanceof Error ? err.message : String(err);
@@ -118,7 +118,7 @@ export async function syncCloudAssistants(
118
118
  );
119
119
  const existingCloudIds = new Set(existingCloudById.keys());
120
120
  log?.(
121
- `Lockfile has ${existingCloudIds.size} cloud assistant(s): ${[...existingCloudIds].join(", ") || "(none)"}`,
121
+ `Lockfile has ${existingCloudIds.size} ${existingCloudIds.size === 1 ? "cloud assistant" : "cloud assistants"}: ${[...existingCloudIds].join(", ") || "(none)"}`,
122
122
  );
123
123
 
124
124
  let added = 0;
@@ -2,6 +2,8 @@ import type { ProviderMeta, ProviderTunnelResult } from "../tunnel.js";
2
2
 
3
3
  import { execFileSync, spawn, type ChildProcess } from "node:child_process";
4
4
 
5
+ import { ensureBinaryInstalled } from "../ensure-binary.js";
6
+
5
7
  import { GATEWAY_PORT } from "../constants.js";
6
8
  import {
7
9
  clearIngressUrl,
@@ -138,18 +140,26 @@ export interface RunCloudflareTunnelOptions {
138
140
  export async function runCloudflareTunnel(
139
141
  opts: RunCloudflareTunnelOptions = {},
140
142
  ): Promise<ProviderTunnelResult> {
141
- const version = getCloudflareTunnelVersion();
143
+ const version = await ensureBinaryInstalled({
144
+ binary: "cloudflared",
145
+ purpose: "the cloudflare tunnel",
146
+ packages: {
147
+ brew: ["brew", "install", "cloudflare/cloudflare/cloudflared"],
148
+ winget: [
149
+ "winget",
150
+ "install",
151
+ "--silent",
152
+ "--accept-package-agreements",
153
+ "--accept-source-agreements",
154
+ "Cloudflare.cloudflared",
155
+ ],
156
+ choco: ["choco", "install", "-y", "cloudflared"],
157
+ },
158
+ manualHint:
159
+ "See https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ for how to install",
160
+ probe: getCloudflareTunnelVersion,
161
+ });
142
162
  if (!version) {
143
- console.error("Error: cloudflared is not installed.");
144
- console.error("");
145
- console.error("Install cloudflared:");
146
- console.error(" macOS: brew install cloudflare/cloudflare/cloudflared");
147
- console.error(" Linux: https://pkg.cloudflare.com/index.html");
148
- console.error(
149
- " Windows: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
150
- );
151
- console.error("");
152
- console.error("No Cloudflare account is required for quick tunnels.");
153
163
  process.exit(1);
154
164
  }
155
165