@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.
- package/bin +3 -5
- package/dist/cli.usage.kdl +26 -25
- package/dist/config.d.mts +1 -1
- package/dist/magic-string.es-BgIV5Mu3.mjs +1011 -0
- package/dist/plugin/__tests__/bin-probe.test.d.mts +1 -0
- package/dist/plugin/__tests__/bin-probe.test.mjs +64 -0
- package/dist/plugin/__tests__/decide-scaffold.test.d.mts +1 -0
- package/dist/plugin/__tests__/decide-scaffold.test.mjs +103 -0
- package/dist/plugin/__tests__/define-plugin.test.d.mts +1 -0
- package/dist/plugin/__tests__/define-plugin.test.mjs +130 -0
- package/dist/plugin/__tests__/pick-preset.test.d.mts +1 -0
- package/dist/plugin/__tests__/pick-preset.test.mjs +72 -0
- package/dist/plugin/__tests__/registry.test.d.mts +1 -0
- package/dist/plugin/__tests__/registry.test.mjs +104 -0
- package/dist/plugin/bin-probe.d.mts +4 -0
- package/dist/plugin/bin-probe.mjs +22 -0
- package/dist/plugin/decide-scaffold.d.mts +18 -0
- package/dist/plugin/decide-scaffold.mjs +36 -0
- package/dist/plugin/define-plugin.d.mts +17 -0
- package/dist/plugin/define-plugin.mjs +25 -0
- package/dist/plugin/directory.d.mts +47 -0
- package/dist/plugin/directory.mjs +45 -0
- package/dist/plugin/errors.d.mts +11 -0
- package/dist/plugin/errors.mjs +15 -0
- package/dist/plugin/index.d.mts +7 -0
- package/dist/plugin/index.mjs +50 -0
- package/dist/plugin/pick-preset.d.mts +13 -0
- package/dist/plugin/pick-preset.mjs +17 -0
- package/dist/plugin/registry.d.mts +19 -0
- package/dist/plugin/registry.mjs +2 -0
- package/dist/plugin/tool-service.d.mts +45 -0
- package/dist/plugin/tool-service.mjs +64 -0
- package/dist/plugin/types.d.mts +3 -0
- package/dist/plugin/types.mjs +1 -0
- package/dist/registry-BgqfKK5L.mjs +55 -0
- package/dist/run.mjs +969 -585
- package/dist/test.DNmyFkvJ-09ScyH13.mjs +13617 -0
- package/dist/tool-DKL6TauZ.d.mts +43 -0
- package/dist/{types-snfbujDH.d.mts → types-Iu4IyWof.d.mts} +11 -75
- package/package.json +6 -5
- package/src/actions/clean.ts +36 -0
- package/src/actions/config.ts +46 -0
- package/src/actions/doctor.ts +47 -0
- package/src/actions/format.ts +13 -0
- package/src/actions/jsc.ts +13 -0
- package/src/actions/lint.ts +13 -0
- package/src/actions/pack.ts +12 -0
- package/src/actions/plugins/add.ts +143 -0
- package/src/actions/plugins/list.ts +27 -0
- package/src/actions/plugins/remove.ts +110 -0
- package/src/actions/plugins/shared.ts +58 -0
- package/src/actions/run-tool.ts +23 -0
- package/src/actions/tsc.ts +65 -0
- package/src/errors/invalid-plugin-module.ts +6 -0
- package/src/errors/missing-plugin.ts +17 -0
- package/src/errors/plugin-api-version.ts +6 -0
- package/src/errors/unknown-plugin.ts +7 -0
- package/src/lib/plugin/define-plugin.ts +56 -0
- package/src/lib/plugin/directory.ts +30 -0
- package/src/lib/plugin/errors.ts +15 -0
- package/src/lib/{plugin.ts → plugin/index.ts} +8 -9
- package/src/lib/plugin/registry.ts +82 -0
- package/src/{plugin → lib/plugin}/tool-service.ts +10 -14
- package/src/{plugin → lib/plugin}/types.ts +10 -33
- package/src/program/base.ts +75 -0
- package/src/program/commands/check.ts +31 -62
- package/src/program/commands/clean.ts +12 -43
- package/src/program/commands/completion.ts +6 -4
- package/src/program/commands/config.ts +6 -11
- package/src/program/commands/doctor.ts +5 -54
- package/src/program/commands/format.ts +18 -25
- package/src/program/commands/jscheck.ts +18 -31
- package/src/program/commands/lint.ts +18 -26
- package/src/program/commands/pack.ts +18 -22
- package/src/program/commands/plugins.ts +17 -364
- package/src/program/commands/tscheck.ts +19 -77
- package/src/program/index.ts +20 -27
- package/src/program/root.ts +62 -0
- package/src/render/banner.ts +25 -0
- package/src/render/board.ts +41 -0
- package/src/render/footer.ts +31 -0
- package/src/render/labels.ts +28 -0
- package/src/render/lines.ts +100 -0
- package/src/render/plugin-view.ts +68 -0
- package/src/render/steps.ts +20 -0
- package/src/run.ts +2 -8
- package/src/services/config.ts +4 -0
- package/src/services/context.ts +84 -0
- package/src/services/file-ops.ts +79 -0
- package/src/services/json-edit.ts +1 -1
- package/src/services/plugin-meta.ts +63 -0
- package/src/services/plugin-services.ts +41 -0
- package/src/services/prompts.ts +1 -1
- package/src/services/static-checker.ts +46 -0
- package/src/types/config.ts +2 -1
- package/src/types/tool.ts +13 -26
- package/src/ui/theme.ts +5 -0
- package/dist/plugin.d.mts +0 -87
- package/dist/plugin.mjs +0 -214
- package/src/plugin/define-plugin.ts +0 -54
- package/src/plugin/registry.ts +0 -48
- package/src/program/board.ts +0 -86
- package/src/program/composed-jsc.ts +0 -43
- package/src/program/missing-plugin.ts +0 -18
- package/src/program/ui.ts +0 -59
- package/src/services/ctx.ts +0 -71
- package/src/services/plugins-registry.ts +0 -22
- /package/src/{plugin → lib/plugin}/bin-probe.ts +0 -0
- /package/src/{plugin → lib/plugin}/decide-scaffold.ts +0 -0
- /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
|
|
3
|
+
const program = await createProgram(import.meta);
|
|
7
4
|
|
|
8
|
-
await run(
|
|
9
|
-
const { program } = await createProgram({ binDir: BIN_DIR });
|
|
10
|
-
await program.parseAsync(process.argv, { from: "node" });
|
|
11
|
-
}, logger);
|
|
5
|
+
await program.run();
|
package/src/services/config.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/services/prompts.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/types/config.ts
CHANGED
|
@@ -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
|
};
|