@rrlab/cli 0.0.3-git-85e8cd0.0 → 0.0.4-git-c5c6e0f.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.
@@ -1,7 +1,7 @@
1
1
  // @generated by @usage-spec/commander from Commander.js metadata
2
2
  name rr
3
3
  bin rr
4
- version "0.0.3-git-85e8cd0.0"
4
+ version "0.0.4-git-c5c6e0f.0"
5
5
  usage "<command...> [options...]"
6
6
  flag --usage help="print KDL spec for this CLI (https://kdl.dev)"
7
7
  cmd completion help="print shell completion script (usage)" {
@@ -37,7 +37,7 @@ cmd format help="check & fix format errors" {
37
37
  flag --fix help="format all the code"
38
38
  cmd doctor help="check if the underlying tool is working correctly"
39
39
  }
40
- cmd check help="run static checks (run-run)" {
40
+ cmd check help="run static checks" {
41
41
  long_help "Runs `rr jsc` and `rr tsc` concurrently in-process. Aggregates their exit codes — non-zero when either subcommand fails."
42
42
  }
43
43
  cmd doctor help="run all plugin doctors" {
package/dist/config.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { u as Plugin } from "./types-BPHxibPr.mjs";
1
+ import { u as Plugin } from "./types-DnqiiIxe.mjs";
2
2
 
3
3
  //#region src/types/config.d.ts
4
4
  type UserConfig = {
package/dist/plugin.d.mts CHANGED
@@ -1,8 +1,46 @@
1
- import { C as Linter, D as TypeChecker, E as TypeCheckOptions, O as ReleaseService, S as LintOptions, T as StaticCheckerOptions, _ as Doctor, a as InstallFlags, b as FormatOptions, c as PLUGIN_KINDS, d as PluginCapabilities, f as PluginContext, g as UninstallResult, h as UninstallFlags, i as InstallContext, k as ReleaseServiceOptions, l as Packer, m as UninstallContext, n as ClackPromptsSelectOption, o as InstallResult, p as PluginKind, r as FileOp, s as JsonEdit, t as ClackPrompts, u as Plugin, v as DoctorOutput, w as StaticChecker, x as Formatter, y as DoctorResult } from "./types-BPHxibPr.mjs";
1
+ import { C as Linter, D as TypeChecker, E as TypeCheckOptions, O as ReleaseService, S as LintOptions, T as StaticCheckerOptions, _ as Doctor, a as InstallFlags, b as FormatOptions, c as PLUGIN_KINDS, d as PluginCapabilities, f as PluginContext, g as UninstallResult, h as UninstallFlags, i as InstallContext, k as ReleaseServiceOptions, l as Packer, m as UninstallContext, n as ClackPromptsSelectOption, o as InstallResult, p as PluginKind, r as FileOp, s as JsonEdit, t as ClackPrompts, u as Plugin, v as DoctorOutput, w as StaticChecker, x as Formatter, y as DoctorResult } from "./types-DnqiiIxe.mjs";
2
2
  import { ShellService } from "@vlandoss/clibuddy";
3
3
 
4
+ //#region src/plugin/decide-scaffold.d.ts
5
+ type ScaffoldDecision = "create" | "patch" | "overwrite" | "skip";
6
+ type DecideScaffoldOptions = {
7
+ /** The config file label shown to the user (e.g. `"biome.json"`, `"tsdown.config.ts"`). */label: string; /** Whether the file currently exists in the app project. */
8
+ fileExists: boolean; /** Short description of what "patch" does, shown in the select option. */
9
+ patchHint: string;
10
+ /**
11
+ * What to return when the file exists and the run is unattended (`--yes` / non-interactive).
12
+ * - `"patch"` (default): assume the user wants to merge our config into theirs (safe for JSON we can edit).
13
+ * - `"skip"`: assume the user owns the file (right for TS modules we'd otherwise rewrite blindly).
14
+ */
15
+ unattendedExistingAction?: "patch" | "skip";
16
+ };
17
+ declare function decideScaffold(ctx: InstallContext, opts: DecideScaffoldOptions): Promise<ScaffoldDecision>;
18
+ //#endregion
4
19
  //#region src/plugin/define-plugin.d.ts
5
- declare function definePlugin<T = void>(factory: (options: T) => Plugin): (options: T) => Plugin;
20
+ type Caps = Partial<PluginCapabilities>;
21
+ type PluginDefinition<TCaps extends Caps> = {
22
+ name: string;
23
+ apiVersion: 1;
24
+ capabilities(ctx: PluginContext): TCaps | Promise<TCaps>;
25
+ install?(this: void, ctx: InstallContext): Promise<InstallResult>;
26
+ uninstall?(this: void, ctx: UninstallContext): Promise<UninstallResult>;
27
+ };
28
+ type WithOnly<TOptions, TKind extends PluginKind> = TOptions extends void ? {
29
+ only?: readonly TKind[];
30
+ } : TOptions & {
31
+ only?: readonly TKind[];
32
+ };
33
+ declare function definePlugin<TCaps extends Caps, TOptions = void>(factory: (options: TOptions) => PluginDefinition<TCaps>): (options?: WithOnly<TOptions, keyof TCaps & PluginKind>) => Plugin;
34
+ //#endregion
35
+ //#region src/plugin/pick-preset.d.ts
36
+ type PickPresetOptions<K extends string> = {
37
+ message: string;
38
+ presets: Record<K, {
39
+ label: string;
40
+ }>;
41
+ defaultPreset: K;
42
+ };
43
+ declare function pickPreset<K extends string>(ctx: InstallContext, opts: PickPresetOptions<K>): Promise<K>;
6
44
  //#endregion
7
45
  //#region ../../node_modules/.pnpm/tinyexec@1.1.2/node_modules/tinyexec/dist/main.d.mts
8
46
  //#endregion
@@ -49,4 +87,4 @@ declare class ToolService {
49
87
  doctor(): Promise<DoctorResult>;
50
88
  }
51
89
  //#endregion
52
- export { type ClackPrompts, type ClackPromptsSelectOption, type Doctor, type DoctorOutput, type DoctorResult, type FileOp, type FormatOptions, type Formatter, type InstallContext, type InstallFlags, type InstallResult, type JsonEdit, type LintOptions, type Linter, PLUGIN_KINDS, type Packer, type Plugin, type PluginCapabilities, type PluginContext, type PluginKind, ReleaseService, type ReleaseServiceOptions, type StaticChecker, type StaticCheckerOptions, ToolService, type ToolServiceOptions, type TypeCheckOptions, type TypeChecker, type UninstallContext, type UninstallFlags, type UninstallResult, definePlugin };
90
+ export { type ClackPrompts, type ClackPromptsSelectOption, type DecideScaffoldOptions, type Doctor, type DoctorOutput, type DoctorResult, type FileOp, type FormatOptions, type Formatter, type InstallContext, type InstallFlags, type InstallResult, type JsonEdit, type LintOptions, type Linter, PLUGIN_KINDS, type Packer, type PickPresetOptions, type Plugin, type PluginCapabilities, type PluginContext, type PluginDefinition, type PluginKind, ReleaseService, type ReleaseServiceOptions, type ScaffoldDecision, type StaticChecker, type StaticCheckerOptions, ToolService, type ToolServiceOptions, type TypeCheckOptions, type TypeChecker, type UninstallContext, type UninstallFlags, type UninstallResult, decideScaffold, definePlugin, pickPreset };
package/dist/plugin.mjs CHANGED
@@ -1,7 +1,37 @@
1
1
  import { resolvePackageBin } from "@vlandoss/clibuddy";
2
- //#region src/plugin/define-plugin.ts
3
- function definePlugin(factory) {
4
- return factory;
2
+ //#region src/plugin/decide-scaffold.ts
3
+ async function decideScaffold(ctx, opts) {
4
+ const { label, fileExists, patchHint, unattendedExistingAction = "patch" } = opts;
5
+ if (!fileExists) {
6
+ if (ctx.flags.yes || ctx.flags.nonInteractive) return "create";
7
+ const choice = await ctx.prompts.confirm({
8
+ message: `Scaffold ${label}?`,
9
+ initialValue: true
10
+ });
11
+ if (ctx.prompts.isCancel(choice)) throw new Error("Cancelled by user.");
12
+ return choice ? "create" : "skip";
13
+ }
14
+ if (ctx.flags.yes || ctx.flags.nonInteractive) return unattendedExistingAction;
15
+ const choice = await ctx.prompts.select({
16
+ message: `${label} already exists. What do you want to do?`,
17
+ options: [
18
+ {
19
+ value: "patch",
20
+ label: `Patch — ${patchHint}`
21
+ },
22
+ {
23
+ value: "skip",
24
+ label: "Skip — leave it alone"
25
+ },
26
+ {
27
+ value: "overwrite",
28
+ label: "Overwrite — replace with a fresh scaffold"
29
+ }
30
+ ],
31
+ initialValue: "patch"
32
+ });
33
+ if (ctx.prompts.isCancel(choice)) throw new Error("Cancelled by user.");
34
+ return choice;
5
35
  }
6
36
  //#endregion
7
37
  //#region src/plugin/tool-service.ts
@@ -53,6 +83,64 @@ var ToolService = class {
53
83
  }
54
84
  };
55
85
  //#endregion
86
+ //#region src/plugin/bin-probe.ts
87
+ async function probeBins(services, pluginName) {
88
+ const toolServices = services.filter((s) => s instanceof ToolService);
89
+ const distinct = /* @__PURE__ */ new Map();
90
+ for (const svc of toolServices) if (!distinct.has(svc.pkg)) distinct.set(svc.pkg, svc);
91
+ if (distinct.size === 0) return;
92
+ const probes = [...distinct.values()].map(async (svc) => {
93
+ try {
94
+ await svc.getBinDir();
95
+ } catch {
96
+ return svc.pkg;
97
+ }
98
+ return null;
99
+ });
100
+ const missing = (await Promise.all(probes)).filter((p) => p !== null);
101
+ if (missing.length === 0) return;
102
+ const pkgName = `@rrlab/${pluginName}-plugin`;
103
+ throw new Error(`${pkgName} requires ${missing.join(", ")} to be installed in the host project. Run: rr plugins add ${pluginName} (or: pnpm add -D ${missing.join(" ")})`);
104
+ }
105
+ //#endregion
106
+ //#region src/plugin/define-plugin.ts
107
+ function definePlugin(factory) {
108
+ return (options) => {
109
+ const def = factory(options);
110
+ const only = options?.only;
111
+ const pkgName = `@rrlab/${def.name}-plugin`;
112
+ return {
113
+ name: def.name,
114
+ apiVersion: def.apiVersion,
115
+ install: def.install,
116
+ uninstall: def.uninstall,
117
+ async capabilities(ctx) {
118
+ const map = await def.capabilities(ctx);
119
+ await probeBins(Object.values(map), def.name);
120
+ if (!only) return map;
121
+ for (const k of only) if (!(k in map)) throw new Error(`${pkgName}: unknown capability '${k}' in 'only'. Available: ${Object.keys(map).join(", ")}.`);
122
+ return Object.fromEntries(only.map((k) => [k, map[k]]));
123
+ }
124
+ };
125
+ };
126
+ }
127
+ //#endregion
128
+ //#region src/plugin/pick-preset.ts
129
+ async function pickPreset(ctx, opts) {
130
+ const { message, presets, defaultPreset } = opts;
131
+ if (ctx.flags.yes || ctx.flags.nonInteractive) return defaultPreset;
132
+ const choice = await ctx.prompts.select({
133
+ message,
134
+ options: Object.entries(presets).map(([value, meta]) => ({
135
+ value,
136
+ label: meta.label
137
+ })),
138
+ initialValue: defaultPreset
139
+ });
140
+ if (ctx.prompts.isCancel(choice)) throw new Error("Cancelled by user.");
141
+ return choice;
142
+ }
143
+ //#endregion
56
144
  //#region src/plugin/types.ts
57
145
  const PLUGIN_KINDS = [
58
146
  "lint",
@@ -107,4 +195,4 @@ var ReleaseService = class {
107
195
  }
108
196
  };
109
197
  //#endregion
110
- export { PLUGIN_KINDS, ReleaseService, ToolService, definePlugin };
198
+ export { PLUGIN_KINDS, ReleaseService, ToolService, decideScaffold, definePlugin, pickPreset };
package/dist/run.mjs CHANGED
@@ -28,7 +28,8 @@ var PluginRegistry = class {
28
28
  if (!first) return void 0;
29
29
  if (rest.length > 0) {
30
30
  const names = providers.map(({ plugin }) => plugin.name).join(", ");
31
- throw new Error(`Multiple plugins provide capability '${kind}': ${names}. Disambiguate by narrowing each plugin's capabilities in run-run.config.ts.`);
31
+ const example = providers.map(({ plugin }) => `${plugin.name}({ only: ['${kind}'] })`).join(" or ");
32
+ throw new Error(`Multiple plugins provide capability '${kind}': ${names}. Narrow each plugin's capabilities in run-run.config.ts using the 'only' option — e.g. ${example}.`);
32
33
  }
33
34
  return first.impl;
34
35
  }
@@ -121,9 +122,10 @@ async function createContext(binDir) {
121
122
  cwd
122
123
  };
123
124
  for (const plugin of config.config.plugins ?? []) {
124
- if (plugin.apiVersion !== 1) throw new Error(`Plugin '${plugin.name}' targets apiVersion ${plugin.apiVersion}, but this kernel supports only apiVersion 1.`);
125
+ const got = plugin.apiVersion;
126
+ if (got !== 1) throw new Error(`Plugin '${plugin.name}' targets apiVersion ${got}, but this kernel supports only apiVersion 1.`);
125
127
  debug("registering plugin: %s", plugin.name);
126
- const capabilities = await plugin.setup(pluginContext);
128
+ const capabilities = await plugin.capabilities(pluginContext);
127
129
  registry.register(plugin, capabilities);
128
130
  }
129
131
  return {
@@ -198,8 +200,8 @@ ${runRunColor(`
198
200
  * `command.js` — `fn.apply(this, actionArgs)`). `this.parent` gives us the
199
201
  * parent program without any late-binding ceremony.
200
202
  */
201
- function createCheckCommand() {
202
- return createCommand("check").summary(`run static checks (${TOOL_LABELS.RUN_RUN})`).description("Runs `rr jsc` and `rr tsc` concurrently in-process. Aggregates their exit codes — non-zero when either subcommand fails.").action(async function checkAction() {
203
+ function createCheckCommand(ctx) {
204
+ return createCommand("check").summary(`run static checks${checkAnnotation(ctx)}`).description("Runs `rr jsc` and `rr tsc` concurrently in-process. Aggregates their exit codes — non-zero when either subcommand fails.").action(async function checkAction() {
203
205
  const program = this.parent;
204
206
  if (!program) throw new Error("`rr check` requires the parent program to dispatch sibling subcommands.");
205
207
  const cmds = ["jsc", "tsc"].map((name) => ({
@@ -230,6 +232,29 @@ function createCheckCommand() {
230
232
  function findCommand(program, name) {
231
233
  return program.commands.find((c) => c.name() === name || c.aliases().includes(name));
232
234
  }
235
+ /**
236
+ * Mirrors the provider resolution of `jsc` + `tsc` and flattens the
237
+ * underlying tool labels — e.g. biome (composed lint+format) + oxc (tsc)
238
+ * renders as `(biome, oxlint)` rather than `(biome + biome, oxlint)`. When
239
+ * neither sibling has a provider, falls back to the standard `(not
240
+ * configured)` annotation so the help reads consistently with other
241
+ * commands.
242
+ */
243
+ function checkAnnotation(ctx) {
244
+ const directJsc = ctx.registry.get("jsc");
245
+ const linter = ctx.registry.get("lint");
246
+ const formatter = ctx.registry.get("format");
247
+ const tsc = ctx.registry.get("tsc");
248
+ const labels = [];
249
+ if (directJsc) labels.push(directJsc.ui);
250
+ else {
251
+ if (linter) labels.push(linter.ui);
252
+ if (formatter) labels.push(formatter.ui);
253
+ }
254
+ if (tsc) labels.push(tsc.ui);
255
+ if (labels.length === 0) return pluginAnnotation(void 0);
256
+ return pluginAnnotation({ ui: [...new Set(labels)].join(", ") });
257
+ }
233
258
  //#endregion
234
259
  //#region src/program/commands/clean.ts
235
260
  function createCleanCommand() {
@@ -971,7 +996,7 @@ async function runRemove(ctx, alias, opts) {
971
996
  }
972
997
  });
973
998
  } catch (err) {
974
- clack.log.warn(`Could not load ${pkgName} for uninstall hook: ${err instanceof Error ? err.message : err}`);
999
+ clack.log.warn(`Could not load ${pkgName} for uninstall hook: ${err instanceof Error ? err.message : String(err)}`);
975
1000
  }
976
1001
  const planSteps = [];
977
1002
  if (inConfig) {
@@ -1036,9 +1061,9 @@ function isDistTag(spec) {
1036
1061
  function hasInPackageJson(ctx, pkgName) {
1037
1062
  const pkg = ctx.appPkg.packageJson;
1038
1063
  return pkgName in {
1039
- ...pkg.dependencies ?? {},
1040
- ...pkg.devDependencies ?? {},
1041
- ...pkg.peerDependencies ?? {}
1064
+ ...pkg.dependencies,
1065
+ ...pkg.devDependencies,
1066
+ ...pkg.peerDependencies
1042
1067
  };
1043
1068
  }
1044
1069
  async function withSpinner(message, fn) {
@@ -1189,7 +1214,7 @@ async function createProgram(options) {
1189
1214
  program: createCommand("rr").usage("<command...> [options...]").enablePositionalOptions().version(version, "-v, --version").addOption(new Option("--usage", `print KDL spec for this CLI (${palette.muted(palette.link("https://kdl.dev"))})`)).on("option:usage", function onUsage() {
1190
1215
  generateToStdout(this);
1191
1216
  process.exit(0);
1192
- }).addHelpText("before", getBannerText(version)).addHelpText("after", CREDITS_TEXT).addCommand(createCompletionCommand()).addCommand(createPackCommand(ctx)).addCommand(createJsCheckCommand(ctx)).addCommand(createTsCheckCommand(ctx)).addCommand(createLintCommand(ctx)).addCommand(createFormatCommand(ctx)).addCommand(createCheckCommand()).addCommand(createDoctorCommand(ctx)).addCommand(createPluginsCommand(ctx)).addCommand(createCleanCommand()).addCommand(createConfigCommand(ctx)),
1217
+ }).addHelpText("before", getBannerText(version)).addHelpText("after", CREDITS_TEXT).addCommand(createCompletionCommand()).addCommand(createPackCommand(ctx)).addCommand(createJsCheckCommand(ctx)).addCommand(createTsCheckCommand(ctx)).addCommand(createLintCommand(ctx)).addCommand(createFormatCommand(ctx)).addCommand(createCheckCommand(ctx)).addCommand(createDoctorCommand(ctx)).addCommand(createPluginsCommand(ctx)).addCommand(createCleanCommand()).addCommand(createConfigCommand(ctx)),
1193
1218
  ctx
1194
1219
  };
1195
1220
  }
@@ -48,22 +48,22 @@ type DoctorResult = {
48
48
  };
49
49
  type Doctor = {
50
50
  ui: string;
51
- doctor(): Promise<DoctorResult>;
51
+ doctor: () => Promise<DoctorResult>;
52
52
  };
53
53
  type Formatter = {
54
54
  bin: string;
55
55
  ui: string;
56
- format(options: FormatOptions): Promise<void>;
56
+ format: (options: FormatOptions) => Promise<void>;
57
57
  };
58
58
  type Linter = {
59
59
  bin: string;
60
60
  ui: string;
61
- lint(options: LintOptions): Promise<void>;
61
+ lint: (options: LintOptions) => Promise<void>;
62
62
  };
63
63
  type StaticChecker = {
64
64
  bin: string;
65
65
  ui: string;
66
- check(options: StaticCheckerOptions): Promise<void>;
66
+ check: (options: StaticCheckerOptions) => Promise<void>;
67
67
  };
68
68
  type TypeCheckOptions = {
69
69
  /** Where to run the type checker. Defaults to the kernel's `cwd`. */cwd?: string;
@@ -71,14 +71,14 @@ type TypeCheckOptions = {
71
71
  type TypeChecker = {
72
72
  bin: string;
73
73
  ui: string;
74
- check(options?: TypeCheckOptions): Promise<void>;
74
+ check: (options?: TypeCheckOptions) => Promise<void>;
75
75
  };
76
76
  //#endregion
77
77
  //#region src/plugin/types.d.ts
78
78
  type Packer = {
79
79
  bin: string;
80
80
  ui: string;
81
- pack(): Promise<void>;
81
+ pack: () => Promise<void>;
82
82
  };
83
83
  declare const PLUGIN_KINDS: readonly ["lint", "format", "jsc", "tsc", "pack"];
84
84
  type PluginKind = (typeof PLUGIN_KINDS)[number];
@@ -99,7 +99,7 @@ type PluginContext = {
99
99
  type Plugin = {
100
100
  name: string;
101
101
  apiVersion: 1;
102
- setup(ctx: PluginContext): Promise<PluginCapabilities> | PluginCapabilities;
102
+ capabilities(ctx: PluginContext): Promise<PluginCapabilities>;
103
103
  install?(ctx: InstallContext): Promise<InstallResult>;
104
104
  uninstall?(ctx: UninstallContext): Promise<UninstallResult>;
105
105
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rrlab/cli",
3
- "version": "0.0.3-git-85e8cd0.0",
3
+ "version": "0.0.4-git-c5c6e0f.0",
4
4
  "description": "The CLI toolbox to fullstack common scripts in Variable Land",
5
5
  "homepage": "https://github.com/variableland/dx/tree/main/run-run/cli#readme",
6
6
  "bugs": {
@@ -56,8 +56,8 @@
56
56
  "memoize": "10.2.0",
57
57
  "nypm": "0.6.0",
58
58
  "rimraf": "6.1.3",
59
- "@vlandoss/clibuddy": "0.6.1",
60
- "@vlandoss/loggy": "0.2.1"
59
+ "@vlandoss/loggy": "0.2.2-git-c5c6e0f.0",
60
+ "@vlandoss/clibuddy": "0.6.1"
61
61
  },
62
62
  "devDependencies": {
63
63
  "tsdown": "0.22.0",
package/src/lib/plugin.ts CHANGED
@@ -1,4 +1,6 @@
1
- export { definePlugin } from "#src/plugin/define-plugin.ts";
1
+ export { type DecideScaffoldOptions, decideScaffold, type ScaffoldDecision } from "#src/plugin/decide-scaffold.ts";
2
+ export { definePlugin, type PluginDefinition } from "#src/plugin/define-plugin.ts";
3
+ export { type PickPresetOptions, pickPreset } from "#src/plugin/pick-preset.ts";
2
4
  export { ToolService, type ToolServiceOptions } from "#src/plugin/tool-service.ts";
3
5
  export type {
4
6
  ClackPrompts,
@@ -0,0 +1,30 @@
1
+ import { ToolService } from "./tool-service.ts";
2
+
3
+ export async function probeBins(services: readonly unknown[], pluginName: string): Promise<void> {
4
+ const toolServices = services.filter((s): s is ToolService => s instanceof ToolService);
5
+ const distinct = new Map<string, ToolService>();
6
+ for (const svc of toolServices) {
7
+ if (!distinct.has(svc.pkg)) distinct.set(svc.pkg, svc);
8
+ }
9
+
10
+ if (distinct.size === 0) return;
11
+
12
+ const probes = [...distinct.values()].map(async (svc) => {
13
+ try {
14
+ await svc.getBinDir();
15
+ } catch {
16
+ return svc.pkg;
17
+ }
18
+ return null;
19
+ });
20
+
21
+ const results = await Promise.all(probes);
22
+ const missing = results.filter((p): p is string => p !== null);
23
+ if (missing.length === 0) return;
24
+
25
+ const pkgName = `@rrlab/${pluginName}-plugin`;
26
+ throw new Error(
27
+ `${pkgName} requires ${missing.join(", ")} to be installed in the host project. ` +
28
+ `Run: rr plugins add ${pluginName} (or: pnpm add -D ${missing.join(" ")})`,
29
+ );
30
+ }
@@ -0,0 +1,46 @@
1
+ import type { InstallContext } from "./types.ts";
2
+
3
+ export type ScaffoldDecision = "create" | "patch" | "overwrite" | "skip";
4
+
5
+ export type DecideScaffoldOptions = {
6
+ /** The config file label shown to the user (e.g. `"biome.json"`, `"tsdown.config.ts"`). */
7
+ label: string;
8
+ /** Whether the file currently exists in the app project. */
9
+ fileExists: boolean;
10
+ /** Short description of what "patch" does, shown in the select option. */
11
+ patchHint: string;
12
+ /**
13
+ * What to return when the file exists and the run is unattended (`--yes` / non-interactive).
14
+ * - `"patch"` (default): assume the user wants to merge our config into theirs (safe for JSON we can edit).
15
+ * - `"skip"`: assume the user owns the file (right for TS modules we'd otherwise rewrite blindly).
16
+ */
17
+ unattendedExistingAction?: "patch" | "skip";
18
+ };
19
+
20
+ export async function decideScaffold(ctx: InstallContext, opts: DecideScaffoldOptions): Promise<ScaffoldDecision> {
21
+ const { label, fileExists, patchHint, unattendedExistingAction = "patch" } = opts;
22
+
23
+ if (!fileExists) {
24
+ if (ctx.flags.yes || ctx.flags.nonInteractive) return "create";
25
+ const choice = await ctx.prompts.confirm({
26
+ message: `Scaffold ${label}?`,
27
+ initialValue: true,
28
+ });
29
+ if (ctx.prompts.isCancel(choice)) throw new Error("Cancelled by user.");
30
+ return choice ? "create" : "skip";
31
+ }
32
+
33
+ if (ctx.flags.yes || ctx.flags.nonInteractive) return unattendedExistingAction;
34
+
35
+ const choice = await ctx.prompts.select<ScaffoldDecision>({
36
+ message: `${label} already exists. What do you want to do?`,
37
+ options: [
38
+ { value: "patch", label: `Patch — ${patchHint}` },
39
+ { value: "skip", label: "Skip — leave it alone" },
40
+ { value: "overwrite", label: "Overwrite — replace with a fresh scaffold" },
41
+ ],
42
+ initialValue: "patch",
43
+ });
44
+ if (ctx.prompts.isCancel(choice)) throw new Error("Cancelled by user.");
45
+ return choice;
46
+ }
@@ -1,5 +1,54 @@
1
- import type { Plugin } from "./types.ts";
1
+ import { probeBins } from "./bin-probe.ts";
2
+ import type {
3
+ InstallContext,
4
+ InstallResult,
5
+ Plugin,
6
+ PluginCapabilities,
7
+ PluginContext,
8
+ PluginKind,
9
+ UninstallContext,
10
+ UninstallResult,
11
+ } from "./types.ts";
2
12
 
3
- export function definePlugin<T = void>(factory: (options: T) => Plugin): (options: T) => Plugin {
4
- return factory;
13
+ type Caps = Partial<PluginCapabilities>;
14
+
15
+ export type PluginDefinition<TCaps extends Caps> = {
16
+ name: string;
17
+ apiVersion: 1;
18
+ capabilities(ctx: PluginContext): TCaps | Promise<TCaps>;
19
+ install?(this: void, ctx: InstallContext): Promise<InstallResult>;
20
+ uninstall?(this: void, ctx: UninstallContext): Promise<UninstallResult>;
21
+ };
22
+
23
+ type WithOnly<TOptions, TKind extends PluginKind> = TOptions extends void
24
+ ? { only?: readonly TKind[] }
25
+ : TOptions & { only?: readonly TKind[] };
26
+
27
+ export function definePlugin<TCaps extends Caps, TOptions = void>(
28
+ factory: (options: TOptions) => PluginDefinition<TCaps>,
29
+ ): (options?: WithOnly<TOptions, keyof TCaps & PluginKind>) => Plugin {
30
+ return (options) => {
31
+ // biome-ignore lint/suspicious/noExplicitAny: factory accepts TOptions; callers without options pass undefined
32
+ const def = factory(options as any);
33
+ const only = (options as { only?: readonly string[] } | undefined)?.only;
34
+ const pkgName = `@rrlab/${def.name}-plugin`;
35
+
36
+ return {
37
+ name: def.name,
38
+ apiVersion: def.apiVersion,
39
+ install: def.install,
40
+ uninstall: def.uninstall,
41
+ async capabilities(ctx: PluginContext): Promise<PluginCapabilities> {
42
+ const map = (await def.capabilities(ctx)) as PluginCapabilities;
43
+ await probeBins(Object.values(map), def.name);
44
+ if (!only) return map;
45
+ for (const k of only) {
46
+ if (!(k in map)) {
47
+ throw new Error(`${pkgName}: unknown capability '${k}' in 'only'. Available: ${Object.keys(map).join(", ")}.`);
48
+ }
49
+ }
50
+ return Object.fromEntries(only.map((k) => [k, map[k as PluginKind]])) as PluginCapabilities;
51
+ },
52
+ };
53
+ };
5
54
  }
@@ -0,0 +1,23 @@
1
+ import type { InstallContext } from "./types.ts";
2
+
3
+ export type PickPresetOptions<K extends string> = {
4
+ message: string;
5
+ presets: Record<K, { label: string }>;
6
+ defaultPreset: K;
7
+ };
8
+
9
+ export async function pickPreset<K extends string>(ctx: InstallContext, opts: PickPresetOptions<K>): Promise<K> {
10
+ const { message, presets, defaultPreset } = opts;
11
+ if (ctx.flags.yes || ctx.flags.nonInteractive) return defaultPreset;
12
+
13
+ const choice = await ctx.prompts.select<K>({
14
+ message,
15
+ options: (Object.entries(presets) as Array<[K, { label: string }]>).map(([value, meta]) => ({
16
+ value,
17
+ label: meta.label,
18
+ })),
19
+ initialValue: defaultPreset,
20
+ });
21
+ if (ctx.prompts.isCancel(choice)) throw new Error("Cancelled by user.");
22
+ return choice;
23
+ }
@@ -18,9 +18,10 @@ export class PluginRegistry {
18
18
  if (!first) return undefined;
19
19
  if (rest.length > 0) {
20
20
  const names = providers.map(({ plugin }) => plugin.name).join(", ");
21
+ const example = providers.map(({ plugin }) => `${plugin.name}({ only: ['${kind}'] })`).join(" or ");
21
22
  throw new Error(
22
23
  `Multiple plugins provide capability '${kind}': ${names}. ` +
23
- "Disambiguate by narrowing each plugin's capabilities in run-run.config.ts.",
24
+ `Narrow each plugin's capabilities in run-run.config.ts using the 'only' option — e.g. ${example}.`,
24
25
  );
25
26
  }
26
27
  return first.impl;
@@ -20,7 +20,7 @@ export type {
20
20
  export type Packer = {
21
21
  bin: string;
22
22
  ui: string;
23
- pack(): Promise<void>;
23
+ pack: () => Promise<void>;
24
24
  };
25
25
 
26
26
  export const PLUGIN_KINDS = ["lint", "format", "jsc", "tsc", "pack"] as const;
@@ -46,7 +46,7 @@ export type PluginContext = {
46
46
  export type Plugin = {
47
47
  name: string;
48
48
  apiVersion: 1;
49
- setup(ctx: PluginContext): Promise<PluginCapabilities> | PluginCapabilities;
49
+ capabilities(ctx: PluginContext): Promise<PluginCapabilities>;
50
50
  install?(ctx: InstallContext): Promise<InstallResult>;
51
51
  uninstall?(ctx: UninstallContext): Promise<UninstallResult>;
52
52
  };
@@ -1,5 +1,6 @@
1
1
  import { type Command, createCommand } from "commander";
2
- import { TOOL_LABELS } from "#src/program/ui.ts";
2
+ import { pluginAnnotation } from "#src/program/ui.ts";
3
+ import type { Context } from "#src/services/ctx.ts";
3
4
  import { logger } from "#src/services/logger.ts";
4
5
 
5
6
  /**
@@ -15,9 +16,9 @@ import { logger } from "#src/services/logger.ts";
15
16
  * `command.js` — `fn.apply(this, actionArgs)`). `this.parent` gives us the
16
17
  * parent program without any late-binding ceremony.
17
18
  */
18
- export function createCheckCommand() {
19
+ export function createCheckCommand(ctx: Context) {
19
20
  return createCommand("check")
20
- .summary(`run static checks (${TOOL_LABELS.RUN_RUN})`)
21
+ .summary(`run static checks${checkAnnotation(ctx)}`)
21
22
  .description(
22
23
  "Runs `rr jsc` and `rr tsc` concurrently in-process. Aggregates their exit codes — non-zero when either subcommand fails.",
23
24
  )
@@ -61,3 +62,31 @@ export function createCheckCommand() {
61
62
  function findCommand(program: Command, name: string): Command | undefined {
62
63
  return program.commands.find((c) => c.name() === name || c.aliases().includes(name));
63
64
  }
65
+
66
+ /**
67
+ * Mirrors the provider resolution of `jsc` + `tsc` and flattens the
68
+ * underlying tool labels — e.g. biome (composed lint+format) + oxc (tsc)
69
+ * renders as `(biome, oxlint)` rather than `(biome + biome, oxlint)`. When
70
+ * neither sibling has a provider, falls back to the standard `(not
71
+ * configured)` annotation so the help reads consistently with other
72
+ * commands.
73
+ */
74
+ function checkAnnotation(ctx: Context): string {
75
+ const directJsc = ctx.registry.get("jsc");
76
+ const linter = ctx.registry.get("lint");
77
+ const formatter = ctx.registry.get("format");
78
+ const tsc = ctx.registry.get("tsc");
79
+
80
+ const labels: string[] = [];
81
+ if (directJsc) {
82
+ labels.push(directJsc.ui);
83
+ } else {
84
+ if (linter) labels.push(linter.ui);
85
+ if (formatter) labels.push(formatter.ui);
86
+ }
87
+ if (tsc) labels.push(tsc.ui);
88
+
89
+ if (labels.length === 0) return pluginAnnotation(undefined);
90
+ const distinct = [...new Set(labels)];
91
+ return pluginAnnotation({ ui: distinct.join(", ") });
92
+ }
@@ -212,7 +212,7 @@ async function runRemove(ctx: Context, alias: OfficialAlias, opts: RemoveOptions
212
212
  });
213
213
  }
214
214
  } catch (err) {
215
- clack.log.warn(`Could not load ${pkgName} for uninstall hook: ${err instanceof Error ? err.message : err}`);
215
+ clack.log.warn(`Could not load ${pkgName} for uninstall hook: ${err instanceof Error ? err.message : String(err)}`);
216
216
  }
217
217
  }
218
218
 
@@ -290,9 +290,9 @@ function isDistTag(spec: string): boolean {
290
290
  function hasInPackageJson(ctx: Context, pkgName: string): boolean {
291
291
  const pkg = ctx.appPkg.packageJson;
292
292
  const deps = {
293
- ...(pkg.dependencies ?? {}),
294
- ...(pkg.devDependencies ?? {}),
295
- ...(pkg.peerDependencies ?? {}),
293
+ ...pkg.dependencies,
294
+ ...pkg.devDependencies,
295
+ ...pkg.peerDependencies,
296
296
  };
297
297
  return pkgName in deps;
298
298
  }
@@ -40,7 +40,7 @@ export async function createProgram(options: Options) {
40
40
  .addCommand(createTsCheckCommand(ctx))
41
41
  .addCommand(createLintCommand(ctx))
42
42
  .addCommand(createFormatCommand(ctx))
43
- .addCommand(createCheckCommand())
43
+ .addCommand(createCheckCommand(ctx))
44
44
  .addCommand(createDoctorCommand(ctx))
45
45
  .addCommand(createPluginsCommand(ctx))
46
46
  .addCommand(createCleanCommand())
@@ -52,13 +52,12 @@ export async function createContext(binDir: string): Promise<Context> {
52
52
  };
53
53
 
54
54
  for (const plugin of config.config.plugins ?? []) {
55
- if (plugin.apiVersion !== 1) {
56
- throw new Error(
57
- `Plugin '${plugin.name}' targets apiVersion ${plugin.apiVersion}, but this kernel supports only apiVersion 1.`,
58
- );
55
+ const got = plugin.apiVersion as number;
56
+ if (got !== 1) {
57
+ throw new Error(`Plugin '${plugin.name}' targets apiVersion ${got}, but this kernel supports only apiVersion 1.`);
59
58
  }
60
59
  debug("registering plugin: %s", plugin.name);
61
- const capabilities = await plugin.setup(pluginContext);
60
+ const capabilities = await plugin.capabilities(pluginContext);
62
61
  registry.register(plugin, capabilities);
63
62
  }
64
63
 
package/src/types/tool.ts CHANGED
@@ -24,25 +24,25 @@ export type DoctorResult = {
24
24
 
25
25
  export type Doctor = {
26
26
  ui: string;
27
- doctor(): Promise<DoctorResult>;
27
+ doctor: () => Promise<DoctorResult>;
28
28
  };
29
29
 
30
30
  export type Formatter = {
31
31
  bin: string;
32
32
  ui: string;
33
- format(options: FormatOptions): Promise<void>;
33
+ format: (options: FormatOptions) => Promise<void>;
34
34
  };
35
35
 
36
36
  export type Linter = {
37
37
  bin: string;
38
38
  ui: string;
39
- lint(options: LintOptions): Promise<void>;
39
+ lint: (options: LintOptions) => Promise<void>;
40
40
  };
41
41
 
42
42
  export type StaticChecker = {
43
43
  bin: string;
44
44
  ui: string;
45
- check(options: StaticCheckerOptions): Promise<void>;
45
+ check: (options: StaticCheckerOptions) => Promise<void>;
46
46
  };
47
47
 
48
48
  export type TypeCheckOptions = {
@@ -53,5 +53,5 @@ export type TypeCheckOptions = {
53
53
  export type TypeChecker = {
54
54
  bin: string;
55
55
  ui: string;
56
- check(options?: TypeCheckOptions): Promise<void>;
56
+ check: (options?: TypeCheckOptions) => Promise<void>;
57
57
  };