@mycadet/cli 0.12.8 → 0.12.9-dev.202610131452.9acab64

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 (67) 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 +4 -3
  6. package/src/__tests__/client-web-config.test.ts +0 -1
  7. package/src/__tests__/cloud-releases.test.ts +23 -13
  8. package/src/__tests__/ensure-binary.test.ts +182 -0
  9. package/src/__tests__/nginx-ingress.test.ts +6 -11
  10. package/src/__tests__/ngrok-spawn.test.ts +49 -0
  11. package/src/__tests__/pair.test.ts +149 -78
  12. package/src/__tests__/ps-cloud-status.test.ts +4 -3
  13. package/src/__tests__/ps-table.test.ts +79 -0
  14. package/src/__tests__/tailscale.test.ts +1 -1
  15. package/src/__tests__/teleport.test.ts +1 -16
  16. package/src/__tests__/tunnel.test.ts +274 -82
  17. package/src/__tests__/uninstall.test.ts +141 -9
  18. package/src/__tests__/wake.test.ts +2 -2
  19. package/src/commands/backup.ts +3 -3
  20. package/src/commands/client.ts +4 -6
  21. package/src/commands/completions.test.ts +104 -0
  22. package/src/commands/completions.ts +87 -0
  23. package/src/commands/exec.ts +7 -7
  24. package/src/commands/flags.ts +2 -2
  25. package/src/commands/hatch.ts +5 -2
  26. package/src/commands/logs.ts +7 -7
  27. package/src/commands/pair.ts +140 -37
  28. package/src/commands/ps.ts +63 -26
  29. package/src/commands/restore.ts +2 -2
  30. package/src/commands/retire.ts +2 -2
  31. package/src/commands/rollback.ts +2 -2
  32. package/src/commands/sleep.ts +2 -2
  33. package/src/commands/teleport.ts +18 -37
  34. package/src/commands/terminal.ts +1 -1
  35. package/src/commands/tunnel.ts +101 -78
  36. package/src/commands/uninstall.ts +108 -73
  37. package/src/commands/upgrade.ts +8 -8
  38. package/src/commands/use.ts +1 -1
  39. package/src/commands/wake.ts +6 -6
  40. package/src/index.ts +15 -0
  41. package/src/lib/__tests__/docker.test.ts +168 -20
  42. package/src/lib/__tests__/job-polling.test.ts +1 -1
  43. package/src/lib/__tests__/local-gateway-cli.test.ts +42 -0
  44. package/src/lib/cloud-client.ts +0 -3
  45. package/src/lib/cloud-login.ts +0 -1
  46. package/src/lib/cloud-releases.ts +6 -4
  47. package/src/lib/color.ts +4 -0
  48. package/src/lib/docker.ts +190 -102
  49. package/src/lib/ensure-binary.ts +171 -0
  50. package/src/lib/environments/__tests__/paths.test.ts +27 -1
  51. package/src/lib/environments/paths.ts +16 -8
  52. package/src/lib/environments/resolve.ts +2 -4
  53. package/src/lib/flag-args.test.ts +1 -1
  54. package/src/lib/flag-args.ts +1 -1
  55. package/src/lib/guardian-token.ts +1 -1
  56. package/src/lib/hatch-local.ts +15 -8
  57. package/src/lib/hatch-next-steps.ts +5 -9
  58. package/src/lib/job-polling.ts +1 -1
  59. package/src/lib/local.ts +37 -4
  60. package/src/lib/nginx-ingress.ts +3 -5
  61. package/src/lib/pair.ts +1 -1
  62. package/src/lib/step-runner.ts +6 -1
  63. package/src/lib/sync-cloud-assistants.ts +2 -2
  64. package/src/lib/tunnels/cloudflare.ts +21 -11
  65. package/src/lib/tunnels/ngrok.ts +198 -108
  66. package/src/lib/tunnels/tailscale.ts +34 -4
  67. package/src/lib/upgrade-lifecycle.ts +5 -4
