@rrlab/cli 1.0.0 → 1.0.1-git-908d2c0.0

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.
@@ -27,6 +27,17 @@ declare class ReleaseService {
27
27
  }
28
28
  //#endregion
29
29
  //#region src/types/tool.d.ts
30
+ /**
31
+ * The outcome of a check-family tool (lint / format / static check / type
32
+ * check) captured rather than streamed. `ok` is the tool's exit code — never a
33
+ * guess parsed from output, since tool summaries are unstable and not uniform
34
+ * (tsc and oxfmt emit none) — and `output` is the combined stdout+stderr (color
35
+ * preserved), flushed verbatim under the package label. See decisions/013.
36
+ */
37
+ type RunReport = {
38
+ ok: boolean;
39
+ output: string;
40
+ };
30
41
  type FormatOptions = {
31
42
  fix?: boolean;
32
43
  };
@@ -37,33 +48,29 @@ type StaticCheckerOptions = {
37
48
  fix?: boolean;
38
49
  fixStaged?: boolean;
39
50
  };
40
- type DoctorOutput = {
41
- stdout: string;
42
- stderr: string;
43
- exitCode: number | undefined;
44
- };
45
- type DoctorResult = {
46
- ok: boolean;
47
- output: DoctorOutput;
48
- };
49
51
  type Doctor = {
50
52
  ui: string;
51
- doctor: () => Promise<DoctorResult>;
53
+ /**
54
+ * Verifies the tool is wired correctly. Returns a `RunReport` like every
55
+ * other verb so the board renders it identically — `output` leads with the
56
+ * `$ <bin> --help` liveness command, plus the error if the bin won't run.
57
+ */
58
+ doctor: () => Promise<RunReport>;
52
59
  };
53
60
  type Formatter = {
54
61
  bin: string;
55
62
  ui: string;
56
- format: (options: FormatOptions) => Promise<void>;
63
+ format: (options: FormatOptions) => Promise<RunReport>;
57
64
  };
58
65
  type Linter = {
59
66
  bin: string;
60
67
  ui: string;
61
- lint: (options: LintOptions) => Promise<void>;
68
+ lint: (options: LintOptions) => Promise<RunReport>;
62
69
  };
63
70
  type StaticChecker = {
64
71
  bin: string;
65
72
  ui: string;
66
- check: (options: StaticCheckerOptions) => Promise<void>;
73
+ check: (options: StaticCheckerOptions) => Promise<RunReport>;
67
74
  };
