@rrlab/cli 1.1.0 → 1.1.1-git-4903a88.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.
Files changed (110) hide show
  1. package/bin +3 -5
  2. package/dist/cli.usage.kdl +26 -25
  3. package/dist/config.d.mts +1 -1
  4. package/dist/magic-string.es-BgIV5Mu3.mjs +1011 -0
  5. package/dist/plugin/__tests__/bin-probe.test.d.mts +1 -0
  6. package/dist/plugin/__tests__/bin-probe.test.mjs +64 -0
  7. package/dist/plugin/__tests__/decide-scaffold.test.d.mts +1 -0
  8. package/dist/plugin/__tests__/decide-scaffold.test.mjs +103 -0
  9. package/dist/plugin/__tests__/define-plugin.test.d.mts +1 -0
  10. package/dist/plugin/__tests__/define-plugin.test.mjs +130 -0
  11. package/dist/plugin/__tests__/pick-preset.test.d.mts +1 -0
  12. package/dist/plugin/__tests__/pick-preset.test.mjs +72 -0
  13. package/dist/plugin/__tests__/registry.test.d.mts +1 -0
  14. package/dist/plugin/__tests__/registry.test.mjs +104 -0
  15. package/dist/plugin/bin-probe.d.mts +4 -0
  16. package/dist/plugin/bin-probe.mjs +22 -0
  17. package/dist/plugin/decide-scaffold.d.mts +18 -0
  18. package/dist/plugin/decide-scaffold.mjs +36 -0
  19. package/dist/plugin/define-plugin.d.mts +17 -0
  20. package/dist/plugin/define-plugin.mjs +25 -0
  21. package/dist/plugin/directory.d.mts +47 -0
  22. package/dist/plugin/directory.mjs +45 -0
  23. package/dist/plugin/errors.d.mts +11 -0
  24. package/dist/plugin/errors.mjs +15 -0
  25. package/dist/plugin/index.d.mts +7 -0
  26. package/dist/plugin/index.mjs +50 -0
  27. package/dist/plugin/pick-preset.d.mts +13 -0
  28. package/dist/plugin/pick-preset.mjs +17 -0
  29. package/dist/plugin/registry.d.mts +19 -0
  30. package/dist/plugin/registry.mjs +2 -0
  31. package/dist/plugin/tool-service.d.mts +45 -0
  32. package/dist/plugin/tool-service.mjs +64 -0
  33. package/dist/plugin/types.d.mts +3 -0
  34. package/dist/plugin/types.mjs +1 -0
  35. package/dist/registry-BgqfKK5L.mjs +55 -0
  36. package/dist/run.mjs +969 -585
  37. package/dist/test.DNmyFkvJ-09ScyH13.mjs +13617 -0
  38. package/dist/tool-DKL6TauZ.d.mts +43 -0
  39. package/dist/{types-snfbujDH.d.mts → types-Iu4IyWof.d.mts} +11 -75
  40. package/package.json +6 -5
  41. package/src/actions/clean.ts +36 -0
  42. package/src/actions/config.ts +46 -0
  43. package/src/actions/doctor.ts +47 -0
  44. package/src/actions/format.ts +13 -0
  45. package/src/actions/jsc.ts +13 -0
  46. package/src/actions/lint.ts +13 -0
  47. package/src/actions/pack.ts +12 -0
  48. package/src/actions/plugins/add.ts +143 -0
  49. package/src/actions/plugins/list.ts +27 -0
  50. package/src/actions/plugins/remove.ts +110 -0
  51. package/src/actions/plugins/shared.ts +58 -0
  52. package/src/actions/run-tool.ts +23 -0
  53. package/src/actions/tsc.ts +65 -0
  54. package/src/errors/invalid-plugin-module.ts +6 -0
  55. package/src/errors/missing-plugin.ts +17 -0
  56. package/src/errors/plugin-api-version.ts +6 -0
  57. package/src/errors/unknown-plugin.ts +7 -0
  58. package/src/lib/plugin/define-plugin.ts +56 -0
  59. package/src/lib/plugin/directory.ts +30 -0
  60. package/src/lib/plugin/errors.ts +15 -0
  61. package/src/lib/{plugin.ts → plugin/index.ts} +8 -9
  62. package/src/lib/plugin/registry.ts +82 -0
  63. package/src/{plugin → lib/plugin}/tool-service.ts +10 -14
  64. package/src/{plugin → lib/plugin}/types.ts +10 -33
  65. package/src/program/base.ts +75 -0
  66. package/src/program/commands/check.ts +31 -62
  67. package/src/program/commands/clean.ts +12 -43
  68. package/src/program/commands/completion.ts +6 -4
  69. package/src/program/commands/config.ts +6 -11
  70. package/src/program/commands/doctor.ts +5 -54
  71. package/src/program/commands/format.ts +18 -25
  72. package/src/program/commands/jscheck.ts +18 -31
  73. package/src/program/commands/lint.ts +18 -26
  74. package/src/program/commands/pack.ts +18 -22
  75. package/src/program/commands/plugins.ts +17 -364
  76. package/src/program/commands/tscheck.ts +19 -77
  77. package/src/program/index.ts +20 -27
  78. package/src/program/root.ts +62 -0
  79. package/src/render/banner.ts +25 -0
  80. package/src/render/board.ts +41 -0
  81. package/src/render/footer.ts +31 -0
  82. package/src/render/labels.ts +28 -0
  83. package/src/render/lines.ts +100 -0
  84. package/src/render/plugin-view.ts +68 -0
  85. package/src/render/steps.ts +20 -0
  86. package/src/run.ts +2 -8
  87. package/src/services/config.ts +4 -0
  88. package/src/services/context.ts +84 -0
  89. package/src/services/file-ops.ts +79 -0
  90. package/src/services/json-edit.ts +1 -1
  91. package/src/services/plugin-meta.ts +63 -0
  92. package/src/services/plugin-services.ts +41 -0
  93. package/src/services/prompts.ts +1 -1
  94. package/src/services/static-checker.ts +46 -0
  95. package/src/types/config.ts +2 -1
  96. package/src/types/tool.ts +13 -26
  97. package/src/ui/theme.ts +5 -0
  98. package/dist/plugin.d.mts +0 -87
  99. package/dist/plugin.mjs +0 -214
  100. package/src/plugin/define-plugin.ts +0 -54
  101. package/src/plugin/registry.ts +0 -48
  102. package/src/program/board.ts +0 -86
  103. package/src/program/composed-jsc.ts +0 -43
  104. package/src/program/missing-plugin.ts +0 -18
  105. package/src/program/ui.ts +0 -59
  106. package/src/services/ctx.ts +0 -71
  107. package/src/services/plugins-registry.ts +0 -22
  108. /package/src/{plugin → lib/plugin}/bin-probe.ts +0 -0
  109. /package/src/{plugin → lib/plugin}/decide-scaffold.ts +0 -0
  110. /package/src/{plugin → lib/plugin}/pick-preset.ts +0 -0
