@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
@@ -22,9 +22,10 @@ import {
22
22
  } from "../lib/ingress-config.js";
23
23
  import {
24
24
  ensureTunnelEdge,
25
- formatEdgeMode,
25
+ getNginxVersion,
26
26
  type TunnelEdge,
27
27
  } from "../lib/nginx-ingress.js";
28
+ import { ensureBinaryInstalled } from "../lib/ensure-binary.js";
28
29
  import { STALE_CLI_UPDATE_HINT } from "../lib/stale-cli-hint.js";
29
30
  import {
30
31
  ADOPTED_TUNNEL_RE,
@@ -73,29 +74,30 @@ function parseArgs(): TunnelArgs {
73
74
  console.log(
74
75
  "How far that URL reaches depends on the provider; omit --provider for",
75
76
  );
76
- console.log("the default. A tailscale tunnel is reachable only from your");
77
77
  console.log(
78
- "own tailnet; webhook integrations need a public tunnel (see Providers",
78
+ "the default. A tailscale tunnel is reachable only from your",
79
79
  );
80
- console.log("below).");
80
+ console.log(
81
+ "own tailnet; webhook integrations need a public tunnel. See Providers",
82
+ );
83
+ console.log("below.");
81
84
  console.log("");
82
85
  console.log(
83
86
  "The tunnel always fronts the local nginx edge, which is started automatically.",
84
87
  );
85
88
  console.log(
86
- "nginx must be installed (macOS: brew install nginx, Linux: sudo apt install nginx).",
89
+ "nginx must be installed; if it is missing, cadet offers to install it.",
87
90
  );
88
91
  console.log("");
89
92
  console.log("Arguments:");
90
93
  console.log(
91
- " <name> Name of the assistant (defaults to active or only local)",
94
+ " <name> Name of the assistant, defaults to active or only local",
92
95
  );
93
96
  console.log("");
94
97
  console.log("Options:");
95
98
  console.log(
96
- ` --provider <provider> Tunnel provider: ${TUNNEL_PROVIDERS.join(", ")}`,
99
+ ` --provider <provider> Tunnel provider: ${TUNNEL_PROVIDERS.join(", ")} — default ${DEFAULT_TUNNEL_PROVIDER}`,
97
100
  );
98
- console.log(` (default: ${DEFAULT_TUNNEL_PROVIDER})`);
99
101
  console.log(
100
102
  " --domain <domain> Reserved domain to bind, for providers that support",
101
103
  );
@@ -137,7 +139,8 @@ function parseArgs(): TunnelArgs {
137
139
  console.log("");
138
140
  console.log("Examples:");
139
141
  console.log(` $ cadet tunnel --provider ${DEFAULT_TUNNEL_PROVIDER}`);
140
- const domainExample = PROVIDER_META[DEFAULT_TUNNEL_PROVIDER].domainExample;
142
+ const domainExample =
143
+ PROVIDER_META[DEFAULT_TUNNEL_PROVIDER].domainExample;
141
144
  if (domainExample) {
142
145
  console.log(
143
146
  ` $ cadet tunnel --provider ${DEFAULT_TUNNEL_PROVIDER} --domain ${domainExample}`,
@@ -174,7 +177,8 @@ function parseArgs(): TunnelArgs {
174
177
  } else if (arg === "--domain") {
175
178
  const next = args[i + 1];
176
179
  if (!next || next.startsWith("-")) {
177
- const domainExample = PROVIDER_META[DEFAULT_TUNNEL_PROVIDER].domainExample;
180
+ const domainExample =
181
+ PROVIDER_META[DEFAULT_TUNNEL_PROVIDER].domainExample;
178
182
  console.error(
179
183
  `Error: --domain requires a value${domainExample ? `, e.g. --domain ${domainExample}` : ""}`,
180
184
  );
@@ -199,9 +203,7 @@ function parseArgs(): TunnelArgs {
199
203
 
200
204
  if (provider === null) {
201
205
  provider = DEFAULT_TUNNEL_PROVIDER;
202
- console.log(
203
- `Using the default provider: ${provider} (pass --provider to choose another).`,
204
- );
206
+ console.log(`Using the default provider: ${provider}.`);
205
207
  }
206
208
 
207
209
  if (domain && !providerSupportsReservedDomain(provider)) {
@@ -415,57 +417,78 @@ async function spawnDetachedTunnel(): Promise<void> {
415
417
 
416
418
  export const tunnel = Object.assign(
417
419
  async function tunnel(): Promise<void> {
418
- const { assistantName, provider, domain, clearDomain, detach } = parseArgs();
420
+ const { assistantName, provider, domain, clearDomain, detach } =
421
+ parseArgs();
419
422
 
420
- if (detach) {
421
- await spawnDetachedTunnel();
422
- return;
423
- }
423
+ if (detach) {
424
+ await spawnDetachedTunnel();
425
+ return;
426
+ }
424
427
 
425
- if (PROVIDER_META[provider].comingSoon) {
426
- throw cadetTunnelComingSoonError(provider);
427
- }
428
+ if (PROVIDER_META[provider].comingSoon) {
429
+ throw cadetTunnelComingSoonError(provider);
430
+ }
428
431
 
429
- const { entry, gatewayPort, workspaceDir } =
430
- resolveLocalTunnelTarget(assistantName);
432
+ const { entry, gatewayPort, workspaceDir } =
433
+ resolveLocalTunnelTarget(assistantName);
431
434
 
432
- const preserveIngressUrl =
433
- provider === "tailscale" && guardTailnetOnlyWebhookIngress(workspaceDir);
435
+ const preserveIngressUrl =
436
+ provider === "tailscale" && guardTailnetOnlyWebhookIngress(workspaceDir);
434
437
 
435
- if (clearDomain) {
436
- clearReservedDomain();
437
- console.log("Cleared the saved reserved domain.");
438
- }
438
+ if (clearDomain) {
439
+ clearReservedDomain();
440
+ console.log("Cleared the saved reserved domain.");
441
+ }
439
442
 
440
- let edge: TunnelEdge;
441
- try {
442
- edge = await ensureTunnelEdge({
443
- assistantId: entry.assistantId,
444
- workspaceDir,
445
- gatewayPort,
443
+ const nginxVersion = await ensureBinaryInstalled({
444
+ binary: "nginx",
445
+ purpose: "the tunnel edge",
446
+ packages: {
447
+ brew: ["brew", "install", "nginx"],
448
+ "apt-get": ["sudo", "apt-get", "install", "-y", "nginx"],
449
+ dnf: ["sudo", "dnf", "install", "-y", "nginx"],
450
+ pacman: ["sudo", "pacman", "-S", "--noconfirm", "nginx"],
451
+ zypper: ["sudo", "zypper", "install", "-y", "nginx"],
452
+ choco: ["choco", "install", "-y", "nginx"],
453
+ },
454
+ manualHint:
455
+ "See https://nginx.org/en/docs/install.html for how to install, or point NGINX_BIN at an existing binary.",
456
+ probe: getNginxVersion,
446
457
  });
447
- } catch (err) {
448
- console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
449
- process.exit(1);
450
- }
458
+ if (!nginxVersion) {
459
+ console.error("Error: nginx is required to run the tunnel edge.");
460
+ process.exit(1);
461
+ }
451
462
 
452
- console.log(
453
- `${edge.started ? "Started" : "Reusing"} the nginx edge on 127.0.0.1:${edge.port} ` +
454
- `(serves ${formatEdgeMode(edge.includesWebApp)}).`,
455
- );
463
+ let edge: TunnelEdge;
464
+ try {
465
+ edge = await ensureTunnelEdge({
466
+ assistantId: entry.assistantId,
467
+ workspaceDir,
468
+ gatewayPort,
469
+ });
470
+ } catch (err) {
471
+ console.error(
472
+ `Error: ${err instanceof Error ? err.message : String(err)}`,
473
+ );
474
+ process.exit(1);
475
+ }
476
+
477
+ console.log(
478
+ `${edge.started ? "Started" : "Reusing"} the nginx edge on 127.0.0.1:${edge.port}.`,
479
+ );
456
480
 
457
- const baseTunnelOpts = {
458
- port: edge.port,
459
- assistantId: entry.assistantId,
460
- workspaceDir,
461
- };
481
+ const baseTunnelOpts = {
482
+ port: edge.port,
483
+ assistantId: entry.assistantId,
484
+ workspaceDir,
485
+ };
462
486
 
463
- await runProviderTunnel(provider, {
464
- ...baseTunnelOpts,
465
- ...(domain ? { domain } : {}),
466
- ...(preserveIngressUrl ? { preserveIngressUrl } : {}),
467
- });
468
- },
487
+ await runProviderTunnel(provider, {
488
+ ...baseTunnelOpts,
489
+ ...(domain ? { domain } : {}),
490
+ ...(preserveIngressUrl ? { preserveIngressUrl } : {}),
491
+ });
492
+ },
469
493
  { description: "Create a tunnel for a locally hosted assistant" },
470
494
  );
471
-
@@ -141,7 +141,7 @@ function selfDelete(): void {
141
141
  "Cadet is running from a source checkout — skipping self-removal.",
142
142
  );
143
143
  console.log(
144
- "Remove the installed copy with `bun remove -g mycadet` (or `npm uninstall -g mycadet`).",
144
+ "Remove the installed copy with `bun remove -g mycadet` or `npm uninstall -g mycadet`.",
145
145
  );
146
146
  return;
147
147
  }
@@ -206,14 +206,14 @@ export const uninstall = Object.assign(
206
206
  const existing = loadAllAssistants();
207
207
  if (existing.length > 0) {
208
208
  console.warn(
209
- `\n⚠️ ${existing.length} assistant(s) still exist: ${existing.map((a) => a.assistantId).join(", ")}`,
209
+ `\n⚠️ ${existing.length} ${existing.length === 1 ? "assistant still exists" : "assistants still exist"}: ${existing.map((a) => a.assistantId).join(", ")}`,
210
210
  );
211
211
  console.warn(
212
212
  " They will be retired now — their data (and any retired archives) will be permanently deleted by this uninstall.",
213
213
  );
214
214
  for (const entry of existing) {
215
215
  if (entry.cloud === "apple-container" || entry.cloud === "paired") {
216
- console.log(` Skipping '${entry.assistantId}' (${entry.cloud}) — its lifecycle is managed elsewhere.`);
216
+ console.log(` Skipping '${entry.assistantId}' on ${entry.cloud} — its lifecycle is managed elsewhere.`);
217
217
  continue;
218
218
  }
219
219
  try {
@@ -239,7 +239,7 @@ export const uninstall = Object.assign(
239
239
  } else {
240
240
  console.log("It will permanently delete:");
241
241
  for (const t of targets) {
242
- console.log(` - ${t.path} (${t.description})`);
242
+ console.log(` - ${t.path} — ${t.description}`);
243
243
  }
244
244
  }
245
245
  const execBase = basename(execPath);
@@ -19,6 +19,7 @@ import {
19
19
  GATEWAY_INTERNAL_PORT,
20
20
  ASSISTANT_INTERNAL_PORT,
21
21
  dockerResourceNames,
22
+ pullImage,
22
23
  startContainers,
23
24
  stopContainers,
24
25
  } from "../lib/docker";
@@ -46,7 +47,6 @@ import {
46
47
  } from "../lib/backup-ops.js";
47
48
  import { emitCliError, categorizeUpgradeError } from "../lib/cli-error.js";
48
49
  import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
49
- import { exec } from "../lib/step-runner.js";
50
50
  import {
51
51
  broadcastUpgradeEvent,
52
52
  attemptFailedStateRestore,
@@ -118,12 +118,12 @@ function parseArgs(): UpgradeArgs {
118
118
  console.log("");
119
119
  console.log("Arguments:");
120
120
  console.log(
121
- " <name> Name of the assistant to upgrade (default: active or only assistant)",
121
+ " <name> Name of the assistant to upgrade, defaults to active or only assistant",
122
122
  );
123
123
  console.log("");
124
124
  console.log("Options:");
125
125
  console.log(
126
- " --version <version> Target version to upgrade to (default: CLI version)",
126
+ " --version <version> Target version to upgrade to, defaults to the CLI version",
127
127
  );
128
128
  console.log(
129
129
  " --latest Upgrade to the latest stable release, updating the CLI first if needed",
@@ -316,7 +316,7 @@ async function upgradeDocker(
316
316
  );
317
317
  return;
318
318
  }
319
- console.log(`🔁 Reinstalling ${versionTag} (--force)...\n`);
319
+ console.log(`🔁 Reinstalling ${versionTag} with --force...\n`);
320
320
  }
321
321
 
322
322
  console.log("🔍 Resolving image references...");
@@ -406,7 +406,7 @@ async function upgradeDocker(
406
406
  try {
407
407
  for (const [service, image] of pullImages) {
408
408
  console.log(` Pulling ${service}: ${image}`);
409
- await exec("docker", ["pull", image]);
409
+ await pullImage(image);
410
410
  }
411
411
  } catch (pullErr) {
412
412
  const detail = pullErr instanceof Error ? pullErr.message : String(pullErr);
@@ -453,7 +453,7 @@ async function upgradeDocker(
453
453
  // Clean up old pre-upgrade backups, keep last 3
454
454
  pruneOldBackups(entry.assistantId, 3);
455
455
  } else {
456
- console.warn("⚠️ Pre-upgrade backup failed (continuing with upgrade)\n");
456
+ console.warn("⚠️ Pre-upgrade backup failed, continuing with upgrade\n");
457
457
  }
458
458
 
459
459
  // Persist the backup path so `cadet rollback` can restore the exact backup
@@ -891,7 +891,7 @@ async function upgradeLocal(
891
891
  );
892
892
  return;
893
893
  }
894
- console.log(`🔁 Reinstalling ${targetVersion} (--force)...\n`);
894
+ console.log(`🔁 Reinstalling ${targetVersion} with --force...\n`);
895
895
  }
896
896
 
897
897
  console.log(
@@ -933,7 +933,7 @@ async function upgradeLocal(
933
933
  console.log(` Backup saved: ${backupPath}\n`);
934
934
  pruneOldBackups(entry.assistantId, 3);
935
935
  } else {
936
- console.warn("⚠️ Pre-upgrade backup failed (continuing with upgrade)\n");
936
+ console.warn("⚠️ Pre-upgrade backup failed, continuing with upgrade\n");
937
937
  }
938
938
 
939
939
  let bootstrapSecret = entry.guardianBootstrapSecret;
@@ -38,7 +38,7 @@ export const use = Object.assign(
38
38
  `Active assistant: ${formatAssistantReference(result.entry)}`,
39
39
  );
40
40
  } else {
41
- console.log(`Active assistant: ${active} (not found in lockfile)`);
41
+ console.log(`Active assistant: ${active} — not found in lockfile`);
42
42
  }
43
43
  } else {
44
44
  console.log("No active assistant set.");
@@ -58,7 +58,7 @@ export const wake = Object.assign(
58
58
  console.log("");
59
59
  console.log("Arguments:");
60
60
  console.log(
61
- " <name> Name of the assistant to start (default: active or only local)",
61
+ " <name> Name of the assistant to start, defaults to active or only local",
62
62
  );
63
63
  console.log("");
64
64
  console.log("Options:");
@@ -122,7 +122,7 @@ export const wake = Object.assign(
122
122
  // Foreground blocking is a single-assistant concern (--all refuses the
123
123
  // flag), and the docker path already warned that foreground is ignored.
124
124
  if (foreground && entry.cloud !== "docker") {
125
- console.log("Running in foreground (Ctrl+C to stop)...\n");
125
+ console.log("Running in the foreground, Ctrl+C to stop...\n");
126
126
  // Block forever — the daemon is running with inherited stdio so its
127
127
  // output streams to this terminal. When the user hits Ctrl+C, SIGINT
128
128
  // propagates to the daemon child and both exit.
@@ -213,7 +213,7 @@ async function wakeAssistant(
213
213
  .filter(Boolean)
214
214
  .join(" and ");
215
215
  console.warn(
216
- `Warning: ${ignored} ignored for Docker instances (not supported).`,
216
+ `Warning: ${ignored} ignored for Docker instances.`,
217
217
  );
218
218
  }
219
219
  const res = dockerResourceNames(entry.assistantId);
@@ -364,7 +364,7 @@ async function wakeAssistant(
364
364
  daemonMigrationsFailed = daemonState.status === "migration_failed";
365
365
  if (watch) {
366
366
  console.log(
367
- `Assistant running (pid ${daemonState.pid}) — watch mode not available (no source files). Keeping existing process.`,
367
+ `Assistant running (pid ${daemonState.pid}) — watch mode not available, no source files. Keeping existing process.`,
368
368
  );
369
369
  } else if (daemonMigrationsFailed) {
370
370
  console.log(
@@ -488,7 +488,7 @@ async function wakeAssistant(
488
488
  } else {
489
489
  if (watch) {
490
490
  console.log(
491
- `Gateway running (pid ${gatewayState.pid}) — watch mode not available (no source files). Keeping existing process.`,
491
+ `Gateway running (pid ${gatewayState.pid}) — watch mode not available, no source files. Keeping existing process.`,
492
492
  );
493
493
  } else {
494
494
  console.log(`Gateway already running (pid ${gatewayState.pid}).`);
@@ -569,7 +569,7 @@ async function wakeAssistant(
569
569
  await new Promise((r) => setTimeout(r, 2000 * 2 ** (attempt - 1)));
570
570
  } else {
571
571
  console.warn(
572
- ` Guardian token re-provision failed${err instanceof GuardianLeasePermanentError ? " (not retrying — a malformed 200 will not fix itself)" : ` after ${maxAttempts} attempts`}: ${err}`,
572
+ ` Guardian token re-provision failed${err instanceof GuardianLeasePermanentError ?", not retrying — a malformed 200 will not fix itself" : ` after ${maxAttempts} attempts`}: ${err}`,
573
573
  );
574
574
  // The user explicitly confirmed this destructive repair — a
575
575
  // success exit here would make callers (the web recovery flow)
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ import cliPkg from "../package.json";
14
14
  import { backup } from "./commands/backup";
15
15
  import { clean } from "./commands/clean";
16
16
  import { client } from "./commands/client";
17
+ import { completeCadet, completions } from "./commands/completions";
17
18
  import { confirm } from "./commands/confirm";
18
19
  import { connect } from "./commands/connect";
19
20
  import { devices } from "./commands/devices";
@@ -53,6 +54,7 @@ const commands = {
53
54
  backup,
54
55
  clean,
55
56
  client,
57
+ completions,
56
58
  confirm,
57
59
  connect,
58
60
  debug,
@@ -231,6 +233,19 @@ async function main() {
231
233
  process.exit(0);
232
234
  }
233
235
 
236
+ // Hidden completion hook: the bash completion script calls
237
+ // `cadet __complete <words-up-to-cursor>` and filters the printed
238
+ // candidates against the word under the cursor. Deliberately unlisted in
239
+ // the command map so it never shows up in help.
240
+ if (commandName === "__complete") {
241
+ const candidates = completeCadet(
242
+ process.argv.slice(3),
243
+ Object.keys(commands),
244
+ );
245
+ console.log(candidates.join("\n"));
246
+ process.exit(0);
247
+ }
248
+
234
249
  const command = commands[commandName as CommandName];
235
250
 
236
251
  if (!command) {
@@ -8,6 +8,7 @@ import {
8
8
  collectWatchTargets,
9
9
  dockerResourceNames,
10
10
  ensureDockerDaemonRunning,
11
+ pullImage,
11
12
  resolveAvatarDevicePath,
12
13
  resolveDockerHatchMode,
13
14
  resolveDockerProviderCredentialSetupAction,
@@ -158,6 +159,38 @@ describe("resolveDockerProviderCredentialSetupAction", () => {
158
159
  }),
159
160
  ).toBe("skip");
160
161
  });
162
+
163
+ test("detached hatch with a pre-collected key configures instead of deferring", () => {
164
+ expect(
165
+ resolveDockerProviderCredentialSetupAction({
166
+ provider: "fireworks",
167
+ guardianAccessToken: "guardian-token",
168
+ detached: true,
169
+ precollectedKey: "fw-key-123",
170
+ }),
171
+ ).toBe("configure");
172
+ });
173
+
174
+ test("pre-collected key without a guardian token reports missing-token (no silent defer)", () => {
175
+ expect(
176
+ resolveDockerProviderCredentialSetupAction({
177
+ provider: "fireworks",
178
+ detached: true,
179
+ precollectedKey: "fw-key-123",
180
+ }),
181
+ ).toBe("missing-token");
182
+ });
183
+
184
+ test("an explicitly skipped key still counts as answered — configure runs and reports the gap", () => {
185
+ expect(
186
+ resolveDockerProviderCredentialSetupAction({
187
+ provider: "fireworks",
188
+ guardianAccessToken: "guardian-token",
189
+ detached: true,
190
+ precollectedKey: "",
191
+ }),
192
+ ).toBe("configure");
193
+ });
161
194
  });
162
195
 
163
196
  describe("buildServiceRunArgs — gateway", () => {
@@ -384,25 +417,48 @@ describe("collectWatchTargets", () => {
384
417
  });
385
418
 
386
419
  /**
387
- * Build a fake `exec` that records calls and fails the probes named in `fail`.
388
- * Injected into `ensureDockerDaemonRunning` so these cases need no `mock.module`
389
- * (which is process-global and would leak across the CLI's single-process test
390
- * run) and no real Docker/Colima subprocesses.
420
+ * Build a STATEFUL fake `exec` modeling the Docker daemon + Colima interplay:
421
+ * `docker info` succeeds only while `daemonUp`; a `colima start` heals the
422
+ * daemon (unless the fake is told to model the zombie state, where Colima's
423
+ * state file says "already running, ignoring" but the daemon stays dead —
424
+ * only the stop → start cycle recovers). Injected into
425
+ * `ensureDockerDaemonRunning` so these cases need no `mock.module` (which is
426
+ * process-global and would leak across the CLI's single-process test run) and
427
+ * no real Docker/Colima subprocesses.
391
428
  */
392
- function fakeExec(fail: { dockerInfo?: boolean; colimaStatus?: boolean }) {
429
+ function fakeExec(
430
+ opts: {
431
+ /** Daemon already reachable when the test starts. */
432
+ daemonUpAtStart?: boolean;
433
+ /** `colima start` exits nonzero instead of healing. */
434
+ startFails?: boolean;
435
+ /** First `colima start` is a zombie no-op that does NOT heal the daemon. */
436
+ zombieFirstStart?: boolean;
437
+ } = {},
438
+ ) {
393
439
  const calls: Array<{ cmd: string; args: string[] }> = [];
440
+ let daemonUp = opts.daemonUpAtStart ?? false;
441
+ let starts = 0;
394
442
  const exec = async (cmd: string, args: string[] = []): Promise<void> => {
395
443
  calls.push({ cmd, args });
396
- if (cmd === "docker" && args[0] === "info" && fail.dockerInfo) {
397
- throw new Error("Cannot connect to the Docker daemon");
444
+ if (cmd === "docker" && args[0] === "info") {
445
+ if (!daemonUp) throw new Error("Cannot connect to the Docker daemon");
446
+ return;
447
+ }
448
+ if (cmd === "colima" && args[0] === "start") {
449
+ starts++;
450
+ if (opts.startFails) throw new Error("colima start exited 1");
451
+ if (!opts.zombieFirstStart || starts > 1) daemonUp = true;
452
+ return;
398
453
  }
399
- if (cmd === "colima" && args[0] === "status" && fail.colimaStatus) {
400
- throw new Error("colima is not running");
454
+ if (cmd === "colima" && args[0] === "stop") {
455
+ daemonUp = false;
456
+ return;
401
457
  }
402
458
  };
403
459
  const ran = (cmd: string, sub: string): boolean =>
404
460
  calls.some((c) => c.cmd === cmd && c.args[0] === sub);
405
- return { exec: exec as typeof import("../step-runner.js").exec, ran };
461
+ return { exec, ran };
406
462
  }
407
463
 
408
464
  describe("ensureDockerDaemonRunning", () => {
@@ -410,30 +466,122 @@ describe("ensureDockerDaemonRunning", () => {
410
466
  // Colima, because that switches the active `docker context` and points
411
467
  // `docker start` at a VM that never held the instance's containers.
412
468
  test("reuses an already-reachable daemon (e.g. Docker Desktop) and never touches Colima", async () => {
413
- const { exec, ran } = fakeExec({ dockerInfo: false });
469
+ const { exec, ran } = fakeExec({ daemonUpAtStart: true });
414
470
 
415
471
  await ensureDockerDaemonRunning(exec);
416
472
 
417
473
  expect(ran("docker", "info")).toBe(true);
418
- expect(ran("colima", "status")).toBe(false);
419
474
  expect(ran("colima", "start")).toBe(false);
475
+ expect(ran("colima", "stop")).toBe(false);
420
476
  });
421
477
 
422
- test("starts Colima only when no daemon is reachable", async () => {
423
- const { exec, ran } = fakeExec({ dockerInfo: true, colimaStatus: true });
478
+ test("starts Colima when no daemon is reachable and confirms the daemon answered", async () => {
479
+ const { exec, ran } = fakeExec();
424
480
 
425
- await ensureDockerDaemonRunning(exec);
481
+ await ensureDockerDaemonRunning(exec, { settleTimeoutMs: 0 });
426
482
 
427
483
  expect(ran("docker", "info")).toBe(true);
428
484
  expect(ran("colima", "start")).toBe(true);
485
+ expect(ran("colima", "stop")).toBe(false);
429
486
  });
430
487
 
431
- test("does not restart Colima when it is already running", async () => {
432
- const { exec, ran } = fakeExec({ dockerInfo: true, colimaStatus: false });
488
+ // Regression (v0.12.8 release feedback): a zombie Colima — stale state file
489
+ // saying "already running" while the daemon inside is dead — used to sail
490
+ // through wake (no `colima status` oracle anymore) and leave every later
491
+ // docker command dying on the unreachable socket.
492
+ test("recovers a zombie Colima: start no-ops, daemon stays dead → stop --force → start → daemon up", async () => {
493
+ const { exec, ran } = fakeExec({ zombieFirstStart: true });
433
494
 
434
- await ensureDockerDaemonRunning(exec);
495
+ await ensureDockerDaemonRunning(exec, { settleTimeoutMs: 0 });
435
496
 
436
- expect(ran("colima", "status")).toBe(true);
437
- expect(ran("colima", "start")).toBe(false);
497
+ expect(ran("colima", "start")).toBe(true);
498
+ expect(ran("colima", "stop")).toBe(true);
499
+ });
500
+
501
+ test("never deletes the Colima VM — the VM disk holds every image and volume", async () => {
502
+ const { exec, ran } = fakeExec({ zombieFirstStart: true });
503
+
504
+ await ensureDockerDaemonRunning(exec, { settleTimeoutMs: 0 });
505
+
506
+ expect(ran("colima", "delete")).toBe(false);
507
+ });
508
+
509
+ test("throws an actionable error when even the restart cannot bring the daemon up", () => {
510
+ // A start that always exits nonzero: the first failure surfaces its
511
+ // output, the recovery stop+start runs, and the final wait still finds
512
+ // no daemon → the helper throws instead of letting hatch proceed.
513
+ const { exec } = fakeExec({ startFails: true });
514
+ expect(
515
+ ensureDockerDaemonRunning(exec, { settleTimeoutMs: 0 }),
516
+ ).rejects.toThrow(/Failed to restart Colima/);
517
+ });
518
+ });
519
+
520
+ describe("pullImage", () => {
521
+ function fakePull(
522
+ opts: {
523
+ failFirstPullWith?: string;
524
+ failAllPullsWith?: string;
525
+ } = {},
526
+ ) {
527
+ const calls: Array<{ cmd: string; args: string[] }> = [];
528
+ let pulls = 0;
529
+ const exec = async (cmd: string, args: string[] = []): Promise<void> => {
530
+ calls.push({ cmd, args });
531
+ if (cmd === "docker" && args[0] === "pull") {
532
+ pulls++;
533
+ if (opts.failAllPullsWith) throw new Error(opts.failAllPullsWith);
534
+ if (opts.failFirstPullWith && pulls === 1) {
535
+ throw new Error(opts.failFirstPullWith);
536
+ }
537
+ }
538
+ // `docker logout` (and anything else) succeeds silently.
539
+ };
540
+ const ran = (sub: string): boolean =>
541
+ calls.some((c) => c.cmd === "docker" && c.args[0] === sub);
542
+ return { exec, ran };
543
+ }
544
+
545
+ test("pulls without touching stored credentials on success", async () => {
546
+ const { exec, ran } = fakePull();
547
+
548
+ await pullImage("mycadet/mycadet-assistant:v1", () => {}, exec);
549
+
550
+ expect(ran("pull")).toBe(true);
551
+ expect(ran("logout")).toBe(false);
552
+ });
553
+
554
+ test("clears stale Docker Hub credentials and retries once on an expired-token error", async () => {
555
+ const { exec, ran } = fakePull({
556
+ failFirstPullWith:
557
+ "Error response from daemon: authentication required - personal access token is expired",
558
+ });
559
+
560
+ await pullImage("mycadet/mycadet-assistant:v1", () => {}, exec);
561
+
562
+ expect(ran("logout")).toBe(true);
563
+ });
564
+
565
+ test("wraps the failure with actionable copy when the auth retry also fails", () => {
566
+ const { exec } = fakePull({
567
+ failAllPullsWith:
568
+ "Error response from daemon: authentication required - personal access token is expired",
569
+ });
570
+
571
+ expect(
572
+ pullImage("mycadet/mycadet-assistant:v1", () => {}, exec),
573
+ ).rejects.toThrow("Failed to pull mycadet/mycadet-assistant:v1");
574
+ });
575
+
576
+ test("rethrows non-auth pull errors raw and never logs out", () => {
577
+ const { exec, ran } = fakePull({
578
+ failAllPullsWith:
579
+ "Cannot connect to the Docker daemon at unix:///var/run/docker.sock",
580
+ });
581
+
582
+ expect(
583
+ pullImage("mycadet/mycadet-assistant:v1", () => {}, exec),
584
+ ).rejects.toThrow("Cannot connect to the Docker daemon");
585
+ expect(ran("logout")).toBe(false);
438
586
  });
439
587
  });
@@ -203,7 +203,7 @@ describe("pollJobUntilDone", () => {
203
203
  expect(refreshes).toBe(1);
204
204
  expect(calls).toBe(2);
205
205
  // The 401 branch logs its own distinct warning (not the generic
206
- // "polling failed, retrying" one) so operators can distinguish an
206
+ // "polling failed:" one) so operators can distinguish an
207
207
  // auth refresh from a transient-error retry in the output.
208
208
  expect(warnSpy).toHaveBeenCalledWith(
209
209
  expect.stringContaining("refreshing auth"),