@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
@@ -13,6 +13,7 @@ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
13
13
 
14
14
  import { GATEWAY_PORT } from "../constants.js";
15
15
  import { clearRootProcess, stampRootProcess } from "../assistant-config.js";
16
+ import { ensureBinaryInstalled, readLineText } from "../ensure-binary.js";
16
17
  import {
17
18
  clearIngressUrl,
18
19
  getDefaultWorkspaceDir,
@@ -81,6 +82,52 @@ export function getNgrokVersion(): string | null {
81
82
  }
82
83
  }
83
84
 
85
+ /** Whether captured agent output says the startup failure was authentication. */
86
+ export function isNgrokAuthError(output: string): boolean {
87
+ return /authtoken|authenticat|unauthorized|usage limit|ERR_NGROK_10\d\d|ERR_NGROK_40\d\d/i.test(
88
+ output,
89
+ );
90
+ }
91
+
92
+ /** An ngrok agent that exited during startup, with its captured output. */
93
+ export class NgrokStartupError extends Error {
94
+ readonly exitCode: number;
95
+ readonly output: string;
96
+
97
+ constructor(exitCode: number, output: string) {
98
+ super(`ngrok exited with code ${exitCode}`);
99
+ this.exitCode = exitCode;
100
+ this.output = output;
101
+ }
102
+ }
103
+
104
+ /** Paste-in attempts before the tunnel command gives up and says how. */
105
+ const MAX_AUTH_RETRIES = 2;
106
+
107
+ /**
108
+ * Run `ngrok config add-authtoken <token>` — writing the user's default
109
+ * ngrok config, which is exactly the config the tunnel spawn loads. The
110
+ * token is never echoed: output is piped and any failure message has the
111
+ * token scrubbed.
112
+ */
113
+ export function saveNgrokAuthToken(token: string): boolean {
114
+ try {
115
+ execFileSync("ngrok", ["config", "add-authtoken", token], {
116
+ encoding: "utf-8",
117
+ timeout: 10_000,
118
+ stdio: ["ignore", "pipe", "pipe"],
119
+ windowsHide: true,
120
+ });
121
+ return true;
122
+ } catch (err) {
123
+ const message = err instanceof Error ? err.message : String(err);
124
+ console.error(
125
+ `⚠ Couldn't save the authtoken: ${message.replaceAll(token, "***")}`,
126
+ );
127
+ return false;
128
+ }
129
+ }
130
+
84
131
  /**
85
132
  * Query the ngrok local API for running tunnels.
86
133
  * Returns the list of tunnels, or null if the API is unreachable.
@@ -159,11 +206,11 @@ type NgrokConfigFile = z.infer<typeof NgrokConfigFileSchema>;
159
206
  type NgrokTunnelDef = z.infer<typeof NgrokTunnelDefSchema>;
160
207
 
161
208
  /**
162
- * The ngrok-format config file this CLI owns in the XDG config dir, layered
163
- * over the user's root ngrok config at spawn time (`--config` files merge in
164
- * order, later wins; the user's authtoken keeps living in their root config).
165
- * It is also the source of truth for the reserved domain at this layer — the
166
- * workspace config no longer records one.
209
+ * The ngrok-format config file this CLI owns in the XDG config dir. It no
210
+ * longer participates in spawning (plain `ngrok http` loads the user's
211
+ * default config, keeping their authtoken in play); it is the source of
212
+ * truth for the reserved domain at this layer — the workspace config no
213
+ * longer records one.
167
214
  */
168
215
  export function ngrokConfigPath(): string {
169
216
  return join(getConfigDir(getCurrentEnvironment()), "tunnels", "ngrok.yaml");
@@ -250,43 +297,34 @@ function urlMatchesDomain(publicUrl: string, domain: string): boolean {
250
297
  }
251
298
  }
252
299
 
