@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,43 @@
1
+ //#region src/types/tool.d.ts
2
+ type RunReport = {
3
+ ok: boolean;
4
+ output: string;
5
+ };
6
+ type FormatOptions = {
7
+ fix?: boolean;
8
+ };
9
+ type LintOptions = {
10
+ fix?: boolean;
11
+ };
12
+ type StaticCheckerOptions = {
13
+ fix?: boolean;
14
+ fixStaged?: boolean;
15
+ };
16
+ type TypeCheckOptions = {
17
+ cwd?: string;
18
+ };
19
+ type Doctor = {
20
+ doctor: () => Promise<RunReport>;
21
+ };
22
+ type Formatter = {
23
+ readonly ui: string;
24
+ format: (options: FormatOptions) => Promise<RunReport>;
25
+ };
26
+ type Linter = {
27
+ readonly ui: string;
28
+ lint: (options: LintOptions) => Promise<RunReport>;
29
+ };
30
+ type StaticChecker = {
31
+ readonly ui: string;
32
+ check: (options: StaticCheckerOptions) => Promise<RunReport>;
33
+ };
34
+ type TypeChecker = {
35
+ readonly ui: string;
36
+ check: (options?: TypeCheckOptions) => Promise<RunReport>;
37
+ };
38
+ type Packer = {
39
+ readonly ui: string;
40
+ pack: () => Promise<RunReport>;
41
+ };
42
+ //#endregion
43
+ export { Linter as a, StaticChecker as c, TypeChecker as d, LintOptions as i, StaticCheckerOptions as l, FormatOptions as n, Packer as o, Formatter as r, RunReport as s, Doctor as t, TypeCheckOptions as u };
@@ -1,3 +1,4 @@
1
+ import { a as Linter, c as StaticChecker, d as TypeChecker, o as Packer, r as Formatter, t as Doctor } from "./tool-DKL6TauZ.mjs";
1
2
  import { Pkg, ShellService } from "@vlandoss/clibuddy";
2
3
  import { AnyLogger } from "@vlandoss/loggy";
3
4
 
@@ -26,74 +27,13 @@ declare class ReleaseService {
26
27
  resolve(pkg: string): Promise<string>;
27
28
  }