@@ -0,0 +1,41 @@
1
+ import { type BoardOptions, type BoardResult, type BoardTask, runTaskBoard } from "@vlandoss/clibuddy";
2
+ import type { RunReport } from "#src/types/tool.ts";
3
+
4
+ export type { BoardResult, BoardTask };
5
+
6
+ /** Bridges a check-family verb (returns a `RunReport`) to a board row, its `output` becoming the flushed detail. */
7
+ export function reportTask(label: string, run: () => Promise<RunReport>): BoardTask {
8
+ return {
9
+ label,
10
+ async run() {
11
+ const report = await run();
12
+ return { ok: report.ok, detail: report.output };
13
+ },
14
+ };
15
+ }
16
+
17
+ // While `rr check` is dispatching, boards stay framed (to divide the sections)
18
+ // and their results land in this collector so `check` can print one verdict.
19
+ let collector: BoardResult[] | null = null;
20
+
21
+ export async function runCheckSections(run: () => Promise<void>): Promise<BoardResult[]> {
22
+ const previous = collector;
23
+ collector = [];
24
+ try {
25
+ await run();
26
+ return collector;
27
+ } finally {
28
+ collector = previous;
29
+ }
30
+ }
31
+
32
+ /** Runs the rows on the board and returns whether every row passed. */
33
+ export async function runBoard(tasks: BoardTask[], options: BoardOptions = {}): Promise<BoardResult> {
34
+ const sink = collector;
35
+ const result = await runTaskBoard(tasks, { ...options, frame: options.frame ?? (sink !== null || undefined) });
36
+ // Record into the active check collector synchronously (we already awaited the
37
+ // board), so it's populated before our caller's `await runBoard(...)` resolves
38
+ // — no microtask race with the section's own continuation.
39
+ if (sink) sink.push(result);
40
+ return result;
41
+ }
@@ -0,0 +1,31 @@
1
+ import { palette } from "@vlandoss/clibuddy";
2
+ import { Lines } from "#src/render/lines.ts";
3
+ import type { ContextValue } from "#src/services/context.ts";
4
+ import { SEP } from "#src/ui/theme.ts";
5
+ import { availableCell, installedCell, partitionPlugins, relPath } from "./plugin-view.ts";
6
+
7
+ export function getFooterText(ctx: ContextValue): string {
8
+ const { installed, available } = partitionPlugins(ctx);
9
+
10
+ const installedLine = installed.map(installedCell).join(" ");
11
+ const availableLine = available.map(availableCell).join(" ");
12
+ const fromLine = ctx.config.meta.filepath
13
+ ? palette.dim(`from ${relPath(ctx, ctx.config.meta.filepath)}`)
14
+ : palette.dim("(no run-run.config — using defaults)");
15
+
16
+ const lines = new Lines();
17
+
18
+ lines.newline().add(palette.bold("Plugins:"));
19
+
20
+ if (installed.length > 0) {
21
+ lines.add(`${palette.bold("installed:")} ${installedLine}${SEP}${fromLine}`, 2);
22
+ } else {
23
+ lines.add(`${palette.bold("installed:")} ${palette.dim("(none)")}${SEP}${fromLine}`, 2);
24
+ }
25
+
26
+ if (available.length > 0) {
27
+ lines.add(`${palette.bold("available:")} ${availableLine}${SEP}${palette.dim("install with `rr plugins add <name>`")}`, 2);
28
+ }
29
+
30
+ return lines.render();
31
+ }
@@ -0,0 +1,28 @@
1
+ import { basename } from "node:path";
2
+ import { type Pkg, palette } from "@vlandoss/clibuddy";
3
+
4
+ export type Provider = { ui: string };
5
+
6
+ /** `<command> (<tool>)` — the verb plus the `ui` of the tool that backs it (e.g. `lint (biome)`, `tsc (tsc)`). */
7
+ function commandTool(command: string, provider: Provider): string {
8
+ return `${command} (${provider.ui})`;
9
+ }
10
+
11
+ function pkgName(appPkg: Pkg): string {
12
+ return appPkg.packageJson.name ?? basename(appPkg.dirPath);
13
+ }
14
+
15
+ /** The canonical single-target row label, `<command> (<tool>) · <package>`, so every command reads alike. */
16
+ export function targetLabel(command: string, provider: Provider, appPkg: Pkg): string {
17
+ return `${commandTool(command, provider)} ${palette.dim(`· ${pkgName(appPkg)}`)}`;
18
+ }
19
+
20
+ /**
21
+ * The canonical fan-out section title, `<command> (<tool>) · <n> <unit>`. The
22
+ * tool is omitted when the fan-out spans several tools (`rr doctor` → `doctor ·
23
+ * 3 tools`), since the rows then carry the per-tool name.
24
+ */
25
+ export function fanoutTitle(command: string, provider: Provider | undefined, count: number, unit: string): string {
26
+ const head = provider ? commandTool(command, provider) : command;
27
+ return `${head} · ${count} ${unit}`;
28
+ }
@@ -0,0 +1,100 @@
1
+ import stringWidth from "fast-string-width";
2
+
3
+ type Align = "left" | "right";
4
+
5
+ interface Column<T> {
6
+ key: keyof T;
7
+ align?: Align;
8
+ }
9
+
10
+ interface TableOptions {
11
+ gap?: number;
12
+ padStart?: number;
13
+ }
14
+
15
+ export class Lines {
16
+ #lines: string[];
17
+
18
+ constructor() {
19
+ this.#lines = [];
20
+ }
21
+
22
+ isEmpty() {
23
+ return !this.#lines.length;
24
+ }
25
+
26
+ add(data: string | string[], padStart = 0) {
27
+ if (Array.isArray(data)) {
28
+ data.forEach((it) => {
29
+ this.#append(it, padStart);
30
+ });
31
+ } else {
32
+ this.#append(data, padStart);
33
+ }
34
+ return this;
35
+ }
36
+
37
+ addTable<T extends Record<string, unknown>>(rows: T[], columns: Column<T>[], opts: TableOptions = {}) {
38
+ const { gap = 3, padStart = 0 } = opts;
39
+
40
+ const sized = columns.map((col) => ({
41
+ ...col,
42
+ width: Math.max(...rows.map((row) => stringWidth(String(row[col.key])))),
43
+ }));
44
+ const sep = this.#sep(gap);
45
+
46
+ rows.forEach((row) => {
47
+ const cells = sized.map((col) => {
48
+ const raw = String(row[col.key]);
49
+ return this.#padCell(raw, col.width, col.align ?? "left");
50
+ });
51
+
52
+ this.#append(cells.join(sep), padStart);
53
+ });
54
+
55
+ return this;
56
+ }
57
+
58
+ newline(prepend = false) {
59
+ if (prepend) {
60
+ this.#prepend("");
61
+ } else {
62
+ this.#append("");
63
+ }
64
+ return this;
65
+ }
66
+
67
+ printStdout() {
68
+ process.stdout.write(`${this.render()}\n`);
69
+ }
70
+
71
+ render() {
72
+ return this.#lines.join("\n");
73
+ }
74
+
75
+ #padCell(str: string, width: number, align: Align) {
76
+ const diff = Math.max(0, width - stringWidth(str));
77
+ const fill = this.#sep(diff);
78
+ return align === "right" ? `${fill}${str}` : `${str}${fill}`;
79
+ }
80
+
81
+ #append(str: string, padStart = 0) {
82
+ if (padStart > 0) {
83
+ this.#lines.push(`${this.#sep(padStart)}${str}`);
84
+ } else {
85
+ this.#lines.push(str);
86
+ }
87
+ }
88
+
89
+ #prepend(str: string, padStart = 0) {
90
+ if (padStart > 0) {
91
+ this.#lines.unshift(`${this.#sep(padStart)}${str}`);
92
+ } else {
93
+ this.#lines.unshift(str);
94
+ }
95
+ }
96
+
97
+ #sep(count: number) {
98
+ return " ".repeat(count);
99
+ }
100
+ }
@@ -0,0 +1,68 @@
1
+ import path from "node:path";
2
+ import { palette } from "@vlandoss/clibuddy";
3
+ import { allPluginNames, isPluginName, PLUGINS_DIRECTORY } from "#src/lib/plugin/directory.ts";
4
+ import type { ContextValue } from "#src/services/context.ts";
5
+ import { readPluginMeta } from "#src/services/plugin-meta.ts";
6
+ import type { Plugin } from "../lib/plugin/index.ts";
7
+
8
+ /**
9
+ * The single source of truth for how a plugin renders across the UI. The two
10
+ * plugin screens (the root help footer and `rr config`) stay separate
11
+ * composing functions, but every painted plugin cell comes from here — so a
12
+ * color/dot/version-format change happens in one place.
13
+ */
14
+
15
+ /** Path of `abs` relative to the host project root, or `abs` itself when already there. */
16
+ export function relPath(ctx: ContextValue, abs: string): string {
17
+ const rel = path.relative(ctx.appPkg.dirPath, abs);
18
+ return rel === "" ? abs : rel;
19
+ }
20
+
21
+ /** The plugin names present in the host's `run-run.config`, in config-file order. */
22
+ export function configuredPlugins(ctx: ContextValue) {
23
+ return ctx.config.config.plugins ?? [];
24
+ }
25
+
26
+ /**
27
+ * Partition every plugin name into installed (configured) vs available (not yet
28
+ * configured), preserving `PLUGINS_DIRECTORY` declaration order — the order the
29
+ * root footer renders both rows in.
30
+ */
31
+ export function partitionPlugins(ctx: ContextValue): { installed: Plugin[]; available: string[] } {
32
+ const configured = configuredPlugins(ctx);
33
+
34
+ const present = Object.fromEntries(configured.map((it) => [it.name, true]));
35
+
36
+ const available = allPluginNames().filter((name) => !present[name]);
37
+
38
+ return {
39
+ available,
40
+ installed: configured,
41
+ };
42
+ }
43
+
44
+ /** `● <name>` — a configured plugin in the root footer. */
45
+ export function installedCell(plugin: Plugin): string {
46
+ return `${plugin.color("●")} ${plugin.name}`;
47
+ }
48
+
49
+ /** `○ <name>` (dim) — an available-but-unconfigured plugin in the root footer. */
50
+ export function availableCell(label: string): string {
51
+ return `${palette.dim("○")} ${palette.dim(label)}`;
52
+ }
53
+
54
+ /** The npm package for a (kernel-trusted) plugin name — official lookup, else the `@rrlab/<name>-plugin` convention. */
55
+ function pkgOf(name: string): string {
56
+ return isPluginName(name) ? PLUGINS_DIRECTORY[name].pkg : `@rrlab/${name}-plugin`;
57
+ }
58
+
59
+ /** The plugin's npm package name (dim) — `rr config` table column. */
60
+ export function pkgCell(name: string): string {
61
+ return palette.dim(pkgOf(name));
62
+ }
63
+
64
+ /** `v<plugin-version>` or `v?` — `rr config` table column. */
65
+ export function pluginVersionCell(name: string, appDir: string): string {
66
+ const { pluginVersion } = readPluginMeta(pkgOf(name), appDir);
67
+ return pluginVersion ? palette.success(`v${pluginVersion}`) : palette.dim("v?");
68
+ }
@@ -0,0 +1,20 @@
1
+ import * as clack from "@clack/prompts";
2
+
3
+ /**
4
+ * Runs `fn` under a clack spinner: the message stays on success, and is
5
+ * suffixed with `— failed` (error level) before the error re-throws. Used by
6
+ * the interactive `plugins add/remove` flows to frame each install/uninstall
7
+ * step.
8
+ */
9
+ export async function withSpinner<T>(message: string, fn: () => Promise<T>): Promise<T> {
10
+ const sp = clack.spinner();
11
+ sp.start(message);
12
+ try {
13
+ const result = await fn();
14
+ sp.stop(message);
15
+ return result;
16
+ } catch (err) {
17
+ sp.stop(`${message} — failed`, 1);
18
+ throw err;
19
+ }
20
+ }
package/src/run.ts CHANGED
@@ -1,11 +1,5 @@
1
- import path from "node:path";
2
- import { dirnameOf, run } from "@vlandoss/clibuddy";
3
1
  import { createProgram } from "./program/index.ts";