68
75
  type TypeCheckOptions = {
69
76
  /** Where to run the type checker. Defaults to the kernel's `cwd`. */cwd?: string;
@@ -71,14 +78,14 @@ type TypeCheckOptions = {
71
78
  type TypeChecker = {
72
79
  bin: string;
73
80
  ui: string;
74
- check: (options?: TypeCheckOptions) => Promise<void>;
81
+ check: (options?: TypeCheckOptions) => Promise<RunReport>;
75
82
  };
76
83
  //#endregion
77
84
  //#region src/plugin/types.d.ts
78
85
  type Packer = {
79
86
  bin: string;
80
87
  ui: string;
81
- pack: () => Promise<void>;
88
+ pack: () => Promise<RunReport>;
82
89
  };
83
90
  declare const PLUGIN_KINDS: readonly ["lint", "format", "jsc", "tsc", "pack"];
84
91
  type PluginKind = (typeof PLUGIN_KINDS)[number];
@@ -202,4 +209,4 @@ type UninstallResult = {
202
209
  files?: FileOp[];
203
210
  };
204
211
  //#endregion
205
- export { Linter as C, TypeChecker as D, TypeCheckOptions as E, ReleaseService as O, LintOptions as S, StaticCheckerOptions as T, Doctor as _, InstallFlags as a, FormatOptions as b, PLUGIN_KINDS as c, PluginCapabilities as d, PluginContext as f, UninstallResult as g, UninstallFlags as h, InstallContext as i, ReleaseServiceOptions as k, Packer as l, UninstallContext as m, ClackPromptsSelectOption as n, InstallResult as o, PluginKind as p, FileOp as r, JsonEdit as s, ClackPrompts as t, Plugin as u, DoctorOutput as v, StaticChecker as w, Formatter as x, DoctorResult as y };
212
+ export { StaticChecker as C, ReleaseService as D, TypeChecker as E, ReleaseServiceOptions as O, RunReport as S, TypeCheckOptions as T, Doctor as _, InstallFlags as a, LintOptions as b, PLUGIN_KINDS as c, PluginCapabilities as d, PluginContext as f, UninstallResult as g, UninstallFlags as h, InstallContext as i, Packer as l, UninstallContext as m, ClackPromptsSelectOption as n, InstallResult as o, PluginKind as p, FileOp as r, JsonEdit as s, ClackPrompts as t, Plugin as u, FormatOptions as v, StaticCheckerOptions as w, Linter as x, Formatter as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rrlab/cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.1-git-908d2c0.0",
4
4
  "description": "The CLI toolbox to fullstack common scripts in Variable Land",
5
5
  "homepage": "https://github.com/variableland/dx/tree/main/run-run/cli#readme",
6
6
  "bugs": {
@@ -56,8 +56,8 @@
56
56
  "memoize": "10.2.0",
57
57
  "nypm": "0.6.0",
58
58
  "rimraf": "6.1.3",
59
- "@vlandoss/clibuddy": "0.6.1",
60
- "@vlandoss/loggy": "0.2.1"
59
+ "@vlandoss/loggy": "0.2.1",
60
+ "@vlandoss/clibuddy": "0.6.2-git-908d2c0.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "tsdown": "0.22.0",
package/src/lib/plugin.ts CHANGED
@@ -6,8 +6,6 @@ export type {
6
6
  ClackPrompts,
7
7
  ClackPromptsSelectOption,
8
8
  Doctor,
9
- DoctorOutput,
10
- DoctorResult,
11
9
  FileOp,
12
10
  FormatOptions,
13
11
  Formatter,
@@ -22,6 +20,7 @@ export type {
22
20
  PluginCapabilities,
23
21
  PluginContext,
24
22
  PluginKind,
23
+ RunReport,
25
24
  StaticChecker,
26
25
  StaticCheckerOptions,
27
26
  TypeChecker,
@@ -1,5 +1,5 @@
1
- import { resolvePackageBin, type ShellService } from "@vlandoss/clibuddy";
2
- import type { DoctorResult } from "#src/types/tool.ts";
1
+ import { palette, resolvePackageBin, type ShellService } from "@vlandoss/clibuddy";
2
+ import type { RunReport } from "#src/types/tool.ts";
3
3
 
4
4
  export type ToolServiceOptions = {
5
5
  pkg: string;
@@ -16,9 +16,8 @@ export type ToolServiceOptions = {
16
16
  from: string;
17
17
  };
18
18
 
19
- export type ExecOptions = {
19
+ export type RunReportOptions = {
20
20
  cwd?: string;
21
- verbose?: boolean;
22
21
  };
23
22
 
24
23
  export class ToolService {
@@ -55,23 +54,44 @@ export class ToolService {
55
54
  });
56
55
  }
57
56
 
58
- async exec(args: string[] = [], options: ExecOptions = {}) {
59
- const { cwd, verbose } = options;
60
- const sh = cwd ? this.#shellService.at(cwd) : this.#shellService;
61
- return sh.run(await this.getBinDir(), args, { display: this.#bin, verbose });
57
+ /**
58
+ * Runs the tool capturing its output instead of streaming it, and reports the
59
+ * verdict straight from the exit code — never a guess parsed from the output.
60
+ * The board needs the capture to attribute each parallel run's output to its
61
+ * package; the non-zero exit is returned (not thrown) so every task settles
62
+ * and the caller can aggregate. See `decisions/013-check-stream-to-capture-contract.md`.
63
+ */
64
+ async runReport(args: string[] = [], options: RunReportOptions = {}): Promise<RunReport> {
65
+ const sh = options.cwd ? this.#shellService.at(options.cwd) : this.#shellService;
66
+ const output = await sh.runCaptured(await this.getBinDir(), args, { throwOnError: false });
67
+ // Lead the captured output with the command line that ran — the same
68
+ // `$ <cmd>` the streaming path prints via `printCmdLine`, so it stays
69
+ // visible even when captured. It's dim so it reads as context, not result.
70
+ const header = palette.dim(`$ ${[this.#bin, ...args].join(" ")}`);
71
+ const body = combine(output.stdout, output.stderr);
72
+ // Strict `=== 0`: a missing exit code (signal-killed, e.g. OOM) is a
73
+ // failure, not a pass — `?? 0` would silently report a crashed tool green.
74
+ return { ok: output.exitCode === 0, output: body ? `${header}\n${body}` : header };
62
75
  }
63
76
 
64
- async doctor(): Promise<DoctorResult> {
77
+ async doctor(): Promise<RunReport> {
65
78
  const output = await this.#shellService.runCaptured(await this.getBinDir(), ["--help"], { throwOnError: false });
66
79
  const ok = output.exitCode === 0;
67
-
68
- return {
69
- ok,
70
- output: {
71
- stdout: output.stdout,
72
- stderr: output.stderr,
73
- exitCode: output.exitCode,
74
- },
75
- };
80
+ // Same shape as the other verbs: lead with the `$ <bin> --help` liveness
81
+ // command (the tool's full help text is noise on success, so it's dropped);
82
+ // on failure surface whatever the bin printed — stdout AND stderr — so the
83
+ // reason it won't run is visible.
84
+ const command = palette.dim(`$ ${this.#bin} --help`);
85
+ if (ok) return { ok, output: command };
86
+ const detail = combine(output.stdout, output.stderr);
87
+ return { ok, output: detail ? `${command}\n${detail}` : command };
76
88
  }
77
89
  }
90
+
91
+ /** Joins the non-empty, trimmed streams of a captured run. */
92
+ function combine(stdout: string | undefined, stderr: string | undefined): string {
93
+ return [stdout, stderr]
94
+ .map((stream) => stream?.trim())
95
+ .filter(Boolean)
96
+ .join("\n");
97
+ }
@@ -1,16 +1,15 @@
1
1
  import type { Pkg, ShellService } from "@vlandoss/clibuddy";
2
2
  import type { AnyLogger as Logger } from "@vlandoss/loggy";
3
3
  import type { ReleaseService } from "#src/services/release.ts";
4
- import type { Doctor, Formatter, Linter, StaticChecker, TypeChecker } from "#src/types/tool.ts";
4
+ import type { Doctor, Formatter, Linter, RunReport, StaticChecker, TypeChecker } from "#src/types/tool.ts";
5
5
 
6
6
  export type {
7
7
  Doctor,
8
- DoctorOutput,
9
- DoctorResult,
10
8
  FormatOptions,
11
9
  Formatter,
12
10
  Linter,
13
11
  LintOptions,
12
+ RunReport,
14
13
  StaticChecker,
15
14
  StaticCheckerOptions,
16
15
  TypeChecker,
@@ -20,7 +19,7 @@ export type {
20
19
  export type Packer = {
21
20
  bin: string;
22
21
  ui: string;
23
- pack: () => Promise<void>;
22
+ pack: () => Promise<RunReport>;
24
23
  };
25
24
 
26
25
  export const PLUGIN_KINDS = ["lint", "format", "jsc", "tsc", "pack"] as const;
@@ -0,0 +1,86 @@
1
+ import { basename } from "node:path";
2
+ import { type BoardOptions, type BoardResult, type BoardTask, type Pkg, palette, runTaskBoard } from "@vlandoss/clibuddy";
3
+ import type { PluginKind, RunReport } from "#src/plugin/types.ts";
4
+ import type { Context } from "#src/services/ctx.ts";
5
+ import { missingPluginError } from "./missing-plugin.ts";
6
+
7
+ export type { BoardResult, BoardTask };
8
+
9
+ type Provider = { bin?: string; ui: string };
10
+
11
+ /** `<command> (<tool>)`, deduped to just `<command>` when the tool's binary is the command itself (e.g. `tsc`). */
12
+ function commandTool(command: string, provider: Provider): string {
13
+ return provider.bin === command ? command : `${command} (${provider.ui})`;
14
+ }
15
+
16
+ function pkgName(appPkg: Pkg): string {
17
+ return appPkg.packageJson.name ?? basename(appPkg.dirPath);
18
+ }
19
+
20
+ /** The canonical single-target row label, `<command> (<tool>) · <package>`, so every command reads alike. */
21
+ export function targetLabel(command: string, provider: Provider, appPkg: Pkg): string {
22
+ return `${commandTool(command, provider)} ${palette.dim(`· ${pkgName(appPkg)}`)}`;
23
+ }
24
+
25
+ /**
26
+ * The canonical fan-out section title, `<command> (<tool>) · <n> <unit>`. The
27
+ * tool is omitted when the fan-out spans several tools (`rr doctor` → `doctor ·
28
+ * 3 tools`), since the rows then carry the per-tool name.
29
+ */
30
+ export function fanoutTitle(command: string, provider: Provider | undefined, count: number, unit: string): string {
31
+ const head = provider ? commandTool(command, provider) : command;
32
+ return `${head} · ${count} ${unit}`;
33
+ }
34
+
35
+ /** Bridges a check-family verb (returns a `RunReport`) to a board row, its `output` becoming the flushed detail. */
36
+ export function reportTask(label: string, run: () => Promise<RunReport>): BoardTask {
37
+ return {
38
+ label,
39
+ async run() {
40
+ const report = await run();
41
+ return { ok: report.ok, detail: report.output };
42
+ },
43
+ };
44
+ }
45
+
46
+ // While `rr check` is dispatching, boards stay framed (to divide the sections)
47
+ // and their results land in this collector so `check` can print one verdict.
48
+ let collector: BoardResult[] | null = null;
49
+
50
+ export async function runCheckSections(run: () => Promise<void>): Promise<BoardResult[]> {
51
+ const previous = collector;
52
+ collector = [];
53
+ try {
54
+ await run();
55
+ return collector;
56
+ } finally {
57
+ collector = previous;
58
+ }
59
+ }
60
+
61
+ /** Runs the rows on the board and returns whether every row passed. */
62
+ export async function runBoard(tasks: BoardTask[], options: BoardOptions = {}): Promise<BoardResult> {
63
+ const sink = collector;
64
+ const result = await runTaskBoard(tasks, { ...options, frame: options.frame ?? (sink !== null || undefined) });
65
+ // Record into the active check collector synchronously (we already awaited the
66
+ // board), so it's populated before our caller's `await runBoard(...)` resolves
67
+ // — no microtask race with the section's own continuation.
68
+ if (sink) sink.push(result);
69
+ return result;
70
+ }
71
+
72
+ /**
73
+ * The shared action body for a single-provider tool command (lint, format, jsc,
74
+ * pack): require the provider, run its verb as one board row labelled
75
+ * `<name> (<tool>) · <pkg>`, and aggregate the exit code. Commands that fan out
76
+ * (tsc) or compose siblings (check) call `runBoard` directly instead.
77
+ */
78
+ export async function runToolCommand<P extends Provider>(
79
+ ctx: Context,
80
+ spec: { name: string; kind: PluginKind; provider: P | undefined; run: (provider: P) => Promise<RunReport> },
81
+ ): Promise<void> {
82
+ const { provider } = spec;
83
+ if (!provider) throw missingPluginError(spec.kind);
84
+ const result = await runBoard([reportTask(targetLabel(spec.name, provider, ctx.appPkg), () => spec.run(provider))]);
85
+ if (!result.ok) process.exitCode = 1;
86
+ }
@@ -1,26 +1,22 @@
1
+ import { palette } from "@vlandoss/clibuddy";
1
2
  import { type Command, createCommand } from "commander";
3
+ import { runCheckSections } from "#src/program/board.ts";
2
4
  import { pluginAnnotation } from "#src/program/ui.ts";
3
5
  import type { Context } from "#src/services/ctx.ts";
4
6
  import { logger } from "#src/services/logger.ts";
5
7
 
6
8
  /**
7
- * `rr check` is the umbrella that runs the JS check (lint+format) and the
8
- * TS type check together. Both subcommands are already wired into
9
- * commander as siblings (`rr jsc`, `rr tsc`), so we reuse the program's
10
- * command tree as the action registry instead of duplicating it: look the
11
- * sibling up by name and invoke its action via `parseAsync([])`, which
12
- * applies its declared option defaults exactly as if the user had typed
13
- * `rr jsc` directly.
14
- *
15
- * Commander binds the running command as `this` inside an action (see
16
- * `command.js` — `fn.apply(this, actionArgs)`). `this.parent` gives us the
17
- * parent program without any late-binding ceremony.
9
+ * `rr check` runs `jsc` then `tsc`. Rather than keep a parallel action
10
+ * registry, it reuses commander's command tree: it finds each sibling on
11
+ * `this.parent` and runs it via `parseAsync([])`, which applies the sibling's
12
+ * own option defaults. (`this` is the running command inside a non-arrow
13
+ * action see cli/CLAUDE.md.)
18
14
  */
19
15
  export function createCheckCommand(ctx: Context) {
20
16
  return createCommand("check")
21
17
  .summary(`run static checks${checkAnnotation(ctx)}`)
22
18
  .description(
23
- "Runs `rr jsc` and `rr tsc` concurrently in-process. Aggregates their exit codes — non-zero when either subcommand fails.",
19
+ "Runs `rr jsc` then `rr tsc` in-process, each as its own section. Aggregates their exit codes — non-zero when either subcommand fails.",
24
20
  )
25
21
  .action(async function checkAction(this: Command) {
26
22
  const program = this.parent;
@@ -30,46 +26,60 @@ export function createCheckCommand(ctx: Context) {
30
26
  throw new Error("`rr check` requires the parent program to dispatch sibling subcommands.");
31
27
  }
32
28
 
33
- const targets = ["jsc", "tsc"];
34
- const cmds = targets.map((name) => ({ name, cmd: findCommand(program, name) }));
35
-
36
- const missing = cmds.filter(({ cmd }) => !cmd).map(({ name }) => name);
37
- if (missing.length > 0) {
38
- for (const name of missing) logger.error(`rr check: subcommand "${name}" is not registered.`);
39
- process.exitCode = 1;
40
- return;
41
- }
42
-
43
- const results = await Promise.allSettled(
44
- // biome-ignore lint/style/noNonNullAssertion: missing is guarded above
45
- cmds.map(({ cmd }) => cmd!.parseAsync([], { from: "user" })),
46
- );
47
-
48
- const failed: Array<{ name: string; reason: unknown }> = [];
49
- for (const [i, r] of results.entries()) {
50
- if (r.status === "rejected") failed.push({ name: cmds[i]?.name ?? "?", reason: r.reason });
51
- }
52
- if (failed.length > 0) {
53
- for (const { name, reason } of failed) {
54
- const msg = reason instanceof Error ? reason.message : String(reason);
55
- logger.error(`rr check (${name}): ${msg}`);
29
+ // Sequentially, not in parallel: two live boards can't animate the same
30
+ // terminal region at once (decision 012). Each section runs in its own
31
+ // `runCheckSections` scope, which frames it and returns the boards it
32
+ // rendered so a failure is attributed by section name, not a fragile
33
+ // dispatch-vs-render index.
34
+ const start = Date.now();
35
+ const failed: string[] = [];
36
+ let rendered = false;
37
+ for (const name of ["jsc", "tsc"]) {
38
+ const cmd = findCommand(program, name);
39
+ if (!cmd) {
40
+ logger.error(`rr check: subcommand "${name}" is not registered.`);
41
+ failed.push(name);
42
+ continue;
56
43
  }
57
- process.exitCode = 1;
44
+ if (rendered) process.stderr.write("\n"); // one blank line between sections
45
+ let threw = false;
46
+ const results = await runCheckSections(async () => {
47
+ try {
48
+ await cmd.parseAsync([], { from: "user" });
49
+ } catch (reason) {
50
+ logger.error(`rr check (${name}): ${reason instanceof Error ? reason.message : String(reason)}`);
51
+ threw = true;
52
+ }
53
+ });
54
+ if (threw || results.some((r) => !r.ok)) failed.push(name);
55
+ rendered = true;
58
56
  }
57
+
58
+ // One overall verdict so the bottom of the scroll always answers "did
59
+ // check pass?" — a green section summary can otherwise be the last line
60
+ // of a run that failed in the section above it.
61
+ process.stderr.write(`\n${checkVerdict(failed, Date.now() - start)}\n`);
62
+ if (failed.length > 0) process.exitCode = 1;
59
63
  });
60
64
  }
61
65
 
66
+ function checkVerdict(failed: string[], ms: number): string {
67
+ const elapsed = palette.dim(ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`);
68
+ const sep = palette.dim(" · ");
69
+ if (failed.length > 0) {
70
+ return `${palette.error("✖")} check failed${sep}${[...new Set(failed)].join(", ")}${sep}${elapsed}`;
71
+ }
72
+ return `${palette.success("✔")} check passed${sep}${elapsed}`;
73
+ }
74
+
62
75
  function findCommand(program: Command, name: string): Command | undefined {
63
76
  return program.commands.find((c) => c.name() === name || c.aliases().includes(name));
64
77
  }
65
78
 
66
79
  /**
67
- * Mirrors the provider resolution of `jsc` + `tsc` and flattens the
68
- * underlying tool labels — e.g. biome (composed lint+format) + oxc (tsc)
69
- * renders as `(biome, oxlint)` rather than `(biome + biome, oxlint)`. When
70
- * neither sibling has a provider, falls back to the standard `(not
71
- * configured)` annotation so the help reads consistently with other
72
- * commands.
80
+ * Flattens the underlying tool labels of `jsc` + `tsc` for the help summary —
81
+ * e.g. `(biome, oxlint)`, deduped, not `(biome + biome, oxlint)`. Falls back to
82
+ * the standard `(not configured)` when neither sibling has a provider.
73
83
  */
74
84
  function checkAnnotation(ctx: Context): string {
75
85
  const directJsc = ctx.registry.get("jsc");
@@ -1,29 +1,23 @@
1
+ import type { Pkg } from "@vlandoss/clibuddy";
1
2
  import { createCommand } from "commander";
2
- import type { Doctor, DoctorResult } from "#src/plugin/types.ts";
3
+ import type { Doctor } from "#src/plugin/types.ts";
3
4
  import { PLUGIN_KINDS } from "#src/plugin/types.ts";
4
5
  import type { Context } from "#src/services/ctx.ts";
5
6
  import { logger } from "#src/services/logger.ts";
7
+ import { fanoutTitle, reportTask, runBoard, targetLabel } from "../board.ts";
6
8
 
7
9
  /**
8
10
  * Subcommand factory used by every plugin-backed command (lint, format, jsc,
9
- * tsc, pack) to expose a `doctor` subcommand that verifies the underlying
10
- * tool is wired correctly. Each calls this with its own provider.
11
+ * tsc, pack) to expose a `doctor` subcommand that verifies the underlying tool
12
+ * is wired correctly. Renders the canonical `doctor (<tool>) · <pkg>` row like
13
+ * every other single-target command — `doctor()` returns a `RunReport`.
11
14
  */
12
- export function createDoctorSubcommand(service: Doctor) {
15
+ export function createDoctorSubcommand(service: Doctor, appPkg: Pkg) {
13
16
  return createCommand("doctor")
14
17
  .summary("check if the underlying tool is working correctly")
15
18
  .action(async function doctorAction() {
16
- const debug = logger.subdebug("doctor");
17
- const { ok, output } = await service.doctor();
18
-
19
- if (ok) {
20
- logger.success(`${service.ui} ok`);
21
- debug("%O", output);
22
- } else {
23
- logger.error(`${service.ui} not working`);
24
- debug("%O", output);
25
- process.exit(output.exitCode ?? 1);
26
- }
19
+ const result = await runBoard([reportTask(targetLabel("doctor", service, appPkg), () => service.doctor())]);
20
+ if (!result.ok) process.exitCode = 1;
27
21
  });
28
22
  }
29
23
 
@@ -46,27 +40,11 @@ export function createDoctorCommand(ctx: Context) {
46
40
  return;
47
41
  }
48
42
 
49
- const debug = logger.subdebug("doctor");
50
- const results = await Promise.all(
51
- services.map(async (svc) => {
52
- const result = await svc.doctor();
53
- return { svc, result };
54
- }),
55
- );
56
-
57
- let failures = 0;
58
- for (const { svc, result } of results) {
59
- if (result.ok) {
60
- logger.success(`${svc.ui} ok`);
61
- debug("%s: %O", svc.ui, result.output);
62
- } else {
63
- logger.error(`${svc.ui} not working`);
64
- debug("%s: %O", svc.ui, result.output);
65
- failures++;
66
- }
67
- }
68
-
69
- if (failures > 0) process.exitCode = 1;
43
+ // Each tool's health check is one parallel board row — a fan-out across
44
+ // tools, so the rows carry the tool name and the title omits a single tool.
45
+ const tasks = services.map((svc) => reportTask(svc.ui, () => svc.doctor()));
46
+ const result = await runBoard(tasks, { title: fanoutTitle("doctor", undefined, services.length, "tools") });
47
+ if (!result.ok) process.exitCode = 1;
70
48
  });
71
49
  }
72
50
 
@@ -81,5 +59,3 @@ function collectDistinctDoctors(ctx: Context): Doctor[] {
81
59
  }
82
60
  return [...seen];
83
61
  }
84
-
85
- export type { Doctor as _Doctor, DoctorResult as _DoctorResult };
@@ -1,6 +1,6 @@
1
1
  import { createCommand } from "commander";
2
2
  import type { Context } from "#src/services/ctx.ts";
3
- import { missingPluginError } from "../missing-plugin.ts";
3
+ import { runToolCommand } from "../board.ts";
4
4
  import { pluginAnnotation } from "../ui.ts";
5
5
  import { createDoctorSubcommand } from "./doctor.ts";
6
6
 
@@ -19,12 +19,11 @@ export function createFormatCommand(ctx: Context) {
19
19
  .option("--fix", "format all the code");
20
20
 
21
21
  if (formatter) {
22
- cmd.addCommand(createDoctorSubcommand(formatter));
22
+ cmd.addCommand(createDoctorSubcommand(formatter, ctx.appPkg));
23
23
  }
24
24
 
25
25
  cmd.action(async (options: ActionOptions = {}) => {
26
- if (!formatter) throw missingPluginError("format");
27
- await formatter.format(options);
26
+ await runToolCommand(ctx, { name: "format", kind: "format", provider: formatter, run: (p) => p.format(options) });
28
27
  });
29
28
 
30
29
  if (formatter) {
@@ -1,7 +1,7 @@
1
1
  import { createCommand } from "commander";
2
2
  import type { Context } from "#src/services/ctx.ts";
3
+ import { runToolCommand } from "../board.ts";
3
4
  import { composedJscProvider } from "../composed-jsc.ts";
4
- import { missingPluginError } from "../missing-plugin.ts";
5
5
  import { pluginAnnotation } from "../ui.ts";
6
6
  import { createDoctorSubcommand } from "./doctor.ts";
7
7
 
@@ -28,12 +28,11 @@ export function createJsCheckCommand(ctx: Context) {
28
28
  .option("--fix-staged", "try to fix staged files only");
29
29
 
30
30
  if (checker) {
31
- cmd.addCommand(createDoctorSubcommand(checker));
31
+ cmd.addCommand(createDoctorSubcommand(checker, ctx.appPkg));
32
32
  }
33
33
 
34
34
  cmd.action(async (options: ActionOptions = {}) => {
35
- if (!checker) throw missingPluginError("jsc");
36
- await checker.check(options);
35
+ await runToolCommand(ctx, { name: "jsc", kind: "jsc", provider: checker, run: (p) => p.check(options) });
37
36
  });
38
37
 
39
38
  if (checker) {
@@ -1,6 +1,6 @@
1
1
  import { createCommand } from "commander";
2
2
  import type { Context } from "#src/services/ctx.ts";
3
- import { missingPluginError } from "../missing-plugin.ts";
3
+ import { runToolCommand } from "../board.ts";
4
4
  import { pluginAnnotation } from "../ui.ts";
5
5
  import { createDoctorSubcommand } from "./doctor.ts";
6
6
 
@@ -21,12 +21,11 @@ export function createLintCommand(ctx: Context) {
21
21
  .option("--fix", "try to fix all the code");
22
22
 
23
23
  if (linter) {
24
- cmd.addCommand(createDoctorSubcommand(linter));
24
+ cmd.addCommand(createDoctorSubcommand(linter, ctx.appPkg));
25
25
  }
26
26
 
27
27
  cmd.action(async (options: ActionOptions = {}) => {
28
- if (!linter) throw missingPluginError("lint");
29
- await linter.lint(options);
28
+ await runToolCommand(ctx, { name: "lint", kind: "lint", provider: linter, run: (p) => p.lint(options) });
30
29
  });
31
30
 
32
31
  if (linter) {
@@ -1,6 +1,6 @@
1
1
  import { createCommand } from "commander";
2
2
  import type { Context } from "#src/services/ctx.ts";
3
- import { missingPluginError } from "../missing-plugin.ts";
3
+ import { runToolCommand } from "../board.ts";
4
4
  import { pluginAnnotation } from "../ui.ts";
5
5
  import { createDoctorSubcommand } from "./doctor.ts";
6
6
 
@@ -14,13 +14,12 @@ export function createPackCommand(ctx: Context) {
14
14
  );
15
15
 
16
16
  if (packer) {
17
- cmd.addCommand(createDoctorSubcommand(packer));
17
+ cmd.addCommand(createDoctorSubcommand(packer, ctx.appPkg));
18
18
  cmd.addHelpText("afterAll", `\nUnder the hood, this command uses the ${packer.ui} CLI to pack the project.`);
19
19
  }
20
20
 
21
21
  cmd.action(async () => {
22
- if (!packer) throw missingPluginError("pack");
23
- await packer.pack();
22
+ await runToolCommand(ctx, { name: "pack", kind: "pack", provider: packer, run: (p) => p.pack() });
24
23
  });
25
24
 
26
25
  return cmd;