28
29
  //#endregion
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
- };
41
- type FormatOptions = {
42
- fix?: boolean;
43
- };
44
- type LintOptions = {
45
- fix?: boolean;
46
- };
47
- type StaticCheckerOptions = {
48
- fix?: boolean;
49
- fixStaged?: boolean;
50
- };
51
- type Doctor = {
52
- ui: string;
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>;
59
- };
60
- type Formatter = {
61
- bin: string;
62
- ui: string;
63
- format: (options: FormatOptions) => Promise<RunReport>;
64
- };
65
- type Linter = {
66
- bin: string;
67
- ui: string;
68
- lint: (options: LintOptions) => Promise<RunReport>;
69
- };
70
- type StaticChecker = {
71
- bin: string;
72
- ui: string;
73
- check: (options: StaticCheckerOptions) => Promise<RunReport>;
74
- };
75
- type TypeCheckOptions = {
76
- /** Where to run the type checker. Defaults to the kernel's `cwd`. */cwd?: string;
77
- };
78
- type TypeChecker = {
79
- bin: string;
80
- ui: string;
81
- check: (options?: TypeCheckOptions) => Promise<RunReport>;
82
- };
83
- //#endregion
84
- //#region src/plugin/types.d.ts
85
- type Packer = {
86
- bin: string;
87
- ui: string;
88
- pack: () => Promise<RunReport>;
89
- };
90
- declare const PLUGIN_KINDS: readonly ["lint", "format", "jsc", "tsc", "pack"];
91
- type PluginKind = (typeof PLUGIN_KINDS)[number];
92
- type PluginCapabilities = {
30
+ //#region src/lib/plugin/types.d.ts
31
+ type PluginCapability = keyof PluginServices;
32
+ type PluginServices = {
93
33
  lint?: Linter & Doctor;
94
34
  format?: Formatter & Doctor;
95
- jsc?: StaticChecker & Doctor;
96
- tsc?: TypeChecker & Doctor;
35
+ jscheck?: StaticChecker & Doctor;
36
+ typecheck?: TypeChecker & Doctor;
97
37
  pack?: Packer & Doctor;
98
38
  };
99
39
  type PluginContext = {
@@ -104,9 +44,11 @@ type PluginContext = {
104
44
  cwd: string;
105
45
  };
106
46
  type Plugin = {
107
- name: string;
108
47
  apiVersion: 1;
109
- capabilities(ctx: PluginContext): Promise<PluginCapabilities>;
48
+ name: string;
49
+ readonly ui: string;
50
+ color(label: string): string;
51
+ services(ctx: PluginContext): Promise<PluginServices>;
110
52
  install?(ctx: InstallContext): Promise<InstallResult>;
111
53
  uninstall?(ctx: UninstallContext): Promise<UninstallResult>;
112
54
  };
@@ -142,12 +84,6 @@ type InstallContext = {
142
84
  appPkg: Pkg;
143
85
  prompts: ClackPrompts;
144
86
  flags: InstallFlags;
145
- /**
146
- * The release this install is running against — encapsulates the dist-tag the
147
- * user picked (`rr plugins add biome@pr-226`) and resolves install specs for
148
- * related packages under it. Use `ctx.release.resolve(pkg)` for every
149
- * `@rrlab/*-config` sibling so previews and `latest` work uniformly.
150
- */
151
87
  release: ReleaseService;
152
88
  };
153
89
  type UninstallContext = {
@@ -209,4 +145,4 @@ type UninstallResult = {
209
145
  files?: FileOp[];
210
146
  };
211
147
  //#endregion
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 };
148
+ export { InstallFlags as a, Plugin as c, PluginServices as d, UninstallContext as f, ReleaseServiceOptions as g, ReleaseService as h, InstallContext as i, PluginCapability as l, UninstallResult as m, ClackPromptsSelectOption as n, InstallResult as o, UninstallFlags as p, FileOp as r, JsonEdit as s, ClackPrompts as t, PluginContext as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rrlab/cli",
3
- "version": "1.1.0",
3
+ "version": "1.1.1-git-4903a88.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": {
@@ -24,8 +24,8 @@
24
24
  "default": "./dist/config.mjs"
25
25
  },
26
26
  "./plugin": {
27
- "types": "./dist/plugin.d.mts",
28
- "default": "./dist/plugin.mjs"
27
+ "types": "./dist/plugin/index.d.mts",
28
+ "default": "./dist/plugin/index.mjs"
29
29
  }
30
30
  },
31
31
  "bin": {
@@ -48,15 +48,16 @@
48
48
  "dependencies": {
49
49
  "@clack/prompts": "0.11.0",
50
50
  "@usage-spec/commander": "1.1.0",
51
- "comment-json": "4.2.5",
52
51
  "commander": "14.0.3",
52
+ "comment-json": "4.2.5",
53
+ "fast-string-width": "3.0.2",
53
54
  "glob": "13.0.6",
54
55
  "lilconfig": "3.1.3",
55
56
  "magicast": "0.3.5",
56
57
  "memoize": "10.2.0",
57
58
  "nypm": "0.6.0",
58
59
  "rimraf": "6.1.3",
59
- "@vlandoss/clibuddy": "0.7.0",
60
+ "@vlandoss/clibuddy": "0.7.1-git-4903a88.0",
60
61
  "@vlandoss/loggy": "0.2.1"
61
62
  },
62
63
  "devDependencies": {
@@ -0,0 +1,36 @@
1
+ import { cwd } from "@vlandoss/clibuddy";
2
+ import { type GlobOptions, glob } from "glob";
3
+ import { rimraf } from "rimraf";
4
+ import { logger } from "#src/services/logger.ts";
5
+
6
+ export type CleanOptions = {
7
+ onlyDist: boolean;
8
+ dryRun: boolean;
9
+ };
10
+
11
+ export type CleanActionConfig = {
12
+ options: CleanOptions;
13
+ };
14
+
15
+ export async function cleanAction({ options }: CleanActionConfig): Promise<void> {
16
+ async function run(paths: string[], globOptions: GlobOptions) {
17
+ if (options.dryRun) {
18
+ const toDelete = await glob(paths, globOptions);
19
+ logger.info("Paths that would be deleted: %O", toDelete);
20
+ return;
21
+ }
22
+
23
+ logger.start("Clean started");
24
+ await rimraf(paths, { glob: globOptions });
25
+ logger.success("Clean completed");
26
+ }
27
+
28
+ const BUILD_PATHS = ["**/dist"];
29
+ const ALL_DIRTY_PATHS = ["**/.turbo", "**/node_modules", "pnpm-lock.yaml", "bun.lock", ...BUILD_PATHS];
30
+
31
+ if (options.onlyDist) {
32
+ await run(BUILD_PATHS, { cwd, ignore: ["**/node_modules/**"] });
33
+ } else {
34
+ await run(ALL_DIRTY_PATHS, { cwd });
35
+ }
36
+ }
@@ -0,0 +1,46 @@
1
+ import { palette } from "@vlandoss/clibuddy";
2
+ import { Lines } from "#src/render/lines.ts";
3
+ import { configuredPlugins, pkgCell, pluginVersionCell, relPath } from "#src/render/plugin-view.ts";
4
+ import type { ContextValue } from "#src/services/context.ts";
5
+ import { SEP } from "#src/ui/theme.ts";
6
+
7
+ export type ConfigActionConfig = {
8
+ ctx: ContextValue;
9
+ };
10
+
11
+ export function configAction({ ctx }: ConfigActionConfig): void {
12
+ const { meta } = ctx.config;
13
+
14
+ const lines = new Lines();
15
+
16
+ lines
17
+ .add(palette.bold("Source:"))
18
+ .add(
19
+ meta.filepath
20
+ ? `${relPath(ctx, meta.filepath)}${SEP}${palette.dim(`loaded in ${Math.round(meta.loadMs)}ms`)}`
21
+ : `${palette.dim("(no run-run.config — using defaults)")}`,
22
+ 2,
23
+ )
24
+ .add(palette.bold("\nPlugins:"));
25
+
26
+ const plugins = configuredPlugins(ctx);
27
+
28
+ if (!plugins.length) {
29
+ lines.add(palette.dim("No plugins configured. Try `rr plugins add <name>`."), 2);
30
+ } else {
31
+ const rows = plugins.map((p) => ({
32
+ name: `${p.color("●")} ${p.name}`,
33
+ pkg: pkgCell(p.name),
34
+ version: pluginVersionCell(p.name, ctx.appPkg.dirPath),
35
+ }));
36
+
37
+ // biome-ignore format: i prefer multilines here
38
+ lines.addTable(rows, [
39
+ { key: "name" },
40
+ { key: "pkg", align: "right" },
41
+ { key: "version" }
42
+ ], { padStart: 2 });
43
+ }
44
+
45
+ lines.printStdout();
46
+ }
@@ -0,0 +1,47 @@
1
+ import { reportTask, runBoard } from "#src/render/board.ts";
2
+ import { fanoutTitle, targetLabel } from "#src/render/labels.ts";
3
+ import type { ContextValue } from "#src/services/context.ts";
4
+ import { logger } from "#src/services/logger.ts";
5
+ import type { Doctor } from "#src/types/tool.ts";
6
+
7
+ /** A capability impl reduced to what `doctor` needs: its health check plus the label to render it under. */
8
+ export type DoctorService = Doctor & { readonly ui: string };
9
+
10
+ export type DoctorActionConfig = {
11
+ ctx: ContextValue;
12
+ };
13
+
14
+ export type DoctorOneActionConfig = {
15
+ ctx: ContextValue;
16
+ service: DoctorService;
17
+ };
18
+
19
+ /**
20
+ * A single tool's `doctor` as one board row (`doctor (<tool>) · <pkg>`) — used
21
+ * by the `doctor` subcommand each plugin-backed command exposes.
22
+ */
23
+ export async function doctorOneAction({ ctx, service }: DoctorOneActionConfig): Promise<void> {
24
+ const result = await runBoard([reportTask(targetLabel("doctor", service, ctx.appPkg), () => service.doctor())]);
25
+ if (!result.ok) process.exitCode = 1;
26
+ }
27
+
28
+ /**
29
+ * Top-level `rr doctor` — runs the `doctor()` of every distinct capability
30
+ * impl registered with the kernel. Distinct because a single plugin (e.g.
31
+ * biome) often serves multiple kinds (`lint`, `format`, `jscheck`) from the same
32
+ * `BiomeService` instance; running its doctor three times is wasteful.
33
+ */
34
+ export async function doctorAction({ ctx }: DoctorActionConfig): Promise<void> {
35
+ const services = ctx.plugins.getAllServices();
36
+
37
+ if (services.length === 0) {
38
+ logger.info("No plugins configured. Use `rr plugins add <name>` to install one.");
39
+ return;
40
+ }
41
+
42
+ // Each tool's health check is one parallel board row — a fan-out across
43
+ // tools, so the rows carry the tool name and the title omits a single tool.
44
+ const tasks = services.map((svc) => reportTask(svc.ui, () => svc.doctor()));
45
+ const result = await runBoard(tasks, { title: fanoutTitle("doctor", undefined, services.length, "tools") });
46
+ if (!result.ok) process.exitCode = 1;
47
+ }
@@ -0,0 +1,13 @@
1
+ import type { ContextValue } from "#src/services/context.ts";
2
+ import type { Doctor, FormatOptions, Formatter } from "#src/types/tool.ts";
3
+ import { runToolAction } from "./run-tool.ts";
4
+
5
+ export type FormatActionConfig = {
6
+ ctx: ContextValue;
7
+ formatter: Formatter & Doctor;
8
+ options: FormatOptions;
9
+ };
10
+
11
+ export function formatAction({ ctx, formatter, options }: FormatActionConfig) {
12
+ return runToolAction({ ctx, name: "format", provider: formatter, run: (p) => p.format(options) });
13
+ }
@@ -0,0 +1,13 @@
1
+ import type { ContextValue } from "#src/services/context.ts";
2
+ import type { Doctor, StaticChecker, StaticCheckerOptions } from "#src/types/tool.ts";
3
+ import { runToolAction } from "./run-tool.ts";
4
+
5
+ export type JscActionConfig = {
6
+ ctx: ContextValue;
7
+ checker: StaticChecker & Doctor;
8
+ options: StaticCheckerOptions;
9
+ };
10
+
11
+ export function jscAction({ ctx, checker, options }: JscActionConfig) {
12
+ return runToolAction({ ctx, name: "jsc", provider: checker, run: (p) => p.check(options) });
13
+ }
@@ -0,0 +1,13 @@
1
+ import type { ContextValue } from "#src/services/context.ts";
2
+ import type { Doctor, Linter, LintOptions } from "#src/types/tool.ts";
3
+ import { runToolAction } from "./run-tool.ts";
4
+
5
+ export type LintActionConfig = {
6
+ ctx: ContextValue;
7
+ linter: Linter & Doctor;
8
+ options: LintOptions;
9
+ };
10
+
11
+ export function lintAction({ ctx, linter, options }: LintActionConfig) {
12
+ return runToolAction({ ctx, name: "lint", provider: linter, run: (p) => p.lint(options) });
13
+ }
@@ -0,0 +1,12 @@
1
+ import type { ContextValue } from "#src/services/context.ts";
2
+ import type { Doctor, Packer } from "#src/types/tool.ts";
3
+ import { runToolAction } from "./run-tool.ts";
4
+
5
+ export type PackActionConfig = {
6
+ ctx: ContextValue;
7
+ packer: Packer & Doctor;
8
+ };
9
+
10
+ export function packAction({ ctx, packer }: PackActionConfig) {
11
+ return runToolAction({ ctx, name: "pack", provider: packer, run: (p) => p.pack() });
12
+ }
@@ -0,0 +1,143 @@
1
+ import path from "node:path";
2
+ import * as clack from "@clack/prompts";
3
+ import { addDependency, detectPackageManager, removeDependency } from "nypm";
4
+ import { InvalidPluginModuleError } from "#src/errors/invalid-plugin-module.ts";
5
+ import { UnknownPluginError } from "#src/errors/unknown-plugin.ts";
6
+ import { PLUGINS_DIRECTORY, type PluginName } from "#src/lib/plugin/directory.ts";
7
+ import type { InstallContext, InstallResult } from "#src/lib/plugin/types.ts";
8
+ import { withSpinner } from "#src/render/steps.ts";
9
+ import { ConfigAstService } from "#src/services/config-ast.ts";
10
+ import type { ContextValue } from "#src/services/context.ts";
11
+ import { applyFileOp } from "#src/services/file-ops.ts";
12
+ import { logger } from "#src/services/logger.ts";
13
+ import { createClackPrompts } from "#src/services/prompts.ts";
14
+ import { ReleaseService } from "#src/services/release.ts";
15
+ import { describeWorkspaceChoice, resolveWorkspaceChoice, toNypmWorkspace } from "#src/services/workspace-target.ts";
16
+ import { type AddOptions, hasInPackageJson, type PluginModule, reportFileOp } from "./shared.ts";
17
+
18
+ /** Split a `<name>[@<spec>]` input (e.g. `biome@pr-226`) into the plugin name and optional spec. */
19
+ function parsePluginSpec(input: string): { name: string; spec?: string } {
20
+ const at = input.indexOf("@");
21
+ if (at <= 0) return { name: input };
22
+ return { name: input.slice(0, at), spec: input.slice(at + 1) };
23
+ }
24
+
25
+ /** A dist-tag starts with a letter and contains only safe identifier chars. Version ranges (`^0.1`, `>=1`, `0.0.2`, `*`) don't match. */
26
+ function isDistTag(spec: string): boolean {
27
+ return /^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(spec) && spec !== "latest";
28
+ }
29
+
30
+ export type AddPluginActionConfig = {
31
+ ctx: ContextValue;
32
+ args: { name: PluginName };
33
+ options: AddOptions;
34
+ };
35
+
36
+ export async function addPluginAction({ ctx, args, options }: AddPluginActionConfig): Promise<void> {
37
+ const { name: pluginName, spec } = parsePluginSpec(args.name);
38
+ if (!(pluginName in PLUGINS_DIRECTORY)) {
39
+ throw new UnknownPluginError(pluginName);
40
+ }
41
+ const { pkg: pkgName, name: binding } = PLUGINS_DIRECTORY[pluginName as PluginName];
42
+ const tag = spec && isDistTag(spec) ? spec : undefined;
43
+ const installSpec = spec ? `${pkgName}@${spec}` : pkgName;
44
+
45
+ clack.intro(` rr plugins add ${args.name} `);
46
+
47
+ const inPkg = hasInPackageJson(ctx, pkgName);
48
+ const ast = new ConfigAstService();
49
+ const loaded = await ast.load(ctx.appPkg.dirPath);
50
+ const inConfig = !loaded.isNew && ast.hasPlugin(loaded.mod, binding);
51
+
52
+ if (inPkg && inConfig && !options.force && !spec) {
53
+ clack.log.warn(`${pkgName} is already installed and configured. Use --force to re-run install.`);
54
+ clack.outro("Nothing to do.");
55
+ return;
56
+ }
57
+
58
+ const pm = await detectPackageManager(ctx.appPkg.dirPath);
59
+ const wsChoice = resolveWorkspaceChoice(ctx.appPkg, pm);
60
+ const workspace = toNypmWorkspace(wsChoice);
61
+ const targetLabel = describeWorkspaceChoice(wsChoice);
62
+ // A spec means "(re)install at this spec" — upgrade even when the package is already in package.json.
63
+ const willInstall = !inPkg || !!spec;
64
+
65
+ if (options.dryRun) {
66
+ const presence = willInstall
67
+ ? inPkg
68
+ ? " (already present, will be updated to this spec)"
69
+ : ""
70
+ : " (already present, skipped)";
71
+ clack.log.info(`Would: install ${installSpec} as a devDependency in ${targetLabel}${presence}.`);
72
+ if (!inConfig) {
73
+ const rel = path.relative(ctx.appPkg.dirPath, loaded.filepath) || loaded.filepath;
74
+ clack.log.info(`Would: add ${binding}() to ${rel} (plugins[]).`);
75
+ }
76
+ clack.log.info("Would: run the plugin's install() hook (if any) to fetch peer deps and create files.");
77
+ clack.outro("Dry run complete.");
78
+ return;
79
+ }
80
+
81
+ let installedNow = false;
82
+ if (willInstall) {
83
+ await withSpinner(`Installing ${installSpec}`, async () => {
84
+ await addDependency([installSpec], { cwd: ctx.appPkg.dirPath, dev: true, silent: true, workspace });
85
+ });
86
+ // Only mark for rollback when this was a fresh install — a failed upgrade can't be safely reverted to the previous version.
87
+ if (!inPkg) installedNow = true;
88
+ }
89
+
90
+ let installResult: InstallResult | undefined;
91
+ try {
92
+ const mod = (await import(pkgName)) as PluginModule;
93
+ const factory = mod.default;
94
+ if (typeof factory !== "function") {
95
+ throw new InvalidPluginModuleError(pkgName);
96
+ }
97
+ const plugin = factory();
98
+ if (plugin.install) {
99
+ const installCtx: InstallContext = {
100
+ shell: ctx.shell,
101
+ logger,
102
+ appPkg: ctx.appPkg,
103
+ prompts: createClackPrompts(),
104
+ flags: {
105
+ force: !!options.force,
106
+ yes: !!options.yes,
107
+ nonInteractive: !!options.yes,
108
+ },
109
+ release: new ReleaseService(tag),
110
+ };
111
+ installResult = await plugin.install(installCtx);
112
+ }
113
+ } catch (err) {
114
+ if (installedNow) {
115
+ try {
116
+ await removeDependency(pkgName, { cwd: ctx.appPkg.dirPath, silent: true, workspace });
117
+ } catch {
118
+ // best-effort rollback — don't mask the original error
119
+ }
120
+ }
121
+ throw err;
122
+ }
123
+
124
+ if (installResult?.devDependencies && Object.keys(installResult.devDependencies).length > 0) {
125
+ const names = Object.keys(installResult.devDependencies);
126
+ const deps = Object.entries(installResult.devDependencies).map(([k, v]) => `${k}@${v}`);
127
+ await withSpinner(`Installing ${names.join(", ")}`, async () => {
128
+ await addDependency(deps, { cwd: ctx.appPkg.dirPath, dev: true, silent: true, workspace });
129
+ });
130
+ }
131
+ for (const op of installResult?.files ?? []) {
132
+ reportFileOp(await applyFileOp(ctx.appPkg.dirPath, op, !!options.force));
133
+ }
134
+
135
+ if (!inConfig) {
136
+ ast.addPlugin(loaded.mod, { exportName: binding, pkgName });
137
+ await ast.save(loaded);
138
+ const rel = path.relative(ctx.appPkg.dirPath, loaded.filepath) || loaded.filepath;
139
+ clack.log.success(`Updated ${rel}`);
140
+ }
141
+
142
+ clack.outro(`Plugin '${pluginName}' ready 🎉`);
143
+ }
@@ -0,0 +1,27 @@
1
+ import path from "node:path";
2
+ import { ConfigAstService } from "#src/services/config-ast.ts";
3
+ import type { ContextValue } from "#src/services/context.ts";
4
+ import { logger } from "#src/services/logger.ts";
5
+
6
+ export type ListPluginsActionConfig = {
7
+ ctx: ContextValue;
8
+ };
9
+
10
+ export async function listPluginsAction({ ctx }: ListPluginsActionConfig): Promise<void> {
11
+ const ast = new ConfigAstService();
12
+ const loaded = await ast.load(ctx.appPkg.dirPath);
13
+ if (loaded.isNew) {
14
+ logger.info("No run-run.config.{ts,mts} found. Use `rr plugins add <name>` to start.");
15
+ return;
16
+ }
17
+ const plugins = ast.listPlugins(loaded.mod);
18
+ const rel = path.relative(ctx.appPkg.dirPath, loaded.filepath) || loaded.filepath;
19
+ if (plugins.length === 0) {
20
+ logger.info(`${rel}: no plugins configured.`);
21
+ return;
22
+ }
23
+ logger.info(`${rel}:`);
24
+ for (const name of plugins) {
25
+ logger.info(` - ${name}`);
26
+ }
27
+ }
@@ -0,0 +1,110 @@
1
+ import path from "node:path";
2
+ import * as clack from "@clack/prompts";
3
+ import { detectPackageManager, removeDependency } from "nypm";
4
+ import { PLUGINS_DIRECTORY, type PluginName } from "#src/lib/plugin/directory.ts";
5
+ import type { UninstallResult } from "#src/lib/plugin/types.ts";
6
+ import { withSpinner } from "#src/render/steps.ts";
7
+ import { ConfigAstService } from "#src/services/config-ast.ts";
8
+ import type { ContextValue } from "#src/services/context.ts";
9
+ import { applyFileOp, describeFileOp } from "#src/services/file-ops.ts";
10
+ import { logger } from "#src/services/logger.ts";
11
+ import { createClackPrompts } from "#src/services/prompts.ts";
12
+ import { describeWorkspaceChoice, resolveWorkspaceChoice, toNypmWorkspace } from "#src/services/workspace-target.ts";
13
+ import { hasInPackageJson, type PluginModule, type RemoveOptions, reportFileOp } from "./shared.ts";
14
+
15
+ export type RemovePluginActionConfig = {
16
+ ctx: ContextValue;
17
+ args: { name: PluginName };
18
+ options: RemoveOptions;
19
+ };
20
+
21
+ export async function removePluginAction({ ctx, args, options }: RemovePluginActionConfig): Promise<void> {
22
+ const { name } = args;
23
+ const { pkg: pkgName, name: binding } = PLUGINS_DIRECTORY[name];
24
+
25
+ clack.intro(` rr plugins remove ${name} `);
26
+
27
+ const ast = new ConfigAstService();
28
+ const loaded = await ast.load(ctx.appPkg.dirPath);
29
+ const inConfig = !loaded.isNew && ast.hasPlugin(loaded.mod, binding);
30
+
31
+ // Collect plugin's uninstall plan (only if pkg is installed + has uninstall hook).
32
+ let uninstallResult: UninstallResult | undefined;
33
+ if (hasInPackageJson(ctx, pkgName)) {
34
+ try {
35
+ const mod = (await import(pkgName)) as PluginModule;
36
+ const factory = mod.default;
37
+ const plugin = typeof factory === "function" ? factory() : undefined;
38
+ if (plugin?.uninstall) {
39
+ uninstallResult = await plugin.uninstall({
40
+ shell: ctx.shell,
41
+ logger,
42
+ appPkg: ctx.appPkg,
43
+ prompts: createClackPrompts(),
44
+ flags: { yes: !!options.yes, nonInteractive: !!options.yes },
45
+ });
46
+ }
47
+ } catch (err) {
48
+ clack.log.warn(`Could not load ${pkgName} for uninstall hook: ${err instanceof Error ? err.message : String(err)}`);
49
+ }
50
+ }
51
+
52
+ const planSteps: string[] = [];
53
+ if (inConfig) {
54
+ const rel = path.relative(ctx.appPkg.dirPath, loaded.filepath) || loaded.filepath;
55
+ planSteps.push(`Remove ${binding}() from ${rel}`);
56
+ }
57
+ for (const op of uninstallResult?.files ?? []) {
58
+ planSteps.push(describeFileOp(op));
59
+ }
60
+ const depsToRemove = (uninstallResult?.removeDependencies ?? []).filter((dep) => hasInPackageJson(ctx, dep));
61
+ if (hasInPackageJson(ctx, pkgName) && !depsToRemove.includes(pkgName)) {
62
+ depsToRemove.unshift(pkgName);
63
+ }
64
+ const pm = await detectPackageManager(ctx.appPkg.dirPath);
65
+ const wsChoice = resolveWorkspaceChoice(ctx.appPkg, pm);
66
+ const workspace = toNypmWorkspace(wsChoice);
67
+
68
+ if (depsToRemove.length > 0) {
69
+ planSteps.push(`Uninstall: ${depsToRemove.join(", ")} (from ${describeWorkspaceChoice(wsChoice)})`);
70
+ }
71
+
72
+ if (planSteps.length === 0) {
73
+ clack.log.warn(`Plugin '${name}' is not installed nor configured.`);
74
+ clack.outro("Nothing to do.");
75
+ return;
76
+ }
77
+
78
+ clack.log.message(`Plan:\n${planSteps.map((s) => ` • ${s}`).join("\n")}`);
79
+
80
+ if (options.dryRun) {
81
+ clack.outro("Dry run complete.");
82
+ return;
83
+ }
84
+
85
+ if (!options.yes) {
86
+ const choice = await clack.confirm({ message: "Proceed?", initialValue: false });
87
+ if (clack.isCancel(choice) || choice !== true) {
88
+ clack.outro("Aborted.");
89
+ return;
90
+ }
91
+ }
92
+
93
+ // Apply file ops first (they may need the plugin's source to still be importable).
94
+ for (const op of uninstallResult?.files ?? []) {
95
+ reportFileOp(await applyFileOp(ctx.appPkg.dirPath, op, /* force */ true));
96
+ }
97
+ if (inConfig) {
98
+ ast.removePlugin(loaded.mod, binding);
99
+ await ast.save(loaded);
100
+ const rel = path.relative(ctx.appPkg.dirPath, loaded.filepath) || loaded.filepath;
101
+ clack.log.success(`Removed ${binding}() from ${rel}`);
102
+ }
103
+ for (const dep of depsToRemove) {
104
+ await withSpinner(`Uninstalling ${dep}`, async () => {
105
+ await removeDependency(dep, { cwd: ctx.appPkg.dirPath, silent: true, workspace });
106
+ });
107
+ }
108
+
109
+ clack.outro(`Plugin '${name}' removed.`);
110
+ }