253
- /**
254
- * Write the cadet-owned XDG ngrok config for this run: the tunnel definition
255
- * `ngrok start` reads, carrying the loopback target and — when reserved — the
256
- * bound domain. The file is layered over the user's root ngrok config at
257
- * spawn, so their authtoken and settings keep applying untouched.
258
- */
259
- function writeNgrokConfig(targetPort: number, domain?: string): void {
260
- const config = readNgrokConfigFile();
261
- const cadet: NgrokTunnelDef = {
262
- ...config.tunnels?.[NGROK_TUNNEL_NAME],
263
- proto: "http",
264
- // Explicit over a bare port, which ngrok expands to `localhost`, whose
265
- // ::1 answer resolves first on macOS and races the edge's IPv4 bind.
266
- addr: `127.0.0.1:${targetPort}`,
267
- };
300
+ /** The argv for an ngrok agent forwarding the edge port, domain when set. */
301
+ export function buildNgrokSpawnArgs(
302
+ targetPort: number,
303
+ domain?: string,
304
+ ): string[] {
305
+ const args = ["http", `127.0.0.1:${targetPort}`, "--log=stdout"];
268
306
  if (domain) {
269
- cadet.domain = domain;
307
+ args.push(`--domain=${domain}`);
270
308
  }
271
- const tunnels: Record<string, NgrokTunnelDef> = { ...config.tunnels };
272
- tunnels[NGROK_TUNNEL_NAME] = cadet;
273
- const next: NgrokConfigFile = { ...config, version: config.version ?? "2" };
274
- next.tunnels = tunnels;
275
- writeNgrokConfigFile(next);
309
+ return args;
276
310
  }
277
311
 
278
312
  /**
279
- * Start an ngrok process tunneling HTTP traffic to the given local port, via
280
- * the cadet-owned XDG config (`ngrok start cadet`) so the agent never collides
281
- * with the host machine's root ngrok config.
313
+ * Start an ngrok process tunneling HTTP traffic to the given local port.
314
+ *
315
+ * Plain `ngrok http` is spawned deliberately: ngrok only loads the user's
316
+ * default config — where `ngrok config add-authtoken` writes — when no
317
+ * `--config` is passed, so this is what keeps the authtoken in play. The
318
+ * explicit `127.0.0.1:` host avoids a bare port, which ngrok expands to
319
+ * `localhost`, whose ::1 answer resolves first on macOS and races the edge's
320
+ * IPv4 bind.
282
321
  *
283
322
  * When `logFilePath` is provided, stdout/stderr are redirected to that file
284
323
  * instead of being piped. This avoids keeping pipe handles open in the
285
324
  * parent process — which would either prevent the CLI from exiting (if
286
325
  * handles are left open) or send SIGPIPE to ngrok (if destroyed).
287
326
  *
288
- * When `domain` is set, the spawned tunnel binds that reserved ngrok domain,
289
- * recorded in the XDG config alongside the loopback target.
327
+ * When `domain` is set, the spawned tunnel binds that reserved ngrok domain.
290
328
  *
291
329
  * Returns the spawned child process.
292
330
  */
@@ -304,15 +342,7 @@ export function startNgrokProcess(
304
342
  stdio = ["ignore", fd, fd];
305
343
  }
306
344
 
307
- writeNgrokConfig(targetPort, domain);
308
- const args = [
309
- "start",
310
- NGROK_TUNNEL_NAME,
311
- `--config=${ngrokConfigPath()}`,
312
- "--log=stdout",
313
- ];
314
-
315
- const child = spawn("ngrok", args, {
345
+ const child = spawn("ngrok", buildNgrokSpawnArgs(targetPort, domain), {
316
346
  detached: true,
317
347
  stdio,
318
348
  windowsHide: true,
@@ -534,18 +564,26 @@ export interface RunNgrokTunnelOptions {
534
564
  export async function runNgrokTunnel(
535
565
  opts: RunNgrokTunnelOptions = {},
536
566
  ): Promise<ProviderTunnelResult> {
537
- const version = getNgrokVersion();
567
+ const version = await ensureBinaryInstalled({
568
+ binary: "ngrok",
569
+ purpose: "the ngrok tunnel",
570
+ packages: {
571
+ brew: ["brew", "install", "ngrok/ngrok/ngrok"],
572
+ snap: ["sudo", "snap", "install", "ngrok"],
573
+ choco: ["choco", "install", "-y", "ngrok"],
574
+ winget: [
575
+ "winget",
576
+ "install",
577
+ "--silent",
578
+ "--accept-package-agreements",
579
+ "--accept-source-agreements",
580
+ "ngrok.ngrok",
581
+ ],
582
+ },
583
+ manualHint: "See https://ngrok.com/download for how to install",
584
+ probe: getNgrokVersion,
585
+ });
538
586
  if (!version) {
539
- console.error("Error: ngrok is not installed.");
540
- console.error("");
541
- console.error("Install ngrok:");
542
- console.error(" macOS: brew install ngrok/ngrok/ngrok");
543
- console.error(" Linux: sudo snap install ngrok");
544
- console.error("");
545
- console.error("Then authenticate: ngrok config add-authtoken <your-token>");
546
- console.error(
547
- " Get your token at: https://dashboard.ngrok.com/get-started/your-authtoken",
548
- );
549
587
  process.exit(1);
550
588
  }
551
589
 
@@ -607,12 +645,14 @@ export async function runNgrokTunnel(
607
645
  console.log(`Starting ngrok tunnel to localhost:${port}...`);
608
646
 
609
647
  let publicUrl: string | undefined;
610
-
611
- const ngrokProcess = startNgrokProcess(port, undefined, domain);
648
+ // Points at the live agent across auth retries; signals and cleanup act on
649
+ // whichever attempt is current.
650
+ let currentNgrok: ChildProcess | null = null;
651
+ let authAttempts = 0;
612
652
 
613
653
  const cleanup = () => {
614
- if (!ngrokProcess.killed) {
615
- ngrokProcess.kill("SIGTERM");
654
+ if (currentNgrok && !currentNgrok.killed) {
655
+ currentNgrok.kill("SIGTERM");
616
656
  }
617
657
  clearRootProcess("tunnel");
618
658
  if (publicUrl) {
@@ -630,66 +670,116 @@ export async function runNgrokTunnel(
630
670
  process.exit(0);
631
671
  });
632
672
 
633
- ngrokProcess.on("error", (err: Error) => {
634
- console.error(`ngrok process error: ${err.message}`);
635
- process.exit(1);
636
- });
673
+ while (publicUrl === undefined) {
674
+ const ngrokProcess = startNgrokProcess(port, undefined, domain);
675
+ currentNgrok = ngrokProcess;
637
676
 
638
- ngrokProcess.on("exit", (code: number | null) => {
639
- if (code !== null && code !== 0) {
640
- console.error(`ngrok exited with code ${code}.`);
641
- console.error(
642
- "Check that ngrok is authenticated: ngrok config add-authtoken <token>",
643
- );
677
+ // Captured per attempt so an authentication failure can be told apart
678
+ // from other startup failures and recovered with a pasted-in token.
679
+ const ngrokOutput: string[] = [];
680
+
681
+ ngrokProcess.on("error", (err: Error) => {
682
+ console.error(`ngrok process error: ${err.message}`);
644
683
  process.exit(1);
684
+ });
685
+
686
+ // Pipe ngrok stdout/stderr to console for visibility
687
+ ngrokProcess.stdout?.on("data", (data: Buffer) => {
688
+ const line = data.toString().trim();
689
+ if (line) {
690
+ ngrokOutput.push(line);
691
+ console.log(`[ngrok] ${line}`);
692
+ }
693
+ });
694
+ ngrokProcess.stderr?.on("data", (data: Buffer) => {
695
+ const line = data.toString().trim();
696
+ if (line) {
697
+ ngrokOutput.push(line);
698
+ console.error(`[ngrok] ${line}`);
699
+ }
700
+ });
701
+
702
+ // An agent that exits during startup rejects this instead of leaving
703
+ // waitForNgrokUrl to poll a dead API for its full timeout.
704
+ const exitFailure = new Promise<never>((_, reject) => {
705
+ ngrokProcess.on("exit", (code: number | null) => {
706
+ if (code !== null && code !== 0) {
707
+ reject(new NgrokStartupError(code, ngrokOutput.join("\n")));
708
+ }
709
+ });
710
+ });
711
+
712
+ let url: string;
713
+ try {
714
+ url = await Promise.race([waitForNgrokUrl(port, domain), exitFailure]);
715
+ } catch (err) {
716
+ if (err instanceof NgrokStartupError && isNgrokAuthError(err.output)) {
717
+ if (authAttempts >= MAX_AUTH_RETRIES) {
718
+ console.error("\nngrok still rejects the tunnel as unauthenticated.");
719
+ console.error(
720
+ "Authenticate manually: ngrok config add-authtoken <your-token>",
721
+ );
722
+ console.error(
723
+ " Get your token at: https://dashboard.ngrok.com/get-started/your-authtoken",
724
+ );
725
+ process.exit(1);
726
+ }
727
+ console.log("");
728
+ console.log("⚠ ngrok rejected the tunnel: it isn't authenticated.");
729
+ console.log(
730
+ " Get your authtoken at: https://dashboard.ngrok.com/get-started/your-authtoken",
731
+ );
732
+ const token = (
733
+ await readLineText(
734
+ "Paste your ngrok authtoken to continue (Enter to cancel): ",
735
+ )
736
+ ).trim();
737
+ if (!token) {
738
+ console.log("No authtoken entered.");
739
+ process.exit(1);
740
+ }
741
+ authAttempts++;
742
+ if (saveNgrokAuthToken(token)) {
743
+ console.log("✅ ngrok authtoken saved. Retrying the tunnel...");
744
+ }
745
+ continue;
746
+ }
747
+ cleanup();
748
+ if (domain && !opts.domain) {
749
+ console.error(savedDomainRecoveryHint(domain));
750
+ }
751
+ throw err;
645
752
  }
646
- });
753
+ publicUrl = url;
647
754
 
648
- // Pipe ngrok stdout/stderr to console for visibility
649
- ngrokProcess.stdout?.on("data", (data: Buffer) => {
650
- const line = data.toString().trim();
651
- if (line) console.log(`[ngrok] ${line}`);
652
- });
653
- ngrokProcess.stderr?.on("data", (data: Buffer) => {
654
- const line = data.toString().trim();
655
- if (line) console.error(`[ngrok] ${line}`);
656
- });
755
+ console.log("");
756
+ console.log(`Tunnel established: ${publicUrl}`);
757
+ console.log(`Forwarding to: localhost:${port}`);
657
758
 
658
- try {
659
- publicUrl = await waitForNgrokUrl(port, domain);
660
- } catch (err) {
661
- cleanup();
662
- if (domain && !opts.domain) {
663
- console.error(savedDomainRecoveryHint(domain));
759
+ // The domain is standing intent, not tunnel state: cleanup clears the
760
+ // ingress URL but leaves the domain saved for wake/daemon restores.
761
+ saveIngressUrl(workspaceDir, publicUrl, opts.assistantId, "ngrok");
762
+ if (ngrokProcess.pid) {
763
+ stampRootProcess("tunnel", {
764
+ pid: ngrokProcess.pid,
765
+ provider: "ngrok",
766
+ startedAt: new Date().toISOString(),
767
+ assistantId: opts.assistantId,
768
+ });
664
769
  }
665
- throw err;
666
- }
770
+ if (opts.domain) {
771
+ saveNgrokDomain(opts.domain);
772
+ }
773
+ console.log("Ingress URL saved to config.");
774
+ console.log("");
775
+ console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
667
776
 
668
- console.log("");
669
- console.log(`Tunnel established: ${publicUrl}`);
670
- console.log(`Forwarding to: localhost:${port}`);
671
-
672
- // The domain is standing intent, not tunnel state: cleanup clears the
673
- // ingress URL but leaves the domain saved for wake/daemon restores.
674
- saveIngressUrl(workspaceDir, publicUrl, opts.assistantId, "ngrok");
675
- if (ngrokProcess.pid) {
676
- stampRootProcess("tunnel", {
677
- pid: ngrokProcess.pid,
678
- provider: "ngrok",
679
- startedAt: new Date().toISOString(),
680
- assistantId: opts.assistantId,
777
+ // Keep running until the ngrok process exits or we receive a signal
778
+ const waitForExit = new Promise<void>((resolve) => {
779
+ ngrokProcess.on("exit", () => resolve());
681
780
  });
781
+ return { publicUrl, waitForExit };
682
782
  }
683
- if (opts.domain) {
684
- saveNgrokDomain(opts.domain);
685
- }
686
- console.log("Ingress URL saved to config.");
687
- console.log("");
688
- console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
689
-
690
- // Keep running until the ngrok process exits or we receive a signal
691
- const waitForExit = new Promise<void>((resolve) => {
692
- ngrokProcess.on("exit", () => resolve());
693
- });
694
- return { publicUrl, waitForExit };
783
+ // Unreachable: the loop only exits via return, process.exit, or a throw.
784
+ throw new Error("ngrok tunnel did not start.");
695
785
  }
@@ -4,6 +4,7 @@ import { spawnSync } from "node:child_process";
4
4
  import { win32 } from "node:path";
5
5
 
6
6
  import { GATEWAY_PORT } from "../constants.js";
7
+ import { ensureBinaryInstalled } from "../ensure-binary.js";
7
8
  import {
8
9
  clearIngressUrl,
9
10
  getDefaultWorkspaceDir,
@@ -54,7 +55,7 @@ export function getTailscaleInstallMessage(
54
55
  hostPlatform === "win32"
55
56
  ? " Windows: winget install Tailscale.Tailscale"
56
57
  : hostPlatform === "darwin"
57
- ? " macOS: brew install tailscale (or install the Tailscale app)"
58
+ ? " macOS: brew install tailscale, or install the Tailscale app"
58
59
  : " Linux: https://tailscale.com/download/linux";
59
60
  return [
60
61
  "Tailscale is not installed or could not be found.",
@@ -79,12 +80,12 @@ export interface TailscaleCommandResult {
79
80
  */
80
81
  export interface TailscaleDeps {
81
82
  /** Locate the tailscale CLI; returns the invocable path, null if absent. */
82
- findBinary(): string | null;
83
+ findBinary(): Promise<string | null>;
83
84
  /** Run `<bin> <args>` synchronously (spawnSync semantics). */
84
85
  run(bin: string, args: string[]): TailscaleCommandResult;
85
86
  }
86
87
 
87
- function realFindBinary(): string | null {
88
+ function probeTailscaleBinary(): string | null {
88
89
  for (const candidate of getTailscaleBinaryCandidates()) {
89
90
  const res = spawnSync(candidate, ["version"], {
90
91
  encoding: "utf-8",
@@ -99,6 +100,35 @@ function realFindBinary(): string | null {
99
100
  return null;
100
101
  }
101
102
 
103
+ /**
104
+ * The real binary finder offers to install tailscale when it is missing
105
+ * (prompt-install via the shared helper; no-op hint on a non-interactive
106
+ * terminal) and re-probes after.
107
+ */
108
+ async function realFindBinary(): Promise<string | null> {
109
+ const found = probeTailscaleBinary();
110
+ if (found) return found;
111
+ await ensureBinaryInstalled({
112
+ binary: "tailscale",
113
+ purpose: "the tailscale tunnel",
114
+ packages: {
115
+ brew: ["brew", "install", "tailscale"],
116
+ winget: [
117
+ "winget",
118
+ "install",
119
+ "--silent",
120
+ "--accept-package-agreements",
121
+ "--accept-source-agreements",
122
+ "Tailscale.Tailscale",
123
+ ],
124
+ choco: ["choco", "install", "-y", "tailscale"],
125
+ },
126
+ manualHint: getTailscaleInstallMessage(),
127
+ probe: () => probeTailscaleBinary(),
128
+ });
129
+ return probeTailscaleBinary();
130
+ }
131
+
102
132
  function realRun(bin: string, args: string[]): TailscaleCommandResult {
103
133
  const res = spawnSync(bin, args, {
104
134
  encoding: "utf-8",
@@ -292,7 +322,7 @@ export async function startTailscaleServe(
292
322
  opts: RunTailscaleTunnelOptions = {},
293
323
  deps: TailscaleDeps = realTailscaleDeps(),
294
324
  ): Promise<TailscaleServeInfo> {
295
- const binary = deps.findBinary();
325
+ const binary = await deps.findBinary();
296
326
  if (!binary) {
297
327
  throw new Error(getTailscaleInstallMessage());
298
328
  }
@@ -17,6 +17,7 @@ import {
17
17
  dockerResourceNames,
18
18
  GATEWAY_INTERNAL_PORT,
19
19
  ASSISTANT_INTERNAL_PORT,
20
+ pullImage,
20
21
  startContainers,
21
22
  stopContainers,
22
23
  } from "./docker.js";
@@ -40,7 +41,7 @@ import {
40
41
  type DockerStatefulSetSpec,
41
42
  type ServiceName,
42
43
  } from "./statefulset.js";
43
- import { exec, execOutput } from "./step-runner.js";
44
+ import { execOutput } from "./step-runner.js";
44
45
  import { compareVersions, stripVersionPrefix } from "./version-compat.js";
45
46
  import { loopbackSafeFetch } from "./loopback-fetch.js";
46
47
 
@@ -418,7 +419,7 @@ export async function waitForReady(runtimeUrl: string): Promise<boolean> {
418
419
  const parts = [body?.status].filter(Boolean);
419
420
  if (body?.upstream != null) parts.push(`upstream=${body.upstream}`);
420
421
  const detail = parts.length > 0 ? ` — ${parts.join(", ")}` : "";
421
- console.log(`Readiness check: ${resp.status}${detail} (retrying...)`);
422
+ console.log(`Readiness check: ${resp.status}${detail}, retrying...`);
422
423
  } catch {
423
424
  // Connection refused / timeout — not up yet
424
425
  }
@@ -754,7 +755,7 @@ export async function performDockerRollback(
754
755
  console.log(` Backup saved: ${preRollbackBackupPath}\n`);
755
756
  pruneOldBackups(entry.assistantId, 3);
756
757
  } else {
757
- console.warn("⚠️ Pre-rollback backup failed (continuing with rollback)\n");
758
+ console.warn("⚠️ Pre-rollback backup failed, continuing with rollback\n");
758
759
  }
759
760
 
760
761
  const {
@@ -806,7 +807,7 @@ export async function performDockerRollback(
806
807
  try {
807
808
  for (const [service, image] of pullImages) {
808
809
  console.log(` Pulling ${service}: ${image}`);
809
- await exec("docker", ["pull", image]);
810
+ await pullImage(image);
810
811
  }
811
812
  } catch (pullErr) {
812
813
  const detail = pullErr instanceof Error ? pullErr.message : String(pullErr);