@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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { cwd, type ShellService } from "@vlandoss/clibuddy";
|
|
2
|
+
import type { AnyLogger } from "@vlandoss/loggy";
|
|
3
|
+
import { createCommand } from "commander";
|
|
4
|
+
import type { Doctor, TypeChecker } from "#src/plugin/types.ts";
|
|
5
|
+
import type { Context } from "#src/services/ctx.ts";
|
|
6
|
+
import { logger } from "#src/services/logger.ts";
|
|
7
|
+
import { missingPluginError } from "../missing-plugin.ts";
|
|
8
|
+
import { pluginAnnotation } from "../ui.ts";
|
|
9
|
+
import { createDoctorSubcommand } from "./doctor.ts";
|
|
10
|
+
|
|
11
|
+
type TypecheckAtOptions = {
|
|
12
|
+
dir: string;
|
|
13
|
+
scripts: Record<string, string | undefined> | undefined;
|
|
14
|
+
log: AnyLogger;
|
|
15
|
+
shell: ShellService;
|
|
16
|
+
tsc: TypeChecker & Doctor;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const getPreScript = (scripts: Record<string, string | undefined> | undefined) => scripts?.pretsc ?? scripts?.pretypecheck;
|
|
20
|
+
|
|
21
|
+
async function typecheckAt({ dir, scripts, log, shell, tsc }: TypecheckAtOptions) {
|
|
22
|
+
log.debug(`checking types at ${dir}`);
|
|
23
|
+
|
|
24
|
+
const shellAt = cwd === dir ? shell : shell.at(dir);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const preScript = getPreScript(scripts);
|
|
28
|
+
if (preScript) {
|
|
29
|
+
log.start(`Running pre-script: ${preScript}`);
|
|
30
|
+
// Pre-scripts come from package.json and may contain shell features
|
|
31
|
+
// (`&&`, pipes, env-var substitution) — run them through `/bin/sh -c`.
|
|
32
|
+
await shellAt.run(preScript, [], { shell: true });
|
|
33
|
+
log.success("Pre-script completed");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
log.start("Type checking started");
|
|
37
|
+
if (cwd === dir) {
|
|
38
|
+
await tsc.check();
|
|
39
|
+
} else {
|
|
40
|
+
await tsc.check({ cwd: dir });
|
|
41
|
+
}
|
|
42
|
+
log.success("Typecheck completed");
|
|
43
|
+
} catch (error) {
|
|
44
|
+
log.error("Typecheck failed");
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createTsCheckCommand(ctx: Context) {
|
|
50
|
+
const { appPkg, shell } = ctx;
|
|
51
|
+
const tsc = ctx.registry.get("tsc");
|
|
52
|
+
|
|
53
|
+
const cmd = createCommand("tsc")
|
|
54
|
+
.alias("tscheck")
|
|
55
|
+
.summary(`check typescript errors${pluginAnnotation(tsc)}`)
|
|
56
|
+
.description(
|
|
57
|
+
"Checks the TypeScript code for type errors, ensuring that the code adheres to the defined type constraints and helps catch potential issues before runtime.",
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (tsc) {
|
|
61
|
+
cmd.addCommand(createDoctorSubcommand(tsc));
|
|
62
|
+
cmd.addHelpText("afterAll", `\nUnder the hood, this command uses the ${tsc.ui} CLI to check the code.`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
cmd.action(async () => {
|
|
66
|
+
if (!tsc) throw missingPluginError("tsc");
|
|
67
|
+
|
|
68
|
+
const isTsProject = (dir: string) => appPkg.hasFile("tsconfig.json", dir);
|
|
69
|
+
|
|
70
|
+
if (!appPkg.isMonorepo()) {
|
|
71
|
+
if (!isTsProject(appPkg.dirPath)) {
|
|
72
|
+
logger.info("No tsconfig.json found, skipping typecheck");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await typecheckAt({
|
|
77
|
+
shell,
|
|
78
|
+
tsc,
|
|
79
|
+
dir: appPkg.dirPath,
|
|
80
|
+
scripts: appPkg.packageJson.scripts,
|
|
81
|
+
log: logger,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const projects = await appPkg.getWorkspaceProjects();
|
|
88
|
+
const tsProjects = projects.filter((project) => isTsProject(project.rootDir));
|
|
89
|
+
|
|
90
|
+
if (!tsProjects.length) {
|
|
91
|
+
logger.warn("No ts projects found in the monorepo, skipping typecheck");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await Promise.all(
|
|
96
|
+
tsProjects.map((p) =>
|
|
97
|
+
typecheckAt({
|
|
98
|
+
shell,
|
|
99
|
+
tsc,
|
|
100
|
+
dir: p.rootDir,
|
|
101
|
+
scripts: p.manifest.scripts,
|
|
102
|
+
log: logger.child({
|
|
103
|
+
tag: p.manifest.name,
|
|
104
|
+
namespace: "typecheck",
|
|
105
|
+
}),
|
|
106
|
+
}),
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return cmd;
|
|
112
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Doctor, DoctorResult, Formatter, Linter, StaticChecker, StaticCheckerOptions } from "#src/plugin/types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Synthesises a `StaticChecker & Doctor` (the `jsc` capability) by composing
|
|
5
|
+
* a separately-registered linter and formatter. Used when the user's plugin
|
|
6
|
+
* set provides `lint` and `format` independently (e.g. oxc, or eslint +
|
|
7
|
+
* prettier) but no single plugin claims `jsc`.
|
|
8
|
+
*
|
|
9
|
+
* The check runs lint then format sequentially — interleaved stdout from a
|
|
10
|
+
* parallel run is hard to read for the user. `fixStaged` is dropped because
|
|
11
|
+
* the underlying tools don't have a uniform staged-aware mode.
|
|
12
|
+
*/
|
|
13
|
+
export function composedJscProvider(linter: Linter & Doctor, formatter: Formatter & Doctor): StaticChecker & Doctor {
|
|
14
|
+
return {
|
|
15
|
+
bin: `${linter.bin}+${formatter.bin}`,
|
|
16
|
+
ui: `${linter.ui} + ${formatter.ui}`,
|
|
17
|
+
async check({ fix }: StaticCheckerOptions) {
|
|
18
|
+
await linter.lint({ fix });
|
|
19
|
+
await formatter.format({ fix });
|
|
20
|
+
},
|
|
21
|
+
async doctor(): Promise<DoctorResult> {
|
|
22
|
+
const [lintRes, fmtRes] = await Promise.all([linter.doctor(), formatter.doctor()]);
|
|
23
|
+
const ok = lintRes.ok && fmtRes.ok;
|
|
24
|
+
const firstFailure = !lintRes.ok ? lintRes : !fmtRes.ok ? fmtRes : undefined;
|
|
25
|
+
return {
|
|
26
|
+
ok,
|
|
27
|
+
output: {
|
|
28
|
+
stdout: `${linter.ui}:\n${lintRes.output.stdout}\n\n${formatter.ui}:\n${fmtRes.output.stdout}`,
|
|
29
|
+
stderr: `${linter.ui}:\n${lintRes.output.stderr}\n\n${formatter.ui}:\n${fmtRes.output.stderr}`,
|
|
30
|
+
exitCode: firstFailure?.output.exitCode ?? 0,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { generateToStdout } from "@usage-spec/commander";
|
|
2
|
+
import { palette } from "@vlandoss/clibuddy";
|
|
3
|
+
import { type Command, createCommand, Option } from "commander";
|
|
4
|
+
import { createContext } from "#src/services/ctx.ts";
|
|
5
|
+
import { createCheckCommand } from "./commands/check.ts";
|
|
6
|
+
import { createCleanCommand } from "./commands/clean.ts";
|
|
7
|
+
import { createCompletionCommand } from "./commands/completion.ts";
|
|
8
|
+
import { createConfigCommand } from "./commands/config.ts";
|
|
9
|
+
import { createDoctorCommand } from "./commands/doctor.ts";
|
|
10
|
+
import { createFormatCommand } from "./commands/format.ts";
|
|
11
|
+
import { createJsCheckCommand } from "./commands/jscheck.ts";
|
|
12
|
+
import { createLintCommand } from "./commands/lint.ts";
|
|
13
|
+
import { createPackCommand } from "./commands/pack.ts";
|
|
14
|
+
import { createPluginsCommand } from "./commands/plugins.ts";
|
|
15
|
+
import { createTsCheckCommand } from "./commands/tscheck.ts";
|
|
16
|
+
import { CREDITS_TEXT, getBannerText } from "./ui.ts";
|
|
17
|
+
|
|
18
|
+
export type Options = {
|
|
19
|
+
binDir: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export async function createProgram(options: Options) {
|
|
23
|
+
const ctx = await createContext(options.binDir);
|
|
24
|
+
const version = ctx.binPkg.version;
|
|
25
|
+
|
|
26
|
+
const program = createCommand("rr")
|
|
27
|
+
.usage("<command...> [options...]")
|
|
28
|
+
.enablePositionalOptions()
|
|
29
|
+
.version(version, "-v, --version")
|
|
30
|
+
.addOption(new Option("--usage", `print KDL spec for this CLI (${palette.muted(palette.link("https://kdl.dev"))})`))
|
|
31
|
+
.on("option:usage", function onUsage(this: Command) {
|
|
32
|
+
generateToStdout(this);
|
|
33
|
+
process.exit(0);
|
|
34
|
+
})
|
|
35
|
+
.addHelpText("before", getBannerText(version))
|
|
36
|
+
.addHelpText("after", CREDITS_TEXT)
|
|
37
|
+
.addCommand(createCompletionCommand())
|
|
38
|
+
.addCommand(createPackCommand(ctx))
|
|
39
|
+
.addCommand(createJsCheckCommand(ctx))
|
|
40
|
+
.addCommand(createTsCheckCommand(ctx))
|
|
41
|
+
.addCommand(createLintCommand(ctx))
|
|
42
|
+
.addCommand(createFormatCommand(ctx))
|
|
43
|
+
.addCommand(createCheckCommand())
|
|
44
|
+
.addCommand(createDoctorCommand(ctx))
|
|
45
|
+
.addCommand(createPluginsCommand(ctx))
|
|
46
|
+
.addCommand(createCleanCommand())
|
|
47
|
+
.addCommand(createConfigCommand(ctx));
|
|
48
|
+
|
|
49
|
+
return { program, ctx };
|
|
50
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const SUGGESTIONS: Record<string, string[]> = {
|
|
2
|
+
lint: ["biome", "oxc", "eslint"],
|
|
3
|
+
format: ["biome", "oxc"],
|
|
4
|
+
jsc: ["biome"],
|
|
5
|
+
tsc: ["ts"],
|
|
6
|
+
pack: ["tsdown"],
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function missingPluginError(kind: string): Error {
|
|
10
|
+
const aliases = SUGGESTIONS[kind] ?? [];
|
|
11
|
+
const officialList = aliases.map((a) => `@rrlab/plugin-${a}`).join(", ");
|
|
12
|
+
const addList = aliases.map((a) => `rr plugins add ${a}`).join(" | ");
|
|
13
|
+
return new Error(
|
|
14
|
+
`No plugin provides the '${kind}' capability.` +
|
|
15
|
+
(officialList ? `\n Install one of: ${officialList}.` : "") +
|
|
16
|
+
(addList ? `\n Try: ${addList}.` : ""),
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { colorize, palette, text } from "@vlandoss/clibuddy";
|
|
2
|
+
|
|
3
|
+
export const CREDITS_TEXT = `\nAcknowledgment:
|
|
4
|
+
- kcd-scripts: for main inspiration
|
|
5
|
+
${palette.link("https://github.com/kentcdodds/kcd-scripts")}
|
|
6
|
+
|
|
7
|
+
- peruvian news: in honor to Run Run
|
|
8
|
+
${palette.link("https://es.wikipedia.org/wiki/Run_Run")}`;
|
|
9
|
+
|
|
10
|
+
const rimrafColor = colorize("#7C7270");
|
|
11
|
+
const runRunColor = colorize("#E8722A");
|
|
12
|
+
const usageColor = colorize("#24C55E");
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Labels used by kernel-internal commands. Plugin-owned tools (biome, oxc,
|
|
16
|
+
* tsdown, tsc) define their own colored labels inside each plugin's
|
|
17
|
+
* `src/index.ts`.
|
|
18
|
+
*/
|
|
19
|
+
export const TOOL_LABELS = {
|
|
20
|
+
RIMRAF: rimrafColor("rimraf"),
|
|
21
|
+
RUN_RUN: runRunColor("run-run"),
|
|
22
|
+
USAGE: usageColor("usage"),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const IS_USAGE_MODE = process.env.RR_USAGE_MODE === "1";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Renders the parenthesised backend hint that follows a command's summary,
|
|
29
|
+
* e.g. `pack a ts library 📦 (tsdown)` or `… (not configured)` when no plugin
|
|
30
|
+
* provides the capability.
|
|
31
|
+
*
|
|
32
|
+
* Returns an empty string when `RR_USAGE_MODE=1` is set (the kernel's `bin`
|
|
33
|
+
* script exports it during `rr --usage`) so the KDL spec stays free of
|
|
34
|
+
* per-environment state — the active plugin set is a property of the host
|
|
35
|
+
* project, not of the CLI surface.
|
|
36
|
+
*/
|
|
37
|
+
export function pluginAnnotation(provider: { ui: string } | undefined): string {
|
|
38
|
+
if (IS_USAGE_MODE) return "";
|
|
39
|
+
return provider ? ` (${provider.ui})` : " (not configured)";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// npx figlet -f "ANSI Shadow" "run-run"
|
|
43
|
+
export function getBannerText(version: string) {
|
|
44
|
+
const uiLogo = runRunColor(
|
|
45
|
+
`
|
|
46
|
+
██████╗ ██╗ ██╗███╗ ██╗ ██████╗ ██╗ ██╗███╗ ██╗
|
|
47
|
+
██╔══██╗██║ ██║████╗ ██║ ██╔══██╗██║ ██║████╗ ██║
|
|
48
|
+
██████╔╝██║ ██║██╔██╗ ██║█████╗██████╔╝██║ ██║██╔██╗ ██║
|
|
49
|
+
██╔══██╗██║ ██║██║╚██╗██║╚════╝██╔══██╗██║ ██║██║╚██╗██║
|
|
50
|
+
██║ ██║╚██████╔╝██║ ╚████║ ██║ ██║╚██████╔╝██║ ╚████║
|
|
51
|
+
╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ${text.version(version)}
|
|
52
|
+
`.trim(),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return `
|
|
56
|
+
${uiLogo}
|
|
57
|
+
|
|
58
|
+
🦊 ${palette.italic(palette.muted("The CLI toolbox for"))} ${text.vland}\n`.trimStart();
|
|
59
|
+
}
|
package/src/run.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { dirnameOf, run } from "@vlandoss/clibuddy";
|
|
3
|
+
import { createProgram } from "./program/index.ts";
|
|
4
|
+
import { logger } from "./services/logger.ts";
|
|
5
|
+
|
|
6
|
+
const BIN_DIR = path.dirname(dirnameOf(import.meta));
|
|
7
|
+
|
|
8
|
+
await run(async () => {
|
|
9
|
+
const { program } = await createProgram({ binDir: BIN_DIR });
|
|
10
|
+
await program.parseAsync(process.argv, { from: "node" });
|
|
11
|
+
}, logger);
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { builders, generateCode, loadFile, type ProxifiedModule, parseModule, writeFile } from "magicast";
|
|
4
|
+
|
|
5
|
+
const CONFIG_FILENAMES = ["run-run.config.ts", "run-run.config.mts"] as const;
|
|
6
|
+
|
|
7
|
+
export type PluginEntry = {
|
|
8
|
+
/** Local binding (e.g. `biome` for `import biome from "@rrlab/plugin-biome"`). */
|
|
9
|
+
exportName: string;
|
|
10
|
+
/** Full npm package name (e.g. `@rrlab/plugin-biome`). */
|
|
11
|
+
pkgName: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type LoadedConfig = {
|
|
15
|
+
mod: ProxifiedModule;
|
|
16
|
+
filepath: string;
|
|
17
|
+
/** True when we generated a fresh config in memory because none existed on disk. */
|
|
18
|
+
isNew: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* AST-level read/write for `run-run.config.{ts,mts}`. Wraps `magicast` so that
|
|
23
|
+
* adding/removing a plugin survives formatting, comments, and unrelated config
|
|
24
|
+
* options. The kernel and the `rr plugins add | remove` command use this
|
|
25
|
+
* service exclusively — no regex-based edits.
|
|
26
|
+
*/
|
|
27
|
+
export class ConfigAstService {
|
|
28
|
+
/**
|
|
29
|
+
* Looks for an existing `run-run.config.{ts,mts}` in `cwd`. If neither
|
|
30
|
+
* exists, returns a fresh magicast module built from a minimal template
|
|
31
|
+
* marked as `isNew: true`; the caller decides where to save it.
|
|
32
|
+
*/
|
|
33
|
+
async load(cwd: string): Promise<LoadedConfig> {
|
|
34
|
+
for (const name of CONFIG_FILENAMES) {
|
|
35
|
+
const candidate = path.join(cwd, name);
|
|
36
|
+
try {
|
|
37
|
+
await fs.access(candidate);
|
|
38
|
+
const mod = await loadFile(candidate);
|
|
39
|
+
return { mod, filepath: candidate, isNew: false };
|
|
40
|
+
} catch {
|
|
41
|
+
// try the next candidate
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const filepath = path.join(cwd, "run-run.config.mts");
|
|
45
|
+
return {
|
|
46
|
+
mod: parseModule(MINIMAL_TEMPLATE),
|
|
47
|
+
filepath,
|
|
48
|
+
isNew: true,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async save(loaded: LoadedConfig): Promise<void> {
|
|
53
|
+
if (loaded.isNew) {
|
|
54
|
+
// For a fresh file, write the generated code instead of using
|
|
55
|
+
// `writeFile` — writeFile expects an existing file as a starting point.
|
|
56
|
+
const { code } = generateCode(loaded.mod);
|
|
57
|
+
await fs.writeFile(loaded.filepath, code, "utf8");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
await writeFile(loaded.mod.$ast, loaded.filepath);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Returns true when the config's `plugins` array contains a call to the
|
|
65
|
+
* given `exportName` (e.g. `biome()`).
|
|
66
|
+
*/
|
|
67
|
+
hasPlugin(mod: ProxifiedModule, exportName: string): boolean {
|
|
68
|
+
const plugins = this.#pluginsArray(mod);
|
|
69
|
+
if (!plugins) return false;
|
|
70
|
+
for (let i = 0; i < plugins.length; i++) {
|
|
71
|
+
const item = plugins[i];
|
|
72
|
+
if (this.#isCallTo(item, exportName)) return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Adds the import binding and pushes a `exportName()` call onto `plugins`.
|
|
79
|
+
* No-op when the call is already present (idempotent).
|
|
80
|
+
*/
|
|
81
|
+
addPlugin(mod: ProxifiedModule, entry: PluginEntry): { changed: boolean } {
|
|
82
|
+
if (this.hasPlugin(mod, entry.exportName)) return { changed: false };
|
|
83
|
+
|
|
84
|
+
// Ensure the import binding exists.
|
|
85
|
+
if (!mod.imports[entry.exportName]) {
|
|
86
|
+
mod.imports.$add({ from: entry.pkgName, imported: "default", local: entry.exportName });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Ensure `defineConfig` import + default export shape.
|
|
90
|
+
this.#ensureDefineConfig(mod);
|
|
91
|
+
|
|
92
|
+
// Push `exportName()` onto plugins[].
|
|
93
|
+
const plugins = this.#ensurePluginsArray(mod);
|
|
94
|
+
plugins.push(builders.functionCall(entry.exportName));
|
|
95
|
+
|
|
96
|
+
return { changed: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Removes the `exportName()` call from `plugins[]` and, if it was the last
|
|
101
|
+
* use of that local binding, removes the import too.
|
|
102
|
+
*/
|
|
103
|
+
removePlugin(mod: ProxifiedModule, exportName: string): { changed: boolean } {
|
|
104
|
+
const plugins = this.#pluginsArray(mod);
|
|
105
|
+
if (!plugins) return { changed: false };
|
|
106
|
+
|
|
107
|
+
let changed = false;
|
|
108
|
+
// Iterate in reverse so splicing doesn't shift remaining indices we still
|
|
109
|
+
// need to inspect.
|
|
110
|
+
for (let i = plugins.length - 1; i >= 0; i--) {
|
|
111
|
+
if (this.#isCallTo(plugins[i], exportName)) {
|
|
112
|
+
plugins.splice(i, 1);
|
|
113
|
+
changed = true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (changed && mod.imports[exportName]) {
|
|
118
|
+
delete mod.imports[exportName];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { changed };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Returns the list of plugin exportNames currently in `plugins[]`. */
|
|
125
|
+
listPlugins(mod: ProxifiedModule): string[] {
|
|
126
|
+
const plugins = this.#pluginsArray(mod);
|
|
127
|
+
if (!plugins) return [];
|
|
128
|
+
const out: string[] = [];
|
|
129
|
+
for (let i = 0; i < plugins.length; i++) {
|
|
130
|
+
const item = plugins[i];
|
|
131
|
+
const name = this.#calleeName(item);
|
|
132
|
+
if (name) out.push(name);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Locates `defineConfig({ plugins: [...] })` and returns the plugins array
|
|
139
|
+
* proxy. Returns `undefined` if the config doesn't have that shape.
|
|
140
|
+
*/
|
|
141
|
+
#pluginsArray(mod: ProxifiedModule): unknown[] | undefined {
|
|
142
|
+
// biome-ignore lint/suspicious/noExplicitAny: magicast proxies are opaque
|
|
143
|
+
const def = (mod.exports as any).default;
|
|
144
|
+
if (!def || def.$type !== "function-call") return undefined;
|
|
145
|
+
// `def.$args` is a magicast ProxifiedArray (a Proxy, not a real Array) —
|
|
146
|
+
// `Array.isArray` returns false here, so guard on `.length` instead.
|
|
147
|
+
const args = def.$args;
|
|
148
|
+
if (!args || args.length === 0) return undefined;
|
|
149
|
+
const opts = args[0];
|
|
150
|
+
if (!opts || typeof opts !== "object") return undefined;
|
|
151
|
+
return opts.plugins;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#ensureDefineConfig(mod: ProxifiedModule): void {
|
|
155
|
+
if (!mod.imports.defineConfig) {
|
|
156
|
+
mod.imports.$add({ from: "@rrlab/cli/config", imported: "defineConfig", local: "defineConfig" });
|
|
157
|
+
}
|
|
158
|
+
// biome-ignore lint/suspicious/noExplicitAny: magicast proxies are opaque
|
|
159
|
+
const def = (mod.exports as any).default;
|
|
160
|
+
if (!def) {
|
|
161
|
+
// biome-ignore lint/suspicious/noExplicitAny: shape mutation via proxy
|
|
162
|
+
(mod.exports as any).default = builders.functionCall("defineConfig", { plugins: [] });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#ensurePluginsArray(mod: ProxifiedModule): unknown[] {
|
|
167
|
+
// biome-ignore lint/suspicious/noExplicitAny: shape mutation via proxy
|
|
168
|
+
const def = (mod.exports as any).default;
|
|
169
|
+
const args = def.$args;
|
|
170
|
+
if (args.length === 0) {
|
|
171
|
+
args.push({ plugins: [] });
|
|
172
|
+
}
|
|
173
|
+
const opts = args[0];
|
|
174
|
+
if (!opts.plugins) {
|
|
175
|
+
opts.plugins = [];
|
|
176
|
+
}
|
|
177
|
+
return opts.plugins;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#isCallTo(item: unknown, exportName: string): boolean {
|
|
181
|
+
return this.#calleeName(item) === exportName;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#calleeName(item: unknown): string | undefined {
|
|
185
|
+
if (!item || typeof item !== "object") return undefined;
|
|
186
|
+
// Magicast call expressions expose `$type === "function-call"` and a
|
|
187
|
+
// `$callee` with the local identifier name.
|
|
188
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque proxy
|
|
189
|
+
const proxy = item as any;
|
|
190
|
+
if (proxy.$type !== "function-call") return undefined;
|
|
191
|
+
const callee = proxy.$callee;
|
|
192
|
+
if (typeof callee === "string") return callee;
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const MINIMAL_TEMPLATE = `import { defineConfig } from "@rrlab/cli/config";
|
|
198
|
+
|
|
199
|
+
export default defineConfig({
|
|
200
|
+
plugins: [],
|
|
201
|
+
});
|
|
202
|
+
`;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { type AsyncSearcher, lilconfig } from "lilconfig";
|
|
3
|
+
import type { ExportedConfig, UserConfig } from "#src/types/config.ts";
|
|
4
|
+
import { logger } from "./logger.ts";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_CONFIG: UserConfig = {
|
|
7
|
+
plugins: [],
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export class ConfigService {
|
|
11
|
+
#searcher: AsyncSearcher;
|
|
12
|
+
|
|
13
|
+
constructor() {
|
|
14
|
+
this.#searcher = lilconfig("run-run", {
|
|
15
|
+
searchPlaces: ["run-run.config.ts", "run-run.config.mts"],
|
|
16
|
+
cache: true,
|
|
17
|
+
stopDir: os.homedir(),
|
|
18
|
+
loaders: {
|
|
19
|
+
".ts": (filepath: string) => import(filepath).then((mod) => mod.default),
|
|
20
|
+
".mts": (filepath: string) => import(filepath).then((mod) => mod.default),
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async load(): Promise<ExportedConfig> {
|
|
26
|
+
const debug = logger.subdebug("load-config");
|
|
27
|
+
|
|
28
|
+
const searchResult = await this.#searcher.search();
|
|
29
|
+
|
|
30
|
+
if (!searchResult || searchResult?.isEmpty) {
|
|
31
|
+
debug("loaded default config: %O", DEFAULT_CONFIG);
|
|
32
|
+
return {
|
|
33
|
+
config: DEFAULT_CONFIG,
|
|
34
|
+
meta: {
|
|
35
|
+
isDefault: true,
|
|
36
|
+
filepath: undefined,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const config = searchResult.config as UserConfig;
|
|
42
|
+
|
|
43
|
+
debug("loaded config: %O", config);
|
|
44
|
+
debug("config file: %s", searchResult.filepath);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
config,
|
|
48
|
+
meta: {
|
|
49
|
+
isDefault: false,
|
|
50
|
+
filepath: searchResult.filepath,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createPkg, createShellService, cwd, type Pkg, type ShellService } from "@vlandoss/clibuddy";
|
|
3
|
+
import { PluginRegistry } from "#src/plugin/registry.ts";
|
|
4
|
+
import type { PluginContext } from "#src/plugin/types.ts";
|
|
5
|
+
import type { ExportedConfig } from "#src/types/config.ts";
|
|
6
|
+
import { ConfigService } from "./config.ts";
|
|
7
|
+
import { logger } from "./logger.ts";
|
|
8
|
+
|
|
9
|
+
export type Context = {
|
|
10
|
+
binPkg: Pkg;
|
|
11
|
+
appPkg: Pkg;
|
|
12
|
+
shell: ShellService;
|
|
13
|
+
config: ExportedConfig;
|
|
14
|
+
registry: PluginRegistry;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export async function createContext(binDir: string): Promise<Context> {
|
|
18
|
+
const debug = logger.subdebug("create-context");
|
|
19
|
+
|
|
20
|
+
const binPath = fs.realpathSync(binDir);
|
|
21
|
+
|
|
22
|
+
debug("bin path:", binPath);
|
|
23
|
+
debug("process cwd:", process.cwd());
|
|
24
|
+
|
|
25
|
+
const [appPkg, binPkg] = await Promise.all([createPkg(cwd), createPkg(binPath)]);
|
|
26
|
+
|
|
27
|
+
if (!binPkg) {
|
|
28
|
+
throw new Error("Could not find bin package.json");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!appPkg) {
|
|
32
|
+
throw new Error("Could not find app package.json");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
debug("app pkg info: %O", appPkg.info());
|
|
36
|
+
debug("bin pkg info: %O", binPkg.info());
|
|
37
|
+
|
|
38
|
+
const shell = createShellService();
|
|
39
|
+
|
|
40
|
+
debug("shell service options: %O", shell.options);
|
|
41
|
+
|
|
42
|
+
const configService = new ConfigService();
|
|
43
|
+
const config = await configService.load();
|
|
44
|
+
|
|
45
|
+
const registry = new PluginRegistry();
|
|
46
|
+
const pluginContext: PluginContext = {
|
|
47
|
+
shell,
|
|
48
|
+
logger,
|
|
49
|
+
appPkg,
|
|
50
|
+
binPkg,
|
|
51
|
+
cwd,
|
|
52
|
+
};
|
|
53
|
+
|
|
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
|
+
);
|
|
59
|
+
}
|
|
60
|
+
debug("registering plugin: %s", plugin.name);
|
|
61
|
+
const capabilities = await plugin.setup(pluginContext);
|
|
62
|
+
registry.register(plugin, capabilities);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
appPkg,
|
|
67
|
+
binPkg,
|
|
68
|
+
shell,
|
|
69
|
+
config,
|
|
70
|
+
registry,
|
|
71
|
+
};
|
|
72
|
+
}
|