package/src/lib/docker.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  ASSISTANT_INTERNAL_PORT,
28
28
  getDefaultPorts,
29
29
  } from "./environments/paths.js";
30
+ import { green, yellow } from "./color.js";
30
31
  import { getCurrentEnvironment } from "./environments/resolve.js";
31
32
  import { leaseGuardianToken } from "./guardian-token";
32
33
  import { logHatchNextSteps } from "./hatch-next-steps.js";
@@ -40,8 +41,8 @@ import {
40
41
  import { resolveOwnImageRefs } from "./cloud-releases.js";
41
42
  import { isDevVersion } from "./version-compat.js";
42
43
  import {
44
+ collectProviderApiKey,
43
45
  configureHatchProviderApiKey,
44
- formatProviderName,
45
46
  promptProviderChoice,
46
47
  resolveHatchProvider,
47
48
  shouldPromptForHatchProvider,
@@ -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;
@@ -1440,7 +1544,7 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1440
1544
  }
1441
1545
  }
1442
1546
  }
1443
- log("✅ Docker images acquired");
1547
+ log(green("✅ Docker images acquired", process.stdout));
1444
1548
  }
1445
1549
 
1446
1550
  const res = dockerResourceNames(instanceName);
@@ -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) {
@@ -1640,23 +1745,22 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1640
1745
  );
1641
1746
  } else if (providerSetupAction === "missing-token" && provider !== null) {
1642
1747
  log(
1643
- `⚠️ Provider credential setup skipped because the guardian token was not leased.\n` +
1644
- ` The assistant is still hatched. Run \`cadet setup --provider ${provider}\` after fixing the connection.`,
1748
+ yellow(
1749
+ `⚠️ Provider credential setup skipped because the guardian token was not leased.\n` +
1750
+ ` The assistant is still hatched. Run \`cadet setup --provider ${provider}\` after fixing the connection.`,
1751
+ process.stdout,
1752
+ ),
1645
1753
  );
1646
1754
  } else if (
1647
1755
  providerSetupAction === "configure" &&
1648
1756
  provider !== undefined
1649
1757
  ) {
1650
- log(
1651
- provider === null
1652
- ? "Checking provider credentials..."
1653
- : `Checking ${formatProviderName(provider)} credentials...`,
1654
- );
1655
1758
  await configureHatchProviderApiKey({
1656
1759
  gatewayUrl: runtimeUrl,
1657
1760
  provider,
1658
1761
  bearerToken: guardianAccessToken,
1659
1762
  env: process.env,
1763
+ precollectedKey: precollectedApiKey,
1660
1764
  log,
1661
1765
  });
1662
1766
  }
