@rrlab/cli 0.0.1-git-87d22db.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/README.md +110 -0
- package/bin +54 -0
- package/dist/cli.usage.kdl +71 -0
- package/dist/config.d.mts +11 -0
- package/dist/config.mjs +6 -0
- package/dist/plugin.d.mts +52 -0
- package/dist/plugin.mjs +65 -0
- package/dist/run.mjs +1137 -0
- package/dist/types-C3V27_kd.d.mts +173 -0
- package/package.json +74 -0
- package/src/lib/config.ts +5 -0
- package/src/lib/plugin.ts +31 -0
- package/src/plugin/define-plugin.ts +5 -0
- package/src/plugin/registry.ts +47 -0
- package/src/plugin/tool-service.ts +77 -0
- package/src/plugin/types.ts +139 -0
- package/src/program/commands/check.ts +63 -0
- package/src/program/commands/clean.ts +53 -0
- package/src/program/commands/completion.ts +26 -0
- package/src/program/commands/config.ts +15 -0
- package/src/program/commands/doctor.ts +85 -0
- package/src/program/commands/format.ts +35 -0
- package/src/program/commands/jscheck.ts +44 -0
- package/src/program/commands/lint.ts +37 -0
- package/src/program/commands/pack.ts +27 -0
- package/src/program/commands/plugins.ts +359 -0
- package/src/program/commands/tscheck.ts +112 -0
- package/src/program/composed-jsc.ts +35 -0
- package/src/program/index.ts +50 -0
- package/src/program/missing-plugin.ts +18 -0
- package/src/program/ui.ts +59 -0
- package/src/run.ts +11 -0
- package/src/services/config-ast.ts +202 -0
- package/src/services/config.ts +54 -0
- package/src/services/ctx.ts +72 -0
- package/src/services/json-edit.ts +147 -0
- package/src/services/logger.ts +5 -0
- package/src/services/plugins-registry.ts +21 -0
- package/src/services/prompts.ts +26 -0
- package/src/services/workspace-target.ts +27 -0
- package/src/types/config.ts +13 -0
- package/src/types/tool.ts +57 -0
- package/tsconfig.json +3 -0
package/dist/run.mjs
ADDED
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { colorize, createPkg, createShellService, cwd, dirnameOf, palette, run, text } from "@vlandoss/clibuddy";
|
|
3
|
+
import { generateToStdout } from "@usage-spec/commander";
|
|
4
|
+
import { Argument, Option, createCommand } from "commander";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import { lilconfig } from "lilconfig";
|
|
8
|
+
import { createLoggy } from "@vlandoss/loggy";
|
|
9
|
+
import { glob } from "glob";
|
|
10
|
+
import { rimraf } from "rimraf";
|
|
11
|
+
import fs$1 from "node:fs/promises";
|
|
12
|
+
import * as clack from "@clack/prompts";
|
|
13
|
+
import { addDependency, detectPackageManager, removeDependency } from "nypm";
|
|
14
|
+
import { builders, generateCode, loadFile, parseModule, writeFile } from "magicast";
|
|
15
|
+
import * as cjson from "comment-json";
|
|
16
|
+
//#region src/plugin/registry.ts
|
|
17
|
+
var PluginRegistry = class {
|
|
18
|
+
#entries = [];
|
|
19
|
+
register(plugin, capabilities) {
|
|
20
|
+
this.#entries.push({
|
|
21
|
+
plugin,
|
|
22
|
+
capabilities
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
get(kind) {
|
|
26
|
+
const providers = this.#providersOf(kind);
|
|
27
|
+
const [first, ...rest] = providers;
|
|
28
|
+
if (!first) return void 0;
|
|
29
|
+
if (rest.length > 0) {
|
|
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.`);
|
|
32
|
+
}
|
|
33
|
+
return first.impl;
|
|
34
|
+
}
|
|
35
|
+
providersOf(kind) {
|
|
36
|
+
return this.#providersOf(kind).map(({ plugin, impl }) => ({
|
|
37
|
+
name: plugin.name,
|
|
38
|
+
impl
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
plugins() {
|
|
42
|
+
return this.#entries.map(({ plugin }) => plugin);
|
|
43
|
+
}
|
|
44
|
+
#providersOf(kind) {
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const { plugin, capabilities } of this.#entries) {
|
|
47
|
+
const impl = capabilities[kind];
|
|
48
|
+
if (impl != null) out.push({
|
|
49
|
+
plugin,
|
|
50
|
+
impl
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/services/logger.ts
|
|
58
|
+
const logger = createLoggy({ namespace: "run-run" });
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/services/config.ts
|
|
61
|
+
const DEFAULT_CONFIG = { plugins: [] };
|
|
62
|
+
var ConfigService = class {
|
|
63
|
+
#searcher;
|
|
64
|
+
constructor() {
|
|
65
|
+
this.#searcher = lilconfig("run-run", {
|
|
66
|
+
searchPlaces: ["run-run.config.ts", "run-run.config.mts"],
|
|
67
|
+
cache: true,
|
|
68
|
+
stopDir: os.homedir(),
|
|
69
|
+
loaders: {
|
|
70
|
+
".ts": (filepath) => import(filepath).then((mod) => mod.default),
|
|
71
|
+
".mts": (filepath) => import(filepath).then((mod) => mod.default)
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async load() {
|
|
76
|
+
const debug = logger.subdebug("load-config");
|
|
77
|
+
const searchResult = await this.#searcher.search();
|
|
78
|
+
if (!searchResult || searchResult?.isEmpty) {
|
|
79
|
+
debug("loaded default config: %O", DEFAULT_CONFIG);
|
|
80
|
+
return {
|
|
81
|
+
config: DEFAULT_CONFIG,
|
|
82
|
+
meta: {
|
|
83
|
+
isDefault: true,
|
|
84
|
+
filepath: void 0
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const config = searchResult.config;
|
|
89
|
+
debug("loaded config: %O", config);
|
|
90
|
+
debug("config file: %s", searchResult.filepath);
|
|
91
|
+
return {
|
|
92
|
+
config,
|
|
93
|
+
meta: {
|
|
94
|
+
isDefault: false,
|
|
95
|
+
filepath: searchResult.filepath
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region src/services/ctx.ts
|
|
102
|
+
async function createContext(binDir) {
|
|
103
|
+
const debug = logger.subdebug("create-context");
|
|
104
|
+
const binPath = fs.realpathSync(binDir);
|
|
105
|
+
debug("bin path:", binPath);
|
|
106
|
+
debug("process cwd:", process.cwd());
|
|
107
|
+
const [appPkg, binPkg] = await Promise.all([createPkg(cwd), createPkg(binPath)]);
|
|
108
|
+
if (!binPkg) throw new Error("Could not find bin package.json");
|
|
109
|
+
if (!appPkg) throw new Error("Could not find app package.json");
|
|
110
|
+
debug("app pkg info: %O", appPkg.info());
|
|
111
|
+
debug("bin pkg info: %O", binPkg.info());
|
|
112
|
+
const shell = createShellService();
|
|
113
|
+
debug("shell service options: %O", shell.options);
|
|
114
|
+
const config = await new ConfigService().load();
|
|
115
|
+
const registry = new PluginRegistry();
|
|
116
|
+
const pluginContext = {
|
|
117
|
+
shell,
|
|
118
|
+
logger,
|
|
119
|
+
appPkg,
|
|
120
|
+
binPkg,
|
|
121
|
+
cwd
|
|
122
|
+
};
|
|
123
|
+
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
|
+
debug("registering plugin: %s", plugin.name);
|
|
126
|
+
const capabilities = await plugin.setup(pluginContext);
|
|
127
|
+
registry.register(plugin, capabilities);
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
appPkg,
|
|
131
|
+
binPkg,
|
|
132
|
+
shell,
|
|
133
|
+
config,
|
|
134
|
+
registry
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/program/ui.ts
|
|
139
|
+
const CREDITS_TEXT = `\nAcknowledgment:
|
|
140
|
+
- kcd-scripts: for main inspiration
|
|
141
|
+
${palette.link("https://github.com/kentcdodds/kcd-scripts")}
|
|
142
|
+
|
|
143
|
+
- peruvian news: in honor to Run Run
|
|
144
|
+
${palette.link("https://es.wikipedia.org/wiki/Run_Run")}`;
|
|
145
|
+
const rimrafColor = colorize("#7C7270");
|
|
146
|
+
const runRunColor = colorize("#E8722A");
|
|
147
|
+
const usageColor = colorize("#24C55E");
|
|
148
|
+
/**
|
|
149
|
+
* Labels used by kernel-internal commands. Plugin-owned tools (biome, oxc,
|
|
150
|
+
* tsdown, tsc) define their own colored labels inside each plugin's
|
|
151
|
+
* `src/index.ts`.
|
|
152
|
+
*/
|
|
153
|
+
const TOOL_LABELS = {
|
|
154
|
+
RIMRAF: rimrafColor("rimraf"),
|
|
155
|
+
RUN_RUN: runRunColor("run-run"),
|
|
156
|
+
USAGE: usageColor("usage")
|
|
157
|
+
};
|
|
158
|
+
const IS_USAGE_MODE = process.env.RR_USAGE_MODE === "1";
|
|
159
|
+
/**
|
|
160
|
+
* Renders the parenthesised backend hint that follows a command's summary,
|
|
161
|
+
* e.g. `pack a ts library 📦 (tsdown)` or `… (not configured)` when no plugin
|
|
162
|
+
* provides the capability.
|
|
163
|
+
*
|
|
164
|
+
* Returns an empty string when `RR_USAGE_MODE=1` is set (the kernel's `bin`
|
|
165
|
+
* script exports it during `rr --usage`) so the KDL spec stays free of
|
|
166
|
+
* per-environment state — the active plugin set is a property of the host
|
|
167
|
+
* project, not of the CLI surface.
|
|
168
|
+
*/
|
|
169
|
+
function pluginAnnotation(provider) {
|
|
170
|
+
if (IS_USAGE_MODE) return "";
|
|
171
|
+
return provider ? ` (${provider.ui})` : " (not configured)";
|
|
172
|
+
}
|
|
173
|
+
function getBannerText(version) {
|
|
174
|
+
return `
|
|
175
|
+
${runRunColor(`
|
|
176
|
+
██████╗ ██╗ ██╗███╗ ██╗ ██████╗ ██╗ ██╗███╗ ██╗
|
|
177
|
+
██╔══██╗██║ ██║████╗ ██║ ██╔══██╗██║ ██║████╗ ██║
|
|
178
|
+
██████╔╝██║ ██║██╔██╗ ██║█████╗██████╔╝██║ ██║██╔██╗ ██║
|
|
179
|
+
██╔══██╗██║ ██║██║╚██╗██║╚════╝██╔══██╗██║ ██║██║╚██╗██║
|
|
180
|
+
██║ ██║╚██████╔╝██║ ╚████║ ██║ ██║╚██████╔╝██║ ╚████║
|
|
181
|
+
╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ${text.version(version)}
|
|
182
|
+
`.trim())}
|
|
183
|
+
|
|
184
|
+
🦊 ${palette.italic(palette.muted("The CLI toolbox for"))} ${text.vland}\n`.trimStart();
|
|
185
|
+
}
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/program/commands/check.ts
|
|
188
|
+
/**
|
|
189
|
+
* `rr check` is the umbrella that runs the JS check (lint+format) and the
|
|
190
|
+
* TS type check together. Both subcommands are already wired into
|
|
191
|
+
* commander as siblings (`rr jsc`, `rr tsc`), so we reuse the program's
|
|
192
|
+
* command tree as the action registry instead of duplicating it: look the
|
|
193
|
+
* sibling up by name and invoke its action via `parseAsync([])`, which
|
|
194
|
+
* applies its declared option defaults exactly as if the user had typed
|
|
195
|
+
* `rr jsc` directly.
|
|
196
|
+
*
|
|
197
|
+
* Commander binds the running command as `this` inside an action (see
|
|
198
|
+
* `command.js` — `fn.apply(this, actionArgs)`). `this.parent` gives us the
|
|
199
|
+
* parent program without any late-binding ceremony.
|
|
200
|
+
*/
|
|
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
|
+
const program = this.parent;
|
|
204
|
+
if (!program) throw new Error("`rr check` requires the parent program to dispatch sibling subcommands.");
|
|
205
|
+
const cmds = ["jsc", "tsc"].map((name) => ({
|
|
206
|
+
name,
|
|
207
|
+
cmd: findCommand(program, name)
|
|
208
|
+
}));
|
|
209
|
+
const missing = cmds.filter(({ cmd }) => !cmd).map(({ name }) => name);
|
|
210
|
+
if (missing.length > 0) {
|
|
211
|
+
for (const name of missing) logger.error(`rr check: subcommand "${name}" is not registered.`);
|
|
212
|
+
process.exitCode = 1;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const results = await Promise.allSettled(cmds.map(({ cmd }) => cmd.parseAsync([], { from: "user" })));
|
|
216
|
+
const failed = [];
|
|
217
|
+
for (const [i, r] of results.entries()) if (r.status === "rejected") failed.push({
|
|
218
|
+
name: cmds[i]?.name ?? "?",
|
|
219
|
+
reason: r.reason
|
|
220
|
+
});
|
|
221
|
+
if (failed.length > 0) {
|
|
222
|
+
for (const { name, reason } of failed) {
|
|
223
|
+
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
224
|
+
logger.error(`rr check (${name}): ${msg}`);
|
|
225
|
+
}
|
|
226
|
+
process.exitCode = 1;
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
function findCommand(program, name) {
|
|
231
|
+
return program.commands.find((c) => c.name() === name || c.aliases().includes(name));
|
|
232
|
+
}
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region src/program/commands/clean.ts
|
|
235
|
+
function createCleanCommand() {
|
|
236
|
+
return createCommand("clean").summary(`delete dirty files (${TOOL_LABELS.RIMRAF})`).description("Deletes generated files and folders such as 'dist', 'node_modules', and lock files to ensure a clean state.").option("--only-dist", "delete 'dist' folders only").option("--dry-run", "outputs the paths that would be deleted").action(async function cleanCommandAction(options) {
|
|
237
|
+
async function run(paths, globOptions) {
|
|
238
|
+
if (options.dryRun) {
|
|
239
|
+
const toDelete = await glob(paths, globOptions);
|
|
240
|
+
logger.info("Paths that would be deleted: %O", toDelete);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
logger.start("Clean started");
|
|
244
|
+
await rimraf(paths, { glob: globOptions });
|
|
245
|
+
logger.success("Clean completed");
|
|
246
|
+
}
|
|
247
|
+
const BUILD_PATHS = ["**/dist"];
|
|
248
|
+
const ALL_DIRTY_PATHS = [
|
|
249
|
+
"**/.turbo",
|
|
250
|
+
"**/node_modules",
|
|
251
|
+
"pnpm-lock.yaml",
|
|
252
|
+
"bun.lock",
|
|
253
|
+
...BUILD_PATHS
|
|
254
|
+
];
|
|
255
|
+
if (options.onlyDist) await run(BUILD_PATHS, {
|
|
256
|
+
cwd,
|
|
257
|
+
ignore: ["**/node_modules/**"]
|
|
258
|
+
});
|
|
259
|
+
else await run(ALL_DIRTY_PATHS, { cwd });
|
|
260
|
+
}).addHelpText("afterAll", `\nUnder the hood, this command uses ${TOOL_LABELS.RIMRAF} to delete dirty folders or files.`);
|
|
261
|
+
}
|
|
262
|
+
//#endregion
|
|
263
|
+
//#region src/program/commands/completion.ts
|
|
264
|
+
const SHELLS = [
|
|
265
|
+
"bash",
|
|
266
|
+
"zsh",
|
|
267
|
+
"fish"
|
|
268
|
+
];
|
|
269
|
+
function createCompletionCommand() {
|
|
270
|
+
return createCommand("completion").summary(`print shell completion script (${TOOL_LABELS.USAGE})`).description(`Prints a shell completion script for rr. Add to your shell rc file:
|
|
271
|
+
|
|
272
|
+
bash: eval "$(rr completion bash)"
|
|
273
|
+
zsh: eval "$(rr completion zsh)"
|
|
274
|
+
fish: rr completion fish | source`).addArgument(new Argument("<shell>", `target shell`).choices(SHELLS)).addHelpText("afterAll", `\nUnder the hood, this command uses ${TOOL_LABELS.USAGE} (https://usage.jdx.dev).
|
|
275
|
+
Make sure to have it installed and available in your PATH.`);
|
|
276
|
+
}
|
|
277
|
+
//#endregion
|
|
278
|
+
//#region src/program/commands/config.ts
|
|
279
|
+
function createConfigCommand(ctx) {
|
|
280
|
+
return createCommand("config").summary("display the current config").description("Displays the current configuration settings, including their source file path if available.").action(async function configAction() {
|
|
281
|
+
const { config, meta } = ctx.config;
|
|
282
|
+
console.log(palette.muted("Config:"));
|
|
283
|
+
console.log(config);
|
|
284
|
+
console.log(palette.muted(`Loaded from ${meta.filepath ? palette.link(meta.filepath) : "n/a"}`));
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
//#endregion
|
|
288
|
+
//#region src/plugin/types.ts
|
|
289
|
+
const PLUGIN_KINDS = [
|
|
290
|
+
"lint",
|
|
291
|
+
"format",
|
|
292
|
+
"jsc",
|
|
293
|
+
"tsc",
|
|
294
|
+
"pack"
|
|
295
|
+
];
|
|
296
|
+
//#endregion
|
|
297
|
+
//#region src/program/commands/doctor.ts
|
|
298
|
+
/**
|
|
299
|
+
* Subcommand factory used by every plugin-backed command (lint, format, jsc,
|
|
300
|
+
* tsc, pack) to expose a `doctor` subcommand that verifies the underlying
|
|
301
|
+
* tool is wired correctly. Each calls this with its own provider.
|
|
302
|
+
*/
|
|
303
|
+
function createDoctorSubcommand(service) {
|
|
304
|
+
return createCommand("doctor").summary("check if the underlying tool is working correctly").action(async function doctorAction() {
|
|
305
|
+
const debug = logger.subdebug("doctor");
|
|
306
|
+
const { ok, output } = await service.doctor();
|
|
307
|
+
if (ok) {
|
|
308
|
+
logger.success(`${service.ui} ok`);
|
|
309
|
+
debug("%O", output);
|
|
310
|
+
} else {
|
|
311
|
+
logger.error(`${service.ui} not working`);
|
|
312
|
+
debug("%O", output);
|
|
313
|
+
process.exit(output.exitCode ?? 1);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Top-level `rr doctor` — runs the `doctor()` of every distinct capability
|
|
319
|
+
* impl registered with the kernel. Distinct because a single plugin (e.g.
|
|
320
|
+
* biome) often serves multiple kinds (`lint`, `format`, `jsc`) from the same
|
|
321
|
+
* `BiomeService` instance; running its doctor three times is wasteful.
|
|
322
|
+
*/
|
|
323
|
+
function createDoctorCommand(ctx) {
|
|
324
|
+
return createCommand("doctor").summary("run all plugin doctors").description("Runs the `doctor()` of every configured plugin capability. Each plugin reports ok / not working. The exit code is non-zero if any reports not working.").action(async () => {
|
|
325
|
+
const services = collectDistinctDoctors(ctx);
|
|
326
|
+
if (services.length === 0) {
|
|
327
|
+
logger.info("No plugins configured. Use `rr plugins add <name>` to install one.");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const debug = logger.subdebug("doctor");
|
|
331
|
+
const results = await Promise.all(services.map(async (svc) => {
|
|
332
|
+
return {
|
|
333
|
+
svc,
|
|
334
|
+
result: await svc.doctor()
|
|
335
|
+
};
|
|
336
|
+
}));
|
|
337
|
+
let failures = 0;
|
|
338
|
+
for (const { svc, result } of results) if (result.ok) {
|
|
339
|
+
logger.success(`${svc.ui} ok`);
|
|
340
|
+
debug("%s: %O", svc.ui, result.output);
|
|
341
|
+
} else {
|
|
342
|
+
logger.error(`${svc.ui} not working`);
|
|
343
|
+
debug("%s: %O", svc.ui, result.output);
|
|
344
|
+
failures++;
|
|
345
|
+
}
|
|
346
|
+
if (failures > 0) process.exitCode = 1;
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
function collectDistinctDoctors(ctx) {
|
|
350
|
+
const seen = /* @__PURE__ */ new Set();
|
|
351
|
+
for (const kind of PLUGIN_KINDS) for (const { impl } of ctx.registry.providersOf(kind)) seen.add(impl);
|
|
352
|
+
return [...seen];
|
|
353
|
+
}
|
|
354
|
+
//#endregion
|
|
355
|
+
//#region src/program/missing-plugin.ts
|
|
356
|
+
const SUGGESTIONS = {
|
|
357
|
+
lint: [
|
|
358
|
+
"biome",
|
|
359
|
+
"oxc",
|
|
360
|
+
"eslint"
|
|
361
|
+
],
|
|
362
|
+
format: ["biome", "oxc"],
|
|
363
|
+
jsc: ["biome"],
|
|
364
|
+
tsc: ["ts"],
|
|
365
|
+
pack: ["tsdown"]
|
|
366
|
+
};
|
|
367
|
+
function missingPluginError(kind) {
|
|
368
|
+
const aliases = SUGGESTIONS[kind] ?? [];
|
|
369
|
+
const officialList = aliases.map((a) => `@rrlab/plugin-${a}`).join(", ");
|
|
370
|
+
const addList = aliases.map((a) => `rr plugins add ${a}`).join(" | ");
|
|
371
|
+
return /* @__PURE__ */ new Error(`No plugin provides the '${kind}' capability.` + (officialList ? `\n Install one of: ${officialList}.` : "") + (addList ? `\n Try: ${addList}.` : ""));
|
|
372
|
+
}
|
|
373
|
+
//#endregion
|
|
374
|
+
//#region src/program/commands/format.ts
|
|
375
|
+
function createFormatCommand(ctx) {
|
|
376
|
+
const formatter = ctx.registry.get("format");
|
|
377
|
+
const cmd = createCommand("format").summary(`check & fix format errors${pluginAnnotation(formatter)}`).description("Checks the code for formatting issues and optionally fixes them, ensuring it adheres to the defined style standards.").option("--fix", "format all the code");
|
|
378
|
+
if (formatter) cmd.addCommand(createDoctorSubcommand(formatter));
|
|
379
|
+
cmd.action(async (options = {}) => {
|
|
380
|
+
if (!formatter) throw missingPluginError("format");
|
|
381
|
+
await formatter.format(options);
|
|
382
|
+
});
|
|
383
|
+
if (formatter) cmd.addHelpText("afterAll", `\nUnder the hood, this command uses the ${formatter.ui} CLI to format the code.`);
|
|
384
|
+
return cmd;
|
|
385
|
+
}
|
|
386
|
+
//#endregion
|
|
387
|
+
//#region src/program/composed-jsc.ts
|
|
388
|
+
/**
|
|
389
|
+
* Synthesises a `StaticChecker & Doctor` (the `jsc` capability) by composing
|
|
390
|
+
* a separately-registered linter and formatter. Used when the user's plugin
|
|
391
|
+
* set provides `lint` and `format` independently (e.g. oxc, or eslint +
|
|
392
|
+
* prettier) but no single plugin claims `jsc`.
|
|
393
|
+
*
|
|
394
|
+
* The check runs lint then format sequentially — interleaved stdout from a
|
|
395
|
+
* parallel run is hard to read for the user. `fixStaged` is dropped because
|
|
396
|
+
* the underlying tools don't have a uniform staged-aware mode.
|
|
397
|
+
*/
|
|
398
|
+
function composedJscProvider(linter, formatter) {
|
|
399
|
+
return {
|
|
400
|
+
bin: `${linter.bin}+${formatter.bin}`,
|
|
401
|
+
ui: `${linter.ui} + ${formatter.ui}`,
|
|
402
|
+
async check({ fix }) {
|
|
403
|
+
await linter.lint({ fix });
|
|
404
|
+
await formatter.format({ fix });
|
|
405
|
+
},
|
|
406
|
+
async doctor() {
|
|
407
|
+
const [lintRes, fmtRes] = await Promise.all([linter.doctor(), formatter.doctor()]);
|
|
408
|
+
const ok = lintRes.ok && fmtRes.ok;
|
|
409
|
+
const firstFailure = !lintRes.ok ? lintRes : !fmtRes.ok ? fmtRes : void 0;
|
|
410
|
+
return {
|
|
411
|
+
ok,
|
|
412
|
+
output: {
|
|
413
|
+
stdout: `${linter.ui}:\n${lintRes.output.stdout}\n\n${formatter.ui}:\n${fmtRes.output.stdout}`,
|
|
414
|
+
stderr: `${linter.ui}:\n${lintRes.output.stderr}\n\n${formatter.ui}:\n${fmtRes.output.stderr}`,
|
|
415
|
+
exitCode: firstFailure?.output.exitCode ?? 0
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
//#endregion
|
|
422
|
+
//#region src/program/commands/jscheck.ts
|
|
423
|
+
function createJsCheckCommand(ctx) {
|
|
424
|
+
const direct = ctx.registry.get("jsc");
|
|
425
|
+
const linter = ctx.registry.get("lint");
|
|
426
|
+
const formatter = ctx.registry.get("format");
|
|
427
|
+
const checker = direct ?? (linter && formatter ? composedJscProvider(linter, formatter) : void 0);
|
|
428
|
+
const cmd = createCommand("jsc").alias("jscheck").summary(`check format and lint${pluginAnnotation(checker)}`).description("Checks the code for formatting and linting issues, ensuring it adheres to the defined style and quality standards.").option("--fix", "try to fix issues automatically").option("--fix-staged", "try to fix staged files only");
|
|
429
|
+
if (checker) cmd.addCommand(createDoctorSubcommand(checker));
|
|
430
|
+
cmd.action(async (options = {}) => {
|
|
431
|
+
if (!checker) throw missingPluginError("jsc");
|
|
432
|
+
await checker.check(options);
|
|
433
|
+
});
|
|
434
|
+
if (checker) cmd.addHelpText("afterAll", `\nUnder the hood, this command uses the ${checker.ui} CLI to check the code.`);
|
|
435
|
+
return cmd;
|
|
436
|
+
}
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region src/program/commands/lint.ts
|
|
439
|
+
function createLintCommand(ctx) {
|
|
440
|
+
const linter = ctx.registry.get("lint");
|
|
441
|
+
const cmd = createCommand("lint").summary(`check & fix lint errors${pluginAnnotation(linter)}`).description("Checks the code for linting issues and optionally fixes them, ensuring it adheres to the defined quality standards.").option("-c, --check", "check if the code is valid", true).option("--fix", "try to fix all the code");
|
|
442
|
+
if (linter) cmd.addCommand(createDoctorSubcommand(linter));
|
|
443
|
+
cmd.action(async (options = {}) => {
|
|
444
|
+
if (!linter) throw missingPluginError("lint");
|
|
445
|
+
await linter.lint(options);
|
|
446
|
+
});
|
|
447
|
+
if (linter) cmd.addHelpText("afterAll", `\nUnder the hood, this command uses the ${linter.ui} CLI to lint the code.`);
|
|
448
|
+
return cmd;
|
|
449
|
+
}
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region src/program/commands/pack.ts
|
|
452
|
+
function createPackCommand(ctx) {
|
|
453
|
+
const packer = ctx.registry.get("pack");
|
|
454
|
+
const cmd = createCommand("pack").summary(`pack a ts library${pluginAnnotation(packer)}`).description("Compiles TypeScript code into JavaScript and generates type declaration files, packaging the library for distribution.");
|
|
455
|
+
if (packer) {
|
|
456
|
+
cmd.addCommand(createDoctorSubcommand(packer));
|
|
457
|
+
cmd.addHelpText("afterAll", `\nUnder the hood, this command uses the ${packer.ui} CLI to pack the project.`);
|
|
458
|
+
}
|
|
459
|
+
cmd.action(async () => {
|
|
460
|
+
if (!packer) throw missingPluginError("pack");
|
|
461
|
+
await packer.pack();
|
|
462
|
+
});
|
|
463
|
+
return cmd;
|
|
464
|
+
}
|
|
465
|
+
//#endregion
|
|
466
|
+
//#region src/services/config-ast.ts
|
|
467
|
+
const CONFIG_FILENAMES = ["run-run.config.ts", "run-run.config.mts"];
|
|
468
|
+
/**
|
|
469
|
+
* AST-level read/write for `run-run.config.{ts,mts}`. Wraps `magicast` so that
|
|
470
|
+
* adding/removing a plugin survives formatting, comments, and unrelated config
|
|
471
|
+
* options. The kernel and the `rr plugins add | remove` command use this
|
|
472
|
+
* service exclusively — no regex-based edits.
|
|
473
|
+
*/
|
|
474
|
+
var ConfigAstService = class {
|
|
475
|
+
/**
|
|
476
|
+
* Looks for an existing `run-run.config.{ts,mts}` in `cwd`. If neither
|
|
477
|
+
* exists, returns a fresh magicast module built from a minimal template
|
|
478
|
+
* marked as `isNew: true`; the caller decides where to save it.
|
|
479
|
+
*/
|
|
480
|
+
async load(cwd) {
|
|
481
|
+
for (const name of CONFIG_FILENAMES) {
|
|
482
|
+
const candidate = path.join(cwd, name);
|
|
483
|
+
try {
|
|
484
|
+
await fs$1.access(candidate);
|
|
485
|
+
return {
|
|
486
|
+
mod: await loadFile(candidate),
|
|
487
|
+
filepath: candidate,
|
|
488
|
+
isNew: false
|
|
489
|
+
};
|
|
490
|
+
} catch {}
|
|
491
|
+
}
|
|
492
|
+
const filepath = path.join(cwd, "run-run.config.mts");
|
|
493
|
+
return {
|
|
494
|
+
mod: parseModule(MINIMAL_TEMPLATE),
|
|
495
|
+
filepath,
|
|
496
|
+
isNew: true
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
async save(loaded) {
|
|
500
|
+
if (loaded.isNew) {
|
|
501
|
+
const { code } = generateCode(loaded.mod);
|
|
502
|
+
await fs$1.writeFile(loaded.filepath, code, "utf8");
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
await writeFile(loaded.mod.$ast, loaded.filepath);
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Returns true when the config's `plugins` array contains a call to the
|
|
509
|
+
* given `exportName` (e.g. `biome()`).
|
|
510
|
+
*/
|
|
511
|
+
hasPlugin(mod, exportName) {
|
|
512
|
+
const plugins = this.#pluginsArray(mod);
|
|
513
|
+
if (!plugins) return false;
|
|
514
|
+
for (let i = 0; i < plugins.length; i++) {
|
|
515
|
+
const item = plugins[i];
|
|
516
|
+
if (this.#isCallTo(item, exportName)) return true;
|
|
517
|
+
}
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Adds the import binding and pushes a `exportName()` call onto `plugins`.
|
|
522
|
+
* No-op when the call is already present (idempotent).
|
|
523
|
+
*/
|
|
524
|
+
addPlugin(mod, entry) {
|
|
525
|
+
if (this.hasPlugin(mod, entry.exportName)) return { changed: false };
|
|
526
|
+
if (!mod.imports[entry.exportName]) mod.imports.$add({
|
|
527
|
+
from: entry.pkgName,
|
|
528
|
+
imported: "default",
|
|
529
|
+
local: entry.exportName
|
|
530
|
+
});
|
|
531
|
+
this.#ensureDefineConfig(mod);
|
|
532
|
+
this.#ensurePluginsArray(mod).push(builders.functionCall(entry.exportName));
|
|
533
|
+
return { changed: true };
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Removes the `exportName()` call from `plugins[]` and, if it was the last
|
|
537
|
+
* use of that local binding, removes the import too.
|
|
538
|
+
*/
|
|
539
|
+
removePlugin(mod, exportName) {
|
|
540
|
+
const plugins = this.#pluginsArray(mod);
|
|
541
|
+
if (!plugins) return { changed: false };
|
|
542
|
+
let changed = false;
|
|
543
|
+
for (let i = plugins.length - 1; i >= 0; i--) if (this.#isCallTo(plugins[i], exportName)) {
|
|
544
|
+
plugins.splice(i, 1);
|
|
545
|
+
changed = true;
|
|
546
|
+
}
|
|
547
|
+
if (changed && mod.imports[exportName]) delete mod.imports[exportName];
|
|
548
|
+
return { changed };
|
|
549
|
+
}
|
|
550
|
+
/** Returns the list of plugin exportNames currently in `plugins[]`. */
|
|
551
|
+
listPlugins(mod) {
|
|
552
|
+
const plugins = this.#pluginsArray(mod);
|
|
553
|
+
if (!plugins) return [];
|
|
554
|
+
const out = [];
|
|
555
|
+
for (let i = 0; i < plugins.length; i++) {
|
|
556
|
+
const item = plugins[i];
|
|
557
|
+
const name = this.#calleeName(item);
|
|
558
|
+
if (name) out.push(name);
|
|
559
|
+
}
|
|
560
|
+
return out;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Locates `defineConfig({ plugins: [...] })` and returns the plugins array
|
|
564
|
+
* proxy. Returns `undefined` if the config doesn't have that shape.
|
|
565
|
+
*/
|
|
566
|
+
#pluginsArray(mod) {
|
|
567
|
+
const def = mod.exports.default;
|
|
568
|
+
if (!def || def.$type !== "function-call") return void 0;
|
|
569
|
+
const args = def.$args;
|
|
570
|
+
if (!args || args.length === 0) return void 0;
|
|
571
|
+
const opts = args[0];
|
|
572
|
+
if (!opts || typeof opts !== "object") return void 0;
|
|
573
|
+
return opts.plugins;
|
|
574
|
+
}
|
|
575
|
+
#ensureDefineConfig(mod) {
|
|
576
|
+
if (!mod.imports.defineConfig) mod.imports.$add({
|
|
577
|
+
from: "@rrlab/cli/config",
|
|
578
|
+
imported: "defineConfig",
|
|
579
|
+
local: "defineConfig"
|
|
580
|
+
});
|
|
581
|
+
if (!mod.exports.default) mod.exports.default = builders.functionCall("defineConfig", { plugins: [] });
|
|
582
|
+
}
|
|
583
|
+
#ensurePluginsArray(mod) {
|
|
584
|
+
const args = mod.exports.default.$args;
|
|
585
|
+
if (args.length === 0) args.push({ plugins: [] });
|
|
586
|
+
const opts = args[0];
|
|
587
|
+
if (!opts.plugins) opts.plugins = [];
|
|
588
|
+
return opts.plugins;
|
|
589
|
+
}
|
|
590
|
+
#isCallTo(item, exportName) {
|
|
591
|
+
return this.#calleeName(item) === exportName;
|
|
592
|
+
}
|
|
593
|
+
#calleeName(item) {
|
|
594
|
+
if (!item || typeof item !== "object") return void 0;
|
|
595
|
+
const proxy = item;
|
|
596
|
+
if (proxy.$type !== "function-call") return void 0;
|
|
597
|
+
const callee = proxy.$callee;
|
|
598
|
+
if (typeof callee === "string") return callee;
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
const MINIMAL_TEMPLATE = `import { defineConfig } from "@rrlab/cli/config";
|
|
602
|
+
|
|
603
|
+
export default defineConfig({
|
|
604
|
+
plugins: [],
|
|
605
|
+
});
|
|
606
|
+
`;
|
|
607
|
+
//#endregion
|
|
608
|
+
//#region src/services/json-edit.ts
|
|
609
|
+
/**
|
|
610
|
+
* Applies a sequence of `JsonEdit` ops to a JSON / JSONC source string.
|
|
611
|
+
* Uses `comment-json` so user comments and `extends`-like top-level shape
|
|
612
|
+
* survive a parse → mutate → stringify round-trip.
|
|
613
|
+
*
|
|
614
|
+
* Paths follow JSON Pointer (RFC 6901): `"/extends"`,
|
|
615
|
+
* `"/compilerOptions/strict"`. Indices are valid path segments for arrays
|
|
616
|
+
* (`"/extends/0"`).
|
|
617
|
+
*/
|
|
618
|
+
function applyJsonEdits(source, edits) {
|
|
619
|
+
let root = source.trim() === "" ? {} : cjson.parse(source);
|
|
620
|
+
if (root == null || typeof root !== "object") throw new Error(`applyJsonEdits: expected a JSON object/array at the top level, got ${typeof root}.`);
|
|
621
|
+
for (const edit of edits) root = applyOne(root, edit);
|
|
622
|
+
return `${cjson.stringify(root, null, 2)}\n`;
|
|
623
|
+
}
|
|
624
|
+
function applyOne(root, edit) {
|
|
625
|
+
const segments = parsePointer(edit.path);
|
|
626
|
+
if (edit.op === "set") {
|
|
627
|
+
const existing = resolve(root, segments);
|
|
628
|
+
if (edit.mode === "if-missing" && existing !== void 0) return root;
|
|
629
|
+
return setAt(root, segments, edit.value);
|
|
630
|
+
}
|
|
631
|
+
if (edit.op === "unset") return unsetAt(root, segments);
|
|
632
|
+
if (edit.op === "include") return includeInArray(root, segments, edit.value, edit.position ?? "end");
|
|
633
|
+
if (edit.op === "exclude") return excludeFromArray(root, segments, edit.value);
|
|
634
|
+
return root;
|
|
635
|
+
}
|
|
636
|
+
/** Parses a JSON Pointer (RFC 6901) into its segments, decoding `~1` and `~0`. */
|
|
637
|
+
function parsePointer(pointer) {
|
|
638
|
+
if (pointer === "" || pointer === "/") return [];
|
|
639
|
+
if (!pointer.startsWith("/")) throw new Error(`Invalid JSON Pointer "${pointer}": must start with "/" or be empty.`);
|
|
640
|
+
return pointer.slice(1).split("/").map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
641
|
+
}
|
|
642
|
+
function resolve(root, segments) {
|
|
643
|
+
let cur = root;
|
|
644
|
+
for (const seg of segments) {
|
|
645
|
+
if (cur == null) return void 0;
|
|
646
|
+
cur = cur[arrayIndexIfNumeric(cur, seg)];
|
|
647
|
+
}
|
|
648
|
+
return cur;
|
|
649
|
+
}
|
|
650
|
+
function setAt(root, segments, value) {
|
|
651
|
+
if (segments.length === 0) return value;
|
|
652
|
+
let cur = root;
|
|
653
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
654
|
+
const seg = segments[i] ?? "";
|
|
655
|
+
const key = arrayIndexIfNumeric(cur, seg);
|
|
656
|
+
if (cur[key] == null || typeof cur[key] !== "object") cur[key] = isNumericIndex(segments[i + 1] ?? "") ? [] : {};
|
|
657
|
+
cur = cur[key];
|
|
658
|
+
}
|
|
659
|
+
const last = segments[segments.length - 1] ?? "";
|
|
660
|
+
cur[arrayIndexIfNumeric(cur, last)] = value;
|
|
661
|
+
return root;
|
|
662
|
+
}
|
|
663
|
+
function unsetAt(root, segments) {
|
|
664
|
+
if (segments.length === 0) return root;
|
|
665
|
+
let cur = root;
|
|
666
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
667
|
+
const seg = segments[i] ?? "";
|
|
668
|
+
if (cur == null) return root;
|
|
669
|
+
cur = cur[arrayIndexIfNumeric(cur, seg)];
|
|
670
|
+
}
|
|
671
|
+
if (cur == null) return root;
|
|
672
|
+
const last = segments[segments.length - 1] ?? "";
|
|
673
|
+
if (Array.isArray(cur) && isNumericIndex(last)) cur.splice(Number(last), 1);
|
|
674
|
+
else delete cur[last];
|
|
675
|
+
return root;
|
|
676
|
+
}
|
|
677
|
+
function includeInArray(root, segments, value, position) {
|
|
678
|
+
const existing = resolve(root, segments);
|
|
679
|
+
if (existing === void 0) return setAt(root, segments, [value]);
|
|
680
|
+
if (!Array.isArray(existing)) throw new Error(`include: expected an array at "${segments.join("/")}", got ${typeof existing}.`);
|
|
681
|
+
if (existing.some((item) => deepEqual(item, value))) return root;
|
|
682
|
+
if (position === "start") existing.unshift(value);
|
|
683
|
+
else existing.push(value);
|
|
684
|
+
return root;
|
|
685
|
+
}
|
|
686
|
+
function excludeFromArray(root, segments, value) {
|
|
687
|
+
const existing = resolve(root, segments);
|
|
688
|
+
if (!Array.isArray(existing)) return root;
|
|
689
|
+
const idx = existing.findIndex((item) => deepEqual(item, value));
|
|
690
|
+
if (idx >= 0) existing.splice(idx, 1);
|
|
691
|
+
return root;
|
|
692
|
+
}
|
|
693
|
+
function arrayIndexIfNumeric(target, seg) {
|
|
694
|
+
return Array.isArray(target) && isNumericIndex(seg) ? Number(seg) : seg;
|
|
695
|
+
}
|
|
696
|
+
function isNumericIndex(seg) {
|
|
697
|
+
return seg !== "" && /^\d+$/.test(seg);
|
|
698
|
+
}
|
|
699
|
+
function deepEqual(a, b) {
|
|
700
|
+
if (a === b) return true;
|
|
701
|
+
if (typeof a !== "object" || typeof b !== "object" || a == null || b == null) return false;
|
|
702
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
703
|
+
}
|
|
704
|
+
//#endregion
|
|
705
|
+
//#region src/services/plugins-registry.ts
|
|
706
|
+
/**
|
|
707
|
+
* Map from short aliases the user types (`rr plugins add ts`) to the
|
|
708
|
+
* full npm package name and the canonical local binding the plugin file
|
|
709
|
+
* is imported as inside `run-run.config.{ts,mts}`.
|
|
710
|
+
*
|
|
711
|
+
* Third-party plugins use their full package name; the binding is derived
|
|
712
|
+
* by stripping a `@scope/plugin-` (or `@scope/run-run-plugin-`) prefix.
|
|
713
|
+
*/
|
|
714
|
+
const OFFICIAL_PLUGINS = {
|
|
715
|
+
ts: {
|
|
716
|
+
pkg: "@rrlab/plugin-ts",
|
|
717
|
+
exportName: "ts"
|
|
718
|
+
},
|
|
719
|
+
eslint: {
|
|
720
|
+
pkg: "@rrlab/plugin-eslint",
|
|
721
|
+
exportName: "eslint"
|
|
722
|
+
},
|
|
723
|
+
biome: {
|
|
724
|
+
pkg: "@rrlab/plugin-biome",
|
|
725
|
+
exportName: "biome"
|
|
726
|
+
},
|
|
727
|
+
oxc: {
|
|
728
|
+
pkg: "@rrlab/plugin-oxc",
|
|
729
|
+
exportName: "oxc"
|
|
730
|
+
},
|
|
731
|
+
tsdown: {
|
|
732
|
+
pkg: "@rrlab/plugin-tsdown",
|
|
733
|
+
exportName: "tsdown"
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
function officialAliases() {
|
|
737
|
+
return Object.keys(OFFICIAL_PLUGINS);
|
|
738
|
+
}
|
|
739
|
+
//#endregion
|
|
740
|
+
//#region src/services/prompts.ts
|
|
741
|
+
/**
|
|
742
|
+
* Adapter that exposes the subset of `@clack/prompts` matching the
|
|
743
|
+
* `ClackPrompts` contract from `@rrlab/cli/plugin`. The contract intentionally
|
|
744
|
+
* stays narrow (`select`, `confirm`, `isCancel`) so plugin install hooks can
|
|
745
|
+
* be tested by injecting a stub without pulling in the real terminal IO.
|
|
746
|
+
*
|
|
747
|
+
* The casts compensate for two small type-shape mismatches:
|
|
748
|
+
* - Our contract requires `label: string`; clack types `label?: string`.
|
|
749
|
+
* - `clack.select` returns `Promise<unknown>` (a non-cancelled value or
|
|
750
|
+
* `symbol`); our contract narrows that to `Promise<T | symbol>`.
|
|
751
|
+
*
|
|
752
|
+
* Both widenings are safe: every required field is present, and clack's
|
|
753
|
+
* runtime returns either the picked option's `value` (typed `T`) or
|
|
754
|
+
* `clack.isCancel(...)`-detectable `symbol`.
|
|
755
|
+
*/
|
|
756
|
+
function createClackPrompts() {
|
|
757
|
+
return {
|
|
758
|
+
select: (opts) => clack.select(opts),
|
|
759
|
+
confirm: (opts) => clack.confirm(opts),
|
|
760
|
+
isCancel: (value) => clack.isCancel(value)
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
//#endregion
|
|
764
|
+
//#region src/services/workspace-target.ts
|
|
765
|
+
function resolveWorkspaceChoice(appPkg, pm) {
|
|
766
|
+
if (!appPkg.isMonorepo()) return { kind: "current" };
|
|
767
|
+
return {
|
|
768
|
+
kind: "root",
|
|
769
|
+
flag: pmNeedsRootFlag(pm) ? true : void 0
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
function toNypmWorkspace(choice) {
|
|
773
|
+
return choice.kind === "current" ? void 0 : choice.flag;
|
|
774
|
+
}
|
|
775
|
+
function describeWorkspaceChoice(choice) {
|
|
776
|
+
return choice.kind === "current" ? "the current package" : "the workspace root";
|
|
777
|
+
}
|
|
778
|
+
function pmNeedsRootFlag(pm) {
|
|
779
|
+
if (!pm) return false;
|
|
780
|
+
if (pm.name === "pnpm") return true;
|
|
781
|
+
if (pm.name === "yarn" && (!pm.majorVersion || pm.majorVersion === "1")) return true;
|
|
782
|
+
return false;
|
|
783
|
+
}
|
|
784
|
+
//#endregion
|
|
785
|
+
//#region src/program/commands/plugins.ts
|
|
786
|
+
function createPluginsCommand(ctx) {
|
|
787
|
+
const cmd = createCommand("plugins").description("manage @rrlab plugins");
|
|
788
|
+
cmd.command("list").description("list plugins configured in run-run.config.{ts,mts}").action(() => runList(ctx));
|
|
789
|
+
cmd.command("add").description("install and configure an @rrlab plugin").addArgument(new Argument("<name>", "plugin alias").choices(officialAliases())).option("--force", "re-run install even if the plugin is already configured").option("--yes", "skip prompts and use defaults (non-interactive)").option("--dry-run", "show what would happen, without applying changes").action((name, opts) => runAdd(ctx, name, opts));
|
|
790
|
+
cmd.command("remove").description("uninstall an @rrlab plugin and undo its config files + deps").addArgument(new Argument("<name>", "plugin alias to remove").choices(officialAliases())).option("--yes", "skip the confirmation prompt").option("--dry-run", "print the plan without applying changes").action((name, opts) => runRemove(ctx, name, opts));
|
|
791
|
+
return cmd;
|
|
792
|
+
}
|
|
793
|
+
async function runList(ctx) {
|
|
794
|
+
const ast = new ConfigAstService();
|
|
795
|
+
const loaded = await ast.load(ctx.appPkg.dirPath);
|
|
796
|
+
if (loaded.isNew) {
|
|
797
|
+
logger.info("No run-run.config.{ts,mts} found. Use `rr plugins add <name>` to start.");
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
const plugins = ast.listPlugins(loaded.mod);
|
|
801
|
+
const rel = path.relative(ctx.appPkg.dirPath, loaded.filepath) || loaded.filepath;
|
|
802
|
+
if (plugins.length === 0) {
|
|
803
|
+
logger.info(`${rel}: no plugins configured.`);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
logger.info(`${rel}:`);
|
|
807
|
+
for (const name of plugins) logger.info(` - ${name}`);
|
|
808
|
+
}
|
|
809
|
+
async function runAdd(ctx, alias, opts) {
|
|
810
|
+
const { pkg: pkgName, exportName } = OFFICIAL_PLUGINS[alias];
|
|
811
|
+
clack.intro(` rr plugins add ${alias} `);
|
|
812
|
+
const inPkg = hasInPackageJson(ctx, pkgName);
|
|
813
|
+
const ast = new ConfigAstService();
|
|
814
|
+
const loaded = await ast.load(ctx.appPkg.dirPath);
|
|
815
|
+
const inConfig = !loaded.isNew && ast.hasPlugin(loaded.mod, exportName);
|
|
816
|
+
if (inPkg && inConfig && !opts.force) {
|
|
817
|
+
clack.log.warn(`${pkgName} is already installed and configured. Use --force to re-run install.`);
|
|
818
|
+
clack.outro("Nothing to do.");
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const pm = await detectPackageManager(ctx.appPkg.dirPath);
|
|
822
|
+
const wsChoice = resolveWorkspaceChoice(ctx.appPkg, pm);
|
|
823
|
+
const workspace = toNypmWorkspace(wsChoice);
|
|
824
|
+
const targetLabel = describeWorkspaceChoice(wsChoice);
|
|
825
|
+
if (opts.dryRun) {
|
|
826
|
+
clack.log.info(`Would: install ${pkgName} as a devDependency in ${targetLabel}${inPkg ? " (already present, skipped)" : ""}.`);
|
|
827
|
+
if (!inConfig) {
|
|
828
|
+
const rel = path.relative(ctx.appPkg.dirPath, loaded.filepath) || loaded.filepath;
|
|
829
|
+
clack.log.info(`Would: add ${exportName}() to ${rel} (plugins[]).`);
|
|
830
|
+
}
|
|
831
|
+
clack.log.info("Would: run the plugin's install() hook (if any) to fetch peer deps and create files.");
|
|
832
|
+
clack.outro("Dry run complete.");
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
let installedNow = false;
|
|
836
|
+
if (!inPkg) {
|
|
837
|
+
await withSpinner(`Installing ${pkgName}`, async () => {
|
|
838
|
+
await addDependency([pkgName], {
|
|
839
|
+
cwd: ctx.appPkg.dirPath,
|
|
840
|
+
dev: true,
|
|
841
|
+
silent: true,
|
|
842
|
+
workspace
|
|
843
|
+
});
|
|
844
|
+
});
|
|
845
|
+
installedNow = true;
|
|
846
|
+
}
|
|
847
|
+
let installResult;
|
|
848
|
+
try {
|
|
849
|
+
const factory = (await import(pkgName)).default;
|
|
850
|
+
if (typeof factory !== "function") throw new Error(`Plugin '${pkgName}' did not export a default factory function.`);
|
|
851
|
+
const plugin = factory();
|
|
852
|
+
if (plugin.install) {
|
|
853
|
+
const installCtx = {
|
|
854
|
+
shell: ctx.shell,
|
|
855
|
+
logger,
|
|
856
|
+
appPkg: ctx.appPkg,
|
|
857
|
+
prompts: createClackPrompts(),
|
|
858
|
+
flags: {
|
|
859
|
+
force: !!opts.force,
|
|
860
|
+
yes: !!opts.yes,
|
|
861
|
+
nonInteractive: !!opts.yes
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
installResult = await plugin.install(installCtx);
|
|
865
|
+
}
|
|
866
|
+
} catch (err) {
|
|
867
|
+
if (installedNow) try {
|
|
868
|
+
await removeDependency(pkgName, {
|
|
869
|
+
cwd: ctx.appPkg.dirPath,
|
|
870
|
+
workspace
|
|
871
|
+
});
|
|
872
|
+
} catch {}
|
|
873
|
+
throw err;
|
|
874
|
+
}
|
|
875
|
+
if (installResult?.devDependencies && Object.keys(installResult.devDependencies).length > 0) {
|
|
876
|
+
const names = Object.keys(installResult.devDependencies);
|
|
877
|
+
const deps = Object.entries(installResult.devDependencies).map(([k, v]) => `${k}@${v}`);
|
|
878
|
+
await withSpinner(`Installing ${names.join(", ")}`, async () => {
|
|
879
|
+
await addDependency(deps, {
|
|
880
|
+
cwd: ctx.appPkg.dirPath,
|
|
881
|
+
dev: true,
|
|
882
|
+
silent: true,
|
|
883
|
+
workspace
|
|
884
|
+
});
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
for (const op of installResult?.files ?? []) await applyFileOp(ctx.appPkg.dirPath, op, !!opts.force);
|
|
888
|
+
if (!inConfig) {
|
|
889
|
+
ast.addPlugin(loaded.mod, {
|
|
890
|
+
exportName,
|
|
891
|
+
pkgName
|
|
892
|
+
});
|
|
893
|
+
await ast.save(loaded);
|
|
894
|
+
const rel = path.relative(ctx.appPkg.dirPath, loaded.filepath) || loaded.filepath;
|
|
895
|
+
clack.log.success(`Updated ${rel}`);
|
|
896
|
+
}
|
|
897
|
+
clack.outro(`Plugin '${alias}' ready 🎉`);
|
|
898
|
+
}
|
|
899
|
+
async function runRemove(ctx, alias, opts) {
|
|
900
|
+
const { pkg: pkgName, exportName } = OFFICIAL_PLUGINS[alias];
|
|
901
|
+
clack.intro(` rr plugins remove ${alias} `);
|
|
902
|
+
const ast = new ConfigAstService();
|
|
903
|
+
const loaded = await ast.load(ctx.appPkg.dirPath);
|
|
904
|
+
const inConfig = !loaded.isNew && ast.hasPlugin(loaded.mod, exportName);
|
|
905
|
+
let uninstallResult;
|
|
906
|
+
if (hasInPackageJson(ctx, pkgName)) try {
|
|
907
|
+
const factory = (await import(pkgName)).default;
|
|
908
|
+
const plugin = typeof factory === "function" ? factory() : void 0;
|
|
909
|
+
if (plugin?.uninstall) uninstallResult = await plugin.uninstall({
|
|
910
|
+
shell: ctx.shell,
|
|
911
|
+
logger,
|
|
912
|
+
appPkg: ctx.appPkg,
|
|
913
|
+
prompts: createClackPrompts(),
|
|
914
|
+
flags: {
|
|
915
|
+
yes: !!opts.yes,
|
|
916
|
+
nonInteractive: !!opts.yes
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
} catch (err) {
|
|
920
|
+
clack.log.warn(`Could not load ${pkgName} for uninstall hook: ${err instanceof Error ? err.message : err}`);
|
|
921
|
+
}
|
|
922
|
+
const planSteps = [];
|
|
923
|
+
if (inConfig) {
|
|
924
|
+
const rel = path.relative(ctx.appPkg.dirPath, loaded.filepath) || loaded.filepath;
|
|
925
|
+
planSteps.push(`Remove ${exportName}() from ${rel}`);
|
|
926
|
+
}
|
|
927
|
+
for (const op of uninstallResult?.files ?? []) planSteps.push(describeFileOp(op));
|
|
928
|
+
const depsToRemove = (uninstallResult?.removeDependencies ?? []).filter((dep) => hasInPackageJson(ctx, dep));
|
|
929
|
+
if (hasInPackageJson(ctx, pkgName) && !depsToRemove.includes(pkgName)) depsToRemove.unshift(pkgName);
|
|
930
|
+
const pm = await detectPackageManager(ctx.appPkg.dirPath);
|
|
931
|
+
const wsChoice = resolveWorkspaceChoice(ctx.appPkg, pm);
|
|
932
|
+
const workspace = toNypmWorkspace(wsChoice);
|
|
933
|
+
if (depsToRemove.length > 0) planSteps.push(`Uninstall: ${depsToRemove.join(", ")} (from ${describeWorkspaceChoice(wsChoice)})`);
|
|
934
|
+
if (planSteps.length === 0) {
|
|
935
|
+
clack.log.warn(`Plugin '${alias}' is not installed nor configured.`);
|
|
936
|
+
clack.outro("Nothing to do.");
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
clack.log.message(`Plan:\n${planSteps.map((s) => ` • ${s}`).join("\n")}`);
|
|
940
|
+
if (opts.dryRun) {
|
|
941
|
+
clack.outro("Dry run complete.");
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
if (!opts.yes) {
|
|
945
|
+
const choice = await clack.confirm({
|
|
946
|
+
message: "Proceed?",
|
|
947
|
+
initialValue: false
|
|
948
|
+
});
|
|
949
|
+
if (clack.isCancel(choice) || choice !== true) {
|
|
950
|
+
clack.outro("Aborted.");
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
for (const op of uninstallResult?.files ?? []) await applyFileOp(ctx.appPkg.dirPath, op, true);
|
|
955
|
+
if (inConfig) {
|
|
956
|
+
ast.removePlugin(loaded.mod, exportName);
|
|
957
|
+
await ast.save(loaded);
|
|
958
|
+
const rel = path.relative(ctx.appPkg.dirPath, loaded.filepath) || loaded.filepath;
|
|
959
|
+
clack.log.success(`Removed ${exportName}() from ${rel}`);
|
|
960
|
+
}
|
|
961
|
+
for (const dep of depsToRemove) await withSpinner(`Uninstalling ${dep}`, async () => {
|
|
962
|
+
await removeDependency(dep, {
|
|
963
|
+
cwd: ctx.appPkg.dirPath,
|
|
964
|
+
workspace
|
|
965
|
+
});
|
|
966
|
+
});
|
|
967
|
+
clack.outro(`Plugin '${alias}' removed.`);
|
|
968
|
+
}
|
|
969
|
+
function hasInPackageJson(ctx, pkgName) {
|
|
970
|
+
const pkg = ctx.appPkg.packageJson;
|
|
971
|
+
return pkgName in {
|
|
972
|
+
...pkg.dependencies ?? {},
|
|
973
|
+
...pkg.devDependencies ?? {},
|
|
974
|
+
...pkg.peerDependencies ?? {}
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
async function withSpinner(message, fn) {
|
|
978
|
+
const sp = clack.spinner();
|
|
979
|
+
sp.start(message);
|
|
980
|
+
try {
|
|
981
|
+
const result = await fn();
|
|
982
|
+
sp.stop(message);
|
|
983
|
+
return result;
|
|
984
|
+
} catch (err) {
|
|
985
|
+
sp.stop(`${message} — failed`, 1);
|
|
986
|
+
throw err;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
function describeFileOp(op) {
|
|
990
|
+
switch (op.kind) {
|
|
991
|
+
case "create": return `${op.overwrite ? "Overwrite" : "Create"} ${op.path}`;
|
|
992
|
+
case "edit-json": return `Edit ${op.path} (${op.edits.length} change${op.edits.length === 1 ? "" : "s"})`;
|
|
993
|
+
case "edit-text": return `Edit ${op.path}`;
|
|
994
|
+
case "delete": return `Delete ${op.path}`;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
async function applyFileOp(cwd, op, force) {
|
|
998
|
+
const abs = path.join(cwd, op.path);
|
|
999
|
+
if (op.kind === "create") {
|
|
1000
|
+
const exists = await pathExists(abs);
|
|
1001
|
+
if (exists && !op.overwrite && !force) {
|
|
1002
|
+
clack.log.warn(`Skipping ${op.path} — already exists. Use --force to overwrite.`);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
await fs$1.mkdir(path.dirname(abs), { recursive: true });
|
|
1006
|
+
await fs$1.writeFile(abs, op.content, "utf8");
|
|
1007
|
+
clack.log.success(`${exists ? "Overwrote" : "Created"} ${op.path}`);
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
if (op.kind === "edit-json") {
|
|
1011
|
+
if (!await pathExists(abs)) {
|
|
1012
|
+
clack.log.warn(`Skipping ${op.path} — file does not exist.`);
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
const source = await fs$1.readFile(abs, "utf8");
|
|
1016
|
+
const next = applyJsonEdits(source, op.edits);
|
|
1017
|
+
if (next !== source) {
|
|
1018
|
+
await fs$1.writeFile(abs, next, "utf8");
|
|
1019
|
+
clack.log.success(`Edited ${op.path}`);
|
|
1020
|
+
} else clack.log.info(`No changes for ${op.path}.`);
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
if (op.kind === "edit-text") {
|
|
1024
|
+
if (!await pathExists(abs)) {
|
|
1025
|
+
clack.log.warn(`Skipping ${op.path} — file does not exist.`);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
const source = await fs$1.readFile(abs, "utf8");
|
|
1029
|
+
const next = op.edit(source);
|
|
1030
|
+
if (next !== source) {
|
|
1031
|
+
await fs$1.writeFile(abs, next, "utf8");
|
|
1032
|
+
clack.log.success(`Edited ${op.path}`);
|
|
1033
|
+
} else clack.log.info(`No changes for ${op.path}.`);
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
if (op.kind === "delete") {
|
|
1037
|
+
if (!await pathExists(abs)) return;
|
|
1038
|
+
await fs$1.unlink(abs);
|
|
1039
|
+
clack.log.success(`Deleted ${op.path}`);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
async function pathExists(p) {
|
|
1044
|
+
try {
|
|
1045
|
+
await fs$1.access(p);
|
|
1046
|
+
return true;
|
|
1047
|
+
} catch {
|
|
1048
|
+
return false;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
//#endregion
|
|
1052
|
+
//#region src/program/commands/tscheck.ts
|
|
1053
|
+
const getPreScript = (scripts) => scripts?.pretsc ?? scripts?.pretypecheck;
|
|
1054
|
+
async function typecheckAt({ dir, scripts, log, shell, tsc }) {
|
|
1055
|
+
log.debug(`checking types at ${dir}`);
|
|
1056
|
+
const shellAt = cwd === dir ? shell : shell.at(dir);
|
|
1057
|
+
try {
|
|
1058
|
+
const preScript = getPreScript(scripts);
|
|
1059
|
+
if (preScript) {
|
|
1060
|
+
log.start(`Running pre-script: ${preScript}`);
|
|
1061
|
+
await shellAt.run(preScript, [], { shell: true });
|
|
1062
|
+
log.success("Pre-script completed");
|
|
1063
|
+
}
|
|
1064
|
+
log.start("Type checking started");
|
|
1065
|
+
if (cwd === dir) await tsc.check();
|
|
1066
|
+
else await tsc.check({ cwd: dir });
|
|
1067
|
+
log.success("Typecheck completed");
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
log.error("Typecheck failed");
|
|
1070
|
+
throw error;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
function createTsCheckCommand(ctx) {
|
|
1074
|
+
const { appPkg, shell } = ctx;
|
|
1075
|
+
const tsc = ctx.registry.get("tsc");
|
|
1076
|
+
const cmd = createCommand("tsc").alias("tscheck").summary(`check typescript errors${pluginAnnotation(tsc)}`).description("Checks the TypeScript code for type errors, ensuring that the code adheres to the defined type constraints and helps catch potential issues before runtime.");
|
|
1077
|
+
if (tsc) {
|
|
1078
|
+
cmd.addCommand(createDoctorSubcommand(tsc));
|
|
1079
|
+
cmd.addHelpText("afterAll", `\nUnder the hood, this command uses the ${tsc.ui} CLI to check the code.`);
|
|
1080
|
+
}
|
|
1081
|
+
cmd.action(async () => {
|
|
1082
|
+
if (!tsc) throw missingPluginError("tsc");
|
|
1083
|
+
const isTsProject = (dir) => appPkg.hasFile("tsconfig.json", dir);
|
|
1084
|
+
if (!appPkg.isMonorepo()) {
|
|
1085
|
+
if (!isTsProject(appPkg.dirPath)) {
|
|
1086
|
+
logger.info("No tsconfig.json found, skipping typecheck");
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
await typecheckAt({
|
|
1090
|
+
shell,
|
|
1091
|
+
tsc,
|
|
1092
|
+
dir: appPkg.dirPath,
|
|
1093
|
+
scripts: appPkg.packageJson.scripts,
|
|
1094
|
+
log: logger
|
|
1095
|
+
});
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
const tsProjects = (await appPkg.getWorkspaceProjects()).filter((project) => isTsProject(project.rootDir));
|
|
1099
|
+
if (!tsProjects.length) {
|
|
1100
|
+
logger.warn("No ts projects found in the monorepo, skipping typecheck");
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
await Promise.all(tsProjects.map((p) => typecheckAt({
|
|
1104
|
+
shell,
|
|
1105
|
+
tsc,
|
|
1106
|
+
dir: p.rootDir,
|
|
1107
|
+
scripts: p.manifest.scripts,
|
|
1108
|
+
log: logger.child({
|
|
1109
|
+
tag: p.manifest.name,
|
|
1110
|
+
namespace: "typecheck"
|
|
1111
|
+
})
|
|
1112
|
+
})));
|
|
1113
|
+
});
|
|
1114
|
+
return cmd;
|
|
1115
|
+
}
|
|
1116
|
+
//#endregion
|
|
1117
|
+
//#region src/program/index.ts
|
|
1118
|
+
async function createProgram(options) {
|
|
1119
|
+
const ctx = await createContext(options.binDir);
|
|
1120
|
+
const version = ctx.binPkg.version;
|
|
1121
|
+
return {
|
|
1122
|
+
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() {
|
|
1123
|
+
generateToStdout(this);
|
|
1124
|
+
process.exit(0);
|
|
1125
|
+
}).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)),
|
|
1126
|
+
ctx
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
//#endregion
|
|
1130
|
+
//#region src/run.ts
|
|
1131
|
+
const BIN_DIR = path.dirname(dirnameOf(import.meta));
|
|
1132
|
+
await run(async () => {
|
|
1133
|
+
const { program } = await createProgram({ binDir: BIN_DIR });
|
|
1134
|
+
await program.parseAsync(process.argv, { from: "node" });
|
|
1135
|
+
}, logger);
|
|
1136
|
+
//#endregion
|
|
1137
|
+
export {};
|