@mycadet/cli 0.12.8 → 0.12.9-dev.202610131620.d833e01

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
@@ -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"),
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import type { LocalInstanceResources } from "../assistant-config.js";
4
+ import { gatewayCliInvocation } from "../local.js";
5
+
6
+ function fakeResources(instanceDir: string): LocalInstanceResources {
7
+ // gatewayCliInvocation only reads instanceDir; the rest of the resources
8
+ // shape is irrelevant here.
9
+ return { instanceDir } as unknown as LocalInstanceResources;
10
+ }
11
+
12
+ describe("gatewayCliInvocation", () => {
13
+ test("runs the gateway package's CLI entry through bun", () => {
14
+ const invocation = gatewayCliInvocation();
15
+ expect(invocation.args[0]).toBe("run");
16
+ expect(invocation.args[1]).toMatch(/gateway\/src\/cli\/index\.ts$/);
17
+ });
18
+
19
+ test("pins CADET_WORKSPACE_DIR to the instance dir for named instances", () => {
20
+ const invocation = gatewayCliInvocation(
21
+ fakeResources("/data/assistants/testy"),
22
+ );
23
+ expect(invocation.env.CADET_WORKSPACE_DIR).toBe(
24
+ "/data/assistants/testy/workspace",
25
+ );
26
+ });
27
+
28
+ test("pins GATEWAY_SECURITY_DIR to the instance dir for named instances", () => {
29
+ const invocation = gatewayCliInvocation(
30
+ fakeResources("/data/assistants/testy"),
31
+ );
32
+ expect(invocation.env.GATEWAY_SECURITY_DIR).toBe(
33
+ "/data/assistants/testy/gateway-security",
34
+ );
35
+ });
36
+
37
+ test("leaves the workspace resolution untouched for the default instance", () => {
38
+ delete process.env.CADET_WORKSPACE_DIR;
39
+ const invocation = gatewayCliInvocation();
40
+ expect(invocation.env.CADET_WORKSPACE_DIR).toBeUndefined();
41
+ });
42
+ });
@@ -604,7 +604,6 @@ export interface CredentialInjectionParams {
604
604
  bearerToken?: string;
605
605
  assistantApiKey?: string | null;
606
606
  cloudAssistantId: string;
607
- cloudBaseUrl: string;
608
607
  organizationId: string;
609
608
  userId?: string;
610
609
  webhookSecret?: string | null;
@@ -631,8 +630,6 @@ export async function injectCredentialsIntoAssistant(
631
630
  promises.push(inject("cadet:assistant_api_key", params.assistantApiKey));
632
631
  }
633
632
 
634
- promises.push(inject("cadet:platform_base_url", params.cloudBaseUrl));
635
-
636
633
  if (params.webhookSecret) {
637
634
  promises.push(inject("cadet:webhook_secret", params.webhookSecret));
638
635
  }
@@ -409,7 +409,6 @@ export async function registerLocalAssistantWithPlatform(
409
409
  bearerToken: options.bearerToken ?? entry.bearerToken,
410
410
  assistantApiKey,
411
411
  cloudAssistantId: registration.assistant.id,
412
- cloudBaseUrl: cloudUrl,
413
412
  organizationId: orgId,
414
413
  userId: user.id,
415
414
  webhookSecret: registration.webhook_secret,
@@ -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/color.ts CHANGED
@@ -14,3 +14,7 @@ export function red(text: string, stream?: NodeJS.WriteStream): string {
14
14
  export function yellow(text: string, stream?: NodeJS.WriteStream): string {
15
15
  return colorize("\x1b[33m", text, stream);
16
16
  }
17
+
18
+ export function green(text: string, stream?: NodeJS.WriteStream): string {
19
+ return colorize("\x1b[32m", text, stream);
20
+ }