4
- import { logger } from "./services/logger.ts";
5
2
 
6
- const BIN_DIR = path.dirname(dirnameOf(import.meta));
3
+ const program = await createProgram(import.meta);
7
4
 
8
- await run(async () => {
9
- const { program } = await createProgram({ binDir: BIN_DIR });
10
- await program.parseAsync(process.argv, { from: "node" });
11
- }, logger);
5
+ await program.run();
@@ -25,7 +25,9 @@ export class ConfigService {
25
25
  async load(): Promise<ExportedConfig> {
26
26
  const debug = logger.subdebug("load-config");
27
27
 
28
+ const start = performance.now();
28
29
  const searchResult = await this.#searcher.search();
30
+ const loadMs = performance.now() - start;
29
31
 
30
32
  if (!searchResult || searchResult?.isEmpty) {
31
33
  debug("loaded default config: %O", DEFAULT_CONFIG);
@@ -34,6 +36,7 @@ export class ConfigService {
34
36
  meta: {
35
37
  isDefault: true,
36
38
  filepath: undefined,
39
+ loadMs,
37
40
  },
38
41
  };
39
42
  }
@@ -48,6 +51,7 @@ export class ConfigService {
48
51
  meta: {
49
52
  isDefault: false,
50
53
  filepath: searchResult.filepath,
54
+ loadMs,
51
55
  },
52
56
  };
53
57
  }
@@ -0,0 +1,84 @@
1
+ import fs from "node:fs";
2
+ import { createPkg, createShellService, cwd, type Pkg, type ShellService } from "@vlandoss/clibuddy";
3
+ import { PluginApiVersionError } from "#src/errors/plugin-api-version.ts";
4
+ import { PluginRegistry } from "#src/lib/plugin/registry.ts";
5
+ import type { PluginContext } from "#src/lib/plugin/types.ts";
6
+ import type { ExportedConfig } from "#src/types/config.ts";
7
+ import { ConfigService } from "./config.ts";
8
+ import { logger } from "./logger.ts";
9
+ import { PluginServices } from "./plugin-services.ts";
10
+
11
+ export type ContextValue = {
12
+ binPkg: Pkg;
13
+ appPkg: Pkg;
14
+ shell: ShellService;
15
+ config: ExportedConfig;
16
+ plugins: PluginServices;
17
+ };
18
+
19
+ export class ContextService {
20
+ #binDir: string;
21
+
22
+ constructor(binDir: string) {
23
+ this.#binDir = binDir;
24
+ }
25
+
26
+ async getContext(): Promise<ContextValue> {
27
+ const debug = logger.subdebug("create-context");
28
+
29
+ const binPath = fs.realpathSync(this.#binDir);
30
+
31
+ debug("bin path:", binPath);
32
+ debug("process cwd:", process.cwd());
33
+
34
+ const [appPkg, binPkg] = await Promise.all([createPkg(cwd), createPkg(binPath)]);
35
+
36
+ if (!binPkg) {
37
+ throw new Error("Could not find bin package.json");
38
+ }
39
+
40
+ if (!appPkg) {
41
+ throw new Error("Could not find app package.json");
42
+ }
43
+
44
+ debug("app pkg info: %O", appPkg.info());
45
+ debug("bin pkg info: %O", binPkg.info());
46
+
47
+ const shell = createShellService();
48
+
49
+ debug("shell service options: %O", shell.options);
50
+
51
+ const configService = new ConfigService();
52
+ const config = await configService.load();
53
+
54
+ const registry = new PluginRegistry();
55
+
56
+ const pluginContext: PluginContext = {
57
+ shell,
58
+ logger,
59
+ appPkg,
60
+ binPkg,
61
+ cwd,
62
+ };
63
+
64
+ for (const plugin of config.config.plugins ?? []) {
65
+ const got = plugin.apiVersion as number;
66
+ if (got !== 1) {
67
+ throw new PluginApiVersionError(plugin.name, got);
68
+ }
69
+ debug("registering plugin: %s", plugin.name);
70
+ const services = await plugin.services(pluginContext);
71
+ registry.register(plugin, services);
72
+ }
73
+
74
+ const plugins = new PluginServices(registry);
75
+
76
+ return {
77
+ appPkg,
78
+ binPkg,
79
+ shell,
80
+ config,
81
+ plugins,
82
+ };
83
+ }
84
+ }
@@ -0,0 +1,79 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { FileOp } from "#src/lib/plugin/types.ts";
4
+ import { applyJsonEdits } from "./json-edit.ts";
5
+
6
+ /**
7
+ * The semantic outcome of applying one `FileOp`. The engine performs the
8
+ * filesystem work and reports what happened — it never writes to the terminal.
9
+ * Callers (the `plugins add/remove` flows) map the outcome to their own
10
+ * progress UI, which keeps this engine pure and unit-testable without spawning.
11
+ */
12
+ export type FileOpOutcome =
13
+ | { op: "create"; path: string; status: "created" | "overwritten" | "skipped-exists" }
14
+ | { op: "edit"; path: string; status: "edited" | "unchanged" | "missing" }
15
+ | { op: "delete"; path: string; status: "deleted" | "absent" };
16
+
17
+ /**
18
+ * Applies a single declarative `FileOp` under `cwd`. `force` overrides the
19
+ * `create` overwrite guard. Idempotent for `delete` (absent file is a no-op)
20
+ * and skips edits when the target file is missing.
21
+ */
22
+ export async function applyFileOp(cwd: string, op: FileOp, force: boolean): Promise<FileOpOutcome> {
23
+ const abs = path.join(cwd, op.path);
24
+
25
+ if (op.kind === "create") {
26
+ const exists = await pathExists(abs);
27
+ if (exists && !op.overwrite && !force) {
28
+ return { op: "create", path: op.path, status: "skipped-exists" };
29
+ }
30
+ await fs.mkdir(path.dirname(abs), { recursive: true });
31
+ await fs.writeFile(abs, op.content, "utf8");
32
+ return { op: "create", path: op.path, status: exists ? "overwritten" : "created" };
33
+ }
34
+
35
+ if (op.kind === "edit-json") {
36
+ if (!(await pathExists(abs))) return { op: "edit", path: op.path, status: "missing" };
37
+ const source = await fs.readFile(abs, "utf8");
38
+ const next = applyJsonEdits(source, op.edits);
39
+ if (next === source) return { op: "edit", path: op.path, status: "unchanged" };
40
+ await fs.writeFile(abs, next, "utf8");
41
+ return { op: "edit", path: op.path, status: "edited" };
42
+ }
43
+
44
+ if (op.kind === "edit-text") {
45
+ if (!(await pathExists(abs))) return { op: "edit", path: op.path, status: "missing" };
46
+ const source = await fs.readFile(abs, "utf8");
47
+ const next = op.edit(source);
48
+ if (next === source) return { op: "edit", path: op.path, status: "unchanged" };
49
+ await fs.writeFile(abs, next, "utf8");
50
+ return { op: "edit", path: op.path, status: "edited" };
51
+ }
52
+
53
+ if (!(await pathExists(abs))) return { op: "delete", path: op.path, status: "absent" };
54
+ await fs.unlink(abs);
55
+ return { op: "delete", path: op.path, status: "deleted" };
56
+ }
57
+
58
+ /** A one-line, side-effect-free description of a `FileOp` for the remove plan. */
59
+ export function describeFileOp(op: FileOp): string {
60
+ switch (op.kind) {
61
+ case "create":
62
+ return `${op.overwrite ? "Overwrite" : "Create"} ${op.path}`;
63
+ case "edit-json":
64
+ return `Edit ${op.path} (${op.edits.length} change${op.edits.length === 1 ? "" : "s"})`;
65
+ case "edit-text":
66
+ return `Edit ${op.path}`;
67
+ case "delete":
68
+ return `Delete ${op.path}`;
69
+ }
70
+ }
71
+
72
+ async function pathExists(p: string): Promise<boolean> {
73
+ try {
74
+ await fs.access(p);
75
+ return true;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
@@ -1,5 +1,5 @@
1
1
  import * as cjson from "comment-json";
2
- import type { JsonEdit } from "#src/plugin/types.ts";
2
+ import type { JsonEdit } from "#src/lib/plugin/types.ts";
3
3
 
4
4
  /**
5
5
  * Applies a sequence of `JsonEdit` ops to a JSON / JSONC source string.
@@ -0,0 +1,63 @@
1
+ import fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+
5
+ const require = createRequire(import.meta.url);
6
+
7
+ export type PluginMeta = {
8
+ /** Plugin npm package name, e.g. `@rrlab/biome-plugin`. */
9
+ pkgName: string;
10
+ /** Plugin npm package version, e.g. `1.1.0`. `undefined` when not resolvable. */
11
+ pluginVersion?: string;
12
+ };
13
+
14
+ /** Resolves the installed version of a plugin's npm package from the host's `node_modules`. */
15
+ export function readPluginMeta(pkgName: string, fromDir: string): PluginMeta {
16
+ const result: PluginMeta = { pkgName };
17
+ const manifest = readPackageJson(pkgName, fromDir);
18
+ if (!manifest) return result;
19
+
20
+ result.pluginVersion = typeof manifest.version === "string" ? manifest.version : undefined;
21
+ return result;
22
+ }
23
+
24
+ type Manifest = {
25
+ version?: string;
26
+ };
27
+
28
+ function readPackageJson(pkgName: string, fromDir: string): Manifest | undefined {
29
+ try {
30
+ const resolved = require.resolve(`${pkgName}/package.json`, { paths: [fromDir] });
31
+ return JSON.parse(fs.readFileSync(resolved, "utf8")) as Manifest;
32
+ } catch {
33
+ // Some packages don't export `./package.json`. Fall back to walking up from
34
+ // the main entry.
35
+ try {
36
+ const entry = require.resolve(pkgName, { paths: [fromDir] });
37
+ const found = findPackageJsonUpwards(entry, pkgName);
38
+ return found ? (JSON.parse(fs.readFileSync(found, "utf8")) as Manifest) : undefined;
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ }
44
+
45
+ function findPackageJsonUpwards(file: string, pkgName: string): string | undefined {
46
+ let dir = path.dirname(file);
47
+ // Walk up until we find a package.json whose `name` matches `pkgName`.
48
+ for (let i = 0; i < 12; i += 1) {
49
+ const candidate = path.join(dir, "package.json");
50
+ if (fs.existsSync(candidate)) {
51
+ try {
52
+ const manifest = JSON.parse(fs.readFileSync(candidate, "utf8")) as { name?: string };
53
+ if (manifest.name === pkgName) return candidate;
54
+ } catch {
55
+ // ignore malformed package.json and keep walking
56
+ }
57
+ }
58
+ const parent = path.dirname(dir);
59
+ if (parent === dir) break;
60
+ dir = parent;
61
+ }
62
+ return undefined;
63
+ }
@@ -0,0 +1,41 @@
1
+ import { MissingPluginError } from "#src/errors/missing-plugin.ts";
2
+ import type { PluginRegistry } from "../lib/plugin/registry.ts";
3
+ import type { PluginCapability } from "../lib/plugin/types.ts";
4
+ import { StaticCheckService } from "./static-checker.ts";
5
+
6
+ export class PluginServices {
7
+ #registry: PluginRegistry;
8
+
9
+ constructor(registry: PluginRegistry) {
10
+ this.#registry = registry;
11
+ }
12
+
13
+ getAllServices() {
14
+ return this.#registry.getAllServices();
15
+ }
16
+
17
+ providerOf<K extends PluginCapability>(capability: K) {
18
+ return this.#registry.providerOf(capability);
19
+ }
20
+
21
+ getServiceOrThrow<K extends PluginCapability>(capability: K) {
22
+ return this.#registry.getServiceOrThrow(capability);
23
+ }
24
+
25
+ getJsChecker() {
26
+ const checker = this.#registry.getService("jscheck");
27
+
28
+ if (checker) {
29
+ return checker;
30
+ }
31
+
32
+ const linter = this.#registry.getService("lint");
33
+ const formatter = this.#registry.getService("format");
34
+
35
+ if (linter && formatter) {
36
+ return new StaticCheckService(linter, formatter);
37
+ }
38
+
39
+ throw new MissingPluginError("jscheck");
40
+ }
41
+ }
@@ -1,5 +1,5 @@
1
1
  import * as clack from "@clack/prompts";
2
- import type { ClackPrompts } from "#src/plugin/types.ts";
2
+ import type { ClackPrompts } from "#src/lib/plugin/types.ts";
3
3
 
4
4
  /**
5
5
  * Adapter that exposes the subset of `@clack/prompts` matching the
@@ -0,0 +1,46 @@
1
+ import type { Doctor, Formatter, Linter, RunReport, StaticChecker, StaticCheckerOptions } from "#src/types/tool.ts";
2
+
3
+ export class StaticCheckService implements StaticChecker, Doctor {
4
+ #linter: Linter & Doctor;
5
+ #formatter: Formatter & Doctor;
6
+
7
+ get ui() {
8
+ return `${this.#linter.ui} + ${this.#formatter.ui}`;
9
+ }
10
+
11
+ constructor(linter: Linter & Doctor, formatter: Formatter & Doctor) {
12
+ this.#linter = linter;
13
+ this.#formatter = formatter;
14
+ }
15
+
16
+ async check(options: StaticCheckerOptions): Promise<RunReport> {
17
+ const lintReport = await this.#linter.lint(options);
18
+ const formatReport = await this.#formatter.format(options);
19
+
20
+ return this.#mergeReports([
21
+ { ui: this.#linter.ui, report: lintReport },
22
+ { ui: this.#formatter.ui, report: formatReport },
23
+ ]);
24
+ }
25
+
26
+ async doctor(): Promise<RunReport> {
27
+ const [lintRes, fmtRes] = await Promise.all([this.#linter.doctor(), this.#formatter.doctor()]);
28
+
29
+ return this.#mergeReports([
30
+ { ui: this.#linter.ui, report: lintRes },
31
+ { ui: this.#formatter.ui, report: fmtRes },
32
+ ]);
33
+ }
34
+
35
+ #mergeReports(parts: Array<{ ui: string; report: RunReport }>): RunReport {
36
+ const sections = parts
37
+ .filter((part) => part.report.output.trim())
38
+ .map((part) => `${part.ui}:\n${part.report.output}`)
39
+ .join("\n\n");
40
+
41
+ return {
42
+ ok: parts.every((part) => part.report.ok),
43
+ output: sections,
44
+ };
45
+ }
46
+ }
@@ -1,4 +1,4 @@
1
- import type { Plugin } from "#src/plugin/types.ts";
1
+ import type { Plugin } from "#src/lib/plugin/types.ts";
2
2
 
3
3
  export type UserConfig = {
4
4
  plugins?: Plugin[];
@@ -8,6 +8,7 @@ export type ExportedConfig = {
8
8
  config: UserConfig;
9
9
  meta: {
10
10
  isDefault: boolean;
11
+ loadMs: number;
11
12
  filepath?: string;
12
13
  };
13
14
  };