@@ -1749,25 +1853,12 @@ async function waitForGatewayAndLease(opts: {
1749
1853
  };
1750
1854
 
1751
1855
  if (detached) {
1752
- log("\n✅ Docker assistant hatched!\n");
1753
- log("Instance details:");
1754
- printInstanceDetails(
1755
- {
1756
- name: instanceName,
1757
- runtime: runtimeUrl,
1758
- container: containerName,
1759
- },
1760
- log,
1761
- );
1762
- log("");
1763
- log(`Stop with: cadet retire ${instanceName}`);
1764
-
1765
- // Lease a guardian token even in detached mode so that `cadet ps`,
1766
- // `cadet exec`, and other CLI commands can authenticate to the
1767
- // gateway. Skip the /readyz readiness poll (the caller asked to detach
1768
- // and not block) but retry the lease itself since the gateway may need
1769
- // a moment to accept connections after the container starts.
1770
- const leaseStart = Date.now();
1856
+ // Lease a guardian token BEFORE declaring the assistant hatched so the
1857
+ // banner means `cadet ps`, `cadet exec`, and other CLI commands can
1858
+ // already authenticate. Skip the /readyz readiness poll (the caller
1859
+ // asked to detach and not block) but retry the lease silently since
1860
+ // the gateway may need a moment to accept connections after the
1861
+ // container starts; only a final failure surfaces to the user.
1771
1862
  const leaseDeadline = containersUpAt + DOCKER_READY_TIMEOUT_MS;
1772
1863
  let guardianAccessToken: string | undefined;
1773
1864
  while (Date.now() < leaseDeadline) {
@@ -1778,27 +1869,33 @@ async function waitForGatewayAndLease(opts: {
1778
1869
  bootstrapSecret,
1779
1870
  );
1780
1871
  guardianAccessToken = tokenData.accessToken;
1781
- const leaseElapsed = ((Date.now() - leaseStart) / 1000).toFixed(1);
1782
- log(
1783
- `Guardian token lease: success after ${leaseElapsed}s (principalId=${tokenData.guardianPrincipalId})`,
1784
- );
1785
1872
  break;
1786
- } catch (err) {
1787
- const elapsed = ((Date.now() - leaseStart) / 1000).toFixed(0);
1788
- const msg = err instanceof Error ? err.message : String(err);
1789
- log(
1790
- `Guardian token lease: attempt failed after ${elapsed}s (${msg.split("\n")[0]}), retrying...`,
1791
- );
1873
+ } catch {
1792
1874
  await new Promise((r) => setTimeout(r, 2000));
1793
1875
  }
1794
1876
  }
1795
1877
  if (!guardianAccessToken) {
1796
1878
  log(
1797
- `⚠️ Guardian token lease failed after ${DOCKER_READY_TIMEOUT_MS / 1000}s.\n` +
1798
- ` The assistant is running but CLI commands (cadet ps, cadet exec) will not authenticate.\n` +
1799
- ` Re-hatch or run \`cadet setup\` to recover.`,
1879
+ yellow(
1880
+ `⚠️ Guardian token lease failed after ${DOCKER_READY_TIMEOUT_MS / 1000}s.\n` +
1881
+ ` The assistant is running but CLI commands (cadet ps, cadet exec) will not authenticate.\n` +
1882
+ ` Re-hatch or run \`cadet setup\` to recover.`,
1883
+ process.stdout,
1884
+ ),
1800
1885
  );
1801
1886
  }
1887
+
1888
+ log(`\n${green("✅ Docker assistant hatched!", process.stdout)}\n`);
1889
+ log("Instance details:");
1890
+ printInstanceDetails(
1891
+ {
1892
+ name: instanceName,
1893
+ runtime: runtimeUrl,
1894
+ container: containerName,
1895
+ },
1896
+ log,
1897
+ );
1898
+ log("");
1802
1899
  return { ready: true, guardianAccessToken };
1803
1900
  }
1804
1901
 
@@ -1879,7 +1976,6 @@ async function waitForGatewayAndLease(opts: {
1879
1976
  // Lease guardian token. The /readyz check confirms both gateway and
1880
1977
  // assistant are reachable. Retry with backoff in case there is a brief
1881
1978
  // window where readiness passes but the guardian endpoint is not yet ready.
1882
- log(`Guardian token lease: starting for ${instanceName} at ${runtimeUrl}`);
1883
1979
  const leaseStart = Date.now();
1884
1980
  const leaseDeadline = start + DOCKER_READY_TIMEOUT_MS;
1885
1981
  let leaseSuccess = false;
@@ -1893,38 +1989,30 @@ async function waitForGatewayAndLease(opts: {
1893
1989
  instanceName,
1894
1990
  bootstrapSecret,
1895
1991
  );
1896
- const leaseElapsed = ((Date.now() - leaseStart) / 1000).toFixed(1);
1897
- log(
1898
- `Guardian token lease: success after ${leaseElapsed}s (principalId=${tokenData.guardianPrincipalId}, expiresAt=${tokenData.accessTokenExpiresAt})`,
1899
- );
1900
1992
  guardianAccessToken = tokenData.accessToken;
1901
1993
  leaseSuccess = true;
1902
1994
  break;
1903
1995
  } catch (err) {
1904
1996
  lastLeaseError =
1905
1997
  err instanceof Error ? (err.stack ?? err.message) : String(err);
1906
- // Log periodically so the user knows we're still trying
1907
- const elapsed = ((Date.now() - leaseStart) / 1000).toFixed(0);
1908
- log(
1909
- `Guardian token lease: attempt failed after ${elapsed}s (${
1910
- lastLeaseError.split("\n")[0]
1911
- }), retrying...`,
1912
- );
1913
1998
  }
1914
1999
  await new Promise((r) => setTimeout(r, 2000));
1915
2000
  }
1916
2001
 
1917
2002
  if (!leaseSuccess) {
1918
2003
  log(
1919
- `\u26a0\ufe0f Guardian token lease: FAILED after ${(
1920
- (Date.now() - leaseStart) /
1921
- 1000
1922
- ).toFixed(1)}s — ${lastLeaseError ?? "unknown error"}`,
2004
+ yellow(
2005
+ `\u26a0\ufe0f Guardian token lease: FAILED after ${(
2006
+ (Date.now() - leaseStart) /
2007
+ 1000
2008
+ ).toFixed(1)}s — ${lastLeaseError ?? "unknown error"}`,
2009
+ process.stdout,
2010
+ ),
1923
2011
  );
1924
2012
  }
1925
2013
 
1926
2014
  log("");
1927
- log(`\u2705 Docker containers are up and running!`);
2015
+ log(green(`\u2705 Docker containers are up and running!`, process.stdout));
1928
2016
  log(` Name: ${instanceName}`);
1929
2017
  log(` Runtime: ${runtimeUrl}`);
1930
2018
  log("");
@@ -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
+ }
@@ -39,9 +39,11 @@ const {
39
39
  getConfigDir,
40
40
  getConfigDirs,
41
41
  getDefaultPorts,
42
+ getInputHistoryPath,
42
43
  getLockfilePath,
43
44
  getLockfilePaths,
44
45
  getMultiInstanceDir,
46
+ getSharedStateDir,
45
47
  } = await import("../paths.js");
46
48
  type EnvironmentDefinition =
47
49
  import("@mycadet/environments").EnvironmentDefinition;
@@ -58,7 +60,12 @@ const dev: EnvironmentDefinition = {
58
60
  webUrl: "https://dev.mycadet.ai",
59
61
  };
60
62
 
61
- const XDG_ENV_VARS = ["XDG_DATA_HOME", "XDG_CONFIG_HOME", "APPDATA"] as const;
63
+ const XDG_ENV_VARS = [
64
+ "XDG_DATA_HOME",
65
+ "XDG_CONFIG_HOME",
66
+ "XDG_STATE_HOME",
67
+ "APPDATA",
68
+ ] as const;
62
69
 
63
70
  describe("path helpers", () => {
64
71
  let savedEnv: Record<string, string | undefined>;
@@ -224,6 +231,25 @@ describe("path helpers", () => {
224
231
  });
225
232
  });
226
233
 
234
+ describe("getSharedStateDir", () => {
235
+ test("is not environment-scoped: ~/.local/state/cadet for every env", () => {
236
+ expect(getSharedStateDir()).toBe(
237
+ join(TEST_HOME, ".local", "state", "cadet"),
238
+ );
239
+ });
240
+
241
+ test("respects XDG_STATE_HOME", () => {
242
+ process.env.XDG_STATE_HOME = "/custom/state";
243
+ expect(getSharedStateDir()).toBe("/custom/state/cadet");
244
+ });
245
+
246
+ test("holds the shared terminal input history file", () => {
247
+ expect(getInputHistoryPath()).toBe(
248
+ join(getSharedStateDir(), "input-history"),
249
+ );
250
+ });
251
+ });
252
+
227
253
  describe("getDefaultPorts", () => {
228
254
  test("returns production defaults for production", () => {
229
255
  const ports = getDefaultPorts(prod);