Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/biome-plugin-scaffold-format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rrlab/biome-plugin": patch
---

`rr plugins add biome` scaffolds a `biome.json` that passes `rr jsc`: in Biome's own format, with `$schema` pointing at the installed Biome's schema (`./node_modules/@biomejs/biome/configuration_schema.json`). It used to fail the format check, and its pinned 2.4.4 schema URL drew a version-mismatch note against the `^2.0.0` it installs.
5 changes: 5 additions & 0 deletions .changeset/cli-config-import-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rrlab/cli": patch
---

`rr plugins add` writes `run-run.config.mts` with its imports together, in Biome's order, and with a final newline, so `rr jsc` and oxfmt pass on it. Each new import used to go first, so they ended up in reverse order of adding, and the file had no final newline.
5 changes: 5 additions & 0 deletions .changeset/cli-plugins-no-tty.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rrlab/cli": patch
---

`rr plugins add` without a terminal (an agent, a script, CI) takes the defaults, as with `--yes`, instead of prompting: it used to wait on the prompt, or exit 13 with the plugin half installed. `rr plugins remove` without a terminal and without `--yes` stops with an error that asks for `--yes`, since it deletes files and dependencies. Both count as a terminal only when stdin and stdout are TTYs.
5 changes: 5 additions & 0 deletions .changeset/oxc-plugin-cli-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rrlab/oxc-plugin": patch
---

`rr lint`, `rr jsc` and `rr check` run `oxlint` without `--check`, a flag it doesn't have, so they failed wherever oxc provides `lint`. `rr format --fix` runs `oxfmt --write`, since `oxfmt` has no `--fix` either.
5 changes: 5 additions & 0 deletions .changeset/ts-plugin-react-preset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rrlab/ts-plugin": patch
---

`rr plugins add ts` defaults to the React preset when the package depends on `react` (in `dependencies`, `devDependencies` or `peerDependencies`), and to the Node.js app preset otherwise. That's what `--yes` and a run without a terminal pick, and where the prompt starts. Before, they always picked the Node.js preset, which can't type-check a React app.
6 changes: 4 additions & 2 deletions run-run/biome-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ const FROM = import.meta.url;
const COMMON_FLAGS = ["--colors=force", "--no-errors-on-unmatched"];
const BIOME_JSON = "biome.json";
const BIOME_CONFIG_PKG = "@rrlab/biome-config";
const BIOME_SCHEMA = "https://biomejs.dev/schemas/2.4.4/schema.json";
// The installed Biome's own schema: a versioned URL drifts from `^2.0.0` and Biome flags the mismatch.
const BIOME_SCHEMA = "./node_modules/@biomejs/biome/configuration_schema.json";

export { TOOL_VERSIONS } from "./tool-versions.ts";

Expand Down Expand Up @@ -81,7 +82,8 @@ export async function install(ctx: InstallContext): Promise<InstallResult> {
? {
kind: "create",
path: BIOME_JSON,
content: `${JSON.stringify({ $schema: BIOME_SCHEMA, extends: [BIOME_CONFIG_PKG] }, null, 2)}\n`,
// In Biome's own format, which keeps a short array on one line, so `rr jsc` passes on it.
content: `{\n "$schema": "${BIOME_SCHEMA}",\n "extends": ["${BIOME_CONFIG_PKG}"]\n}\n`,
overwrite: scaffoldDecision === "overwrite" || ctx.flags.force,
}
: {
Expand Down
4 changes: 2 additions & 2 deletions run-run/cli/src/actions/plugins/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { ConfigAstService } from "#src/services/config-ast.ts";
import type { ContextValue } from "#src/services/context.ts";
import { applyFileOp } from "#src/services/file-ops.ts";
import { logger } from "#src/services/logger.ts";
import { createClackPrompts } from "#src/services/prompts.ts";
import { canPrompt, createClackPrompts } from "#src/services/prompts.ts";
import { ReleaseService } from "#src/services/release.ts";
import { describeWorkspaceChoice, resolveWorkspaceChoice, toNypmWorkspace } from "#src/services/workspace-target.ts";
import { type AddOptions, hasInPackageJson, type PluginModule, reportFileOp } from "./shared.ts";
Expand Down Expand Up @@ -104,7 +104,7 @@ export async function addPluginAction({ ctx, args, options }: AddPluginActionCon
flags: {
force: !!options.force,
yes: !!options.yes,
nonInteractive: !!options.yes,
nonInteractive: !!options.yes || !canPrompt(),
},
release: new ReleaseService(tag),
};
Expand Down
7 changes: 5 additions & 2 deletions run-run/cli/src/actions/plugins/remove.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import path from "node:path";
import * as clack from "@clack/prompts";
import { detectPackageManager, removeDependency } from "nypm";
import { ConfirmationRequiredError } from "#src/errors/confirmation-required.ts";
import { PLUGINS_DIRECTORY, type PluginName } from "#src/lib/plugin/directory.ts";
import type { UninstallResult } from "#src/lib/plugin/types.ts";
import { withSpinner } from "#src/render/steps.ts";
import { ConfigAstService } from "#src/services/config-ast.ts";
import type { ContextValue } from "#src/services/context.ts";
import { applyFileOp, describeFileOp } from "#src/services/file-ops.ts";
import { logger } from "#src/services/logger.ts";
import { createClackPrompts } from "#src/services/prompts.ts";
import { canPrompt, createClackPrompts } from "#src/services/prompts.ts";
import { describeWorkspaceChoice, resolveWorkspaceChoice, toNypmWorkspace } from "#src/services/workspace-target.ts";
import { hasInPackageJson, type PluginModule, type RemoveOptions, reportFileOp } from "./shared.ts";

Expand Down Expand Up @@ -41,7 +42,7 @@ export async function removePluginAction({ ctx, args, options }: RemovePluginAct
logger,
appPkg: ctx.appPkg,
prompts: createClackPrompts(),
flags: { yes: !!options.yes, nonInteractive: !!options.yes },
flags: { yes: !!options.yes, nonInteractive: !!options.yes || !canPrompt() },
});
}
} catch (err) {
Expand Down Expand Up @@ -83,6 +84,8 @@ export async function removePluginAction({ ctx, args, options }: RemovePluginAct
}

if (!options.yes) {
// Removing deletes files and dependencies: without a terminal to confirm in, insist on --yes.
if (!canPrompt()) throw new ConfirmationRequiredError(`rr plugins remove ${name}`);
const choice = await clack.confirm({ message: "Proceed?", initialValue: false });
if (clack.isCancel(choice) || choice !== true) {
clack.outro("Aborted.");
Expand Down
7 changes: 7 additions & 0 deletions run-run/cli/src/errors/confirmation-required.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { KnownError } from "./known-error.ts";

export class ConfirmationRequiredError extends KnownError {
constructor(command: string) {
super(`'${command}' asks for confirmation, and there's no terminal to ask in. Re-run it with --yes.`);
}
}
70 changes: 70 additions & 0 deletions run-run/cli/src/services/__tests__/config-ast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { ConfigAstService } from "#src/services/config-ast.ts";

const svc = new ConfigAstService();

function importSources(code: string): string[] {
return [...code.matchAll(/^import .* from "(.+)";$/gm)].map((match) => match[1] ?? "");
}

const EMPTY_CONFIG = `import { defineConfig } from "@rrlab/cli/config";

export default defineConfig({
Expand Down Expand Up @@ -76,6 +80,34 @@ describe("ConfigAstService", () => {
});
});

describe("addPlugin import order", () => {
it("puts each import where Biome's organizeImports wants it, in whatever order the plugins come", () => {
const mod = parseModule(EMPTY_CONFIG);
svc.addPlugin(mod, { exportName: "tsdown", pkgName: "@rrlab/tsdown-plugin" });
svc.addPlugin(mod, { exportName: "ts", pkgName: "@rrlab/ts-plugin" });
svc.addPlugin(mod, { exportName: "biome", pkgName: "@rrlab/biome-plugin" });
expect(importSources(generateCode(mod).code)).toEqual([
"@rrlab/biome-plugin",
"@rrlab/cli/config",
"@rrlab/ts-plugin",
"@rrlab/tsdown-plugin",
]);
});

it("leaves the other imports in their order, and goes after a protocol import and before a path", () => {
const mod = parseModule(`import fs from "node:fs";
import { defineConfig } from "@rrlab/cli/config";
import local from "./local.ts";

export default defineConfig({
plugins: [],
});
`);
svc.addPlugin(mod, { exportName: "ts", pkgName: "@rrlab/ts-plugin" });
expect(importSources(generateCode(mod).code)).toEqual(["node:fs", "@rrlab/cli/config", "@rrlab/ts-plugin", "./local.ts"]);
});
});

describe("removePlugin", () => {
it("removes the call and the import binding", () => {
const mod = parseModule(TWO_PLUGINS_CONFIG);
Expand Down Expand Up @@ -129,6 +161,44 @@ describe("ConfigAstService", () => {
expect(written).toMatch(/plugins:\s*\[\s*biome\(\),\s*ts\(\)\s*\]/);
});

it("keeps the imports together and sorted across separate `rr plugins add` runs", async () => {
const cfgPath = path.join(tmpDir, "run-run.config.mts");
await fs.writeFile(cfgPath, EMPTY_CONFIG, "utf8");
for (const [exportName, pkgName] of [
["tsdown", "@rrlab/tsdown-plugin"],
["ts", "@rrlab/ts-plugin"],
["biome", "@rrlab/biome-plugin"],
] as const) {
const loaded = await svc.load(tmpDir);
svc.addPlugin(loaded.mod, { exportName, pkgName });
await svc.save(loaded);
}

expect(await fs.readFile(cfgPath, "utf8")).toBe(`import biome from "@rrlab/biome-plugin";
import { defineConfig } from "@rrlab/cli/config";
import ts from "@rrlab/ts-plugin";
import tsdown from "@rrlab/tsdown-plugin";

export default defineConfig({
plugins: [tsdown(), ts(), biome()],
});
`);
});

it.each([
["an existing config", ONE_PLUGIN_CONFIG],
["a new config", undefined],
])("ends %s with a newline", async (_label, existing) => {
const cfgPath = path.join(tmpDir, "run-run.config.mts");
if (existing) await fs.writeFile(cfgPath, existing, "utf8");

const loaded = await svc.load(tmpDir);
svc.addPlugin(loaded.mod, { exportName: "ts", pkgName: "@rrlab/ts-plugin" });
await svc.save(loaded);

expect(await fs.readFile(cfgPath, "utf8")).toMatch(/\}\);\n$/);
});

it("writes a brand-new minimal config when none existed and a plugin was added", async () => {
const loaded = await svc.load(tmpDir);
svc.addPlugin(loaded.mod, { exportName: "biome", pkgName: "@rrlab/biome-plugin" });
Expand Down
59 changes: 50 additions & 9 deletions run-run/cli/src/services/config-ast.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { builders, generateCode, loadFile, type ProxifiedModule, parseModule, writeFile } from "magicast";
import { builders, generateCode, loadFile, type ProxifiedModule, parseModule } from "magicast";

const CONFIG_FILENAMES = ["run-run.config.ts", "run-run.config.mts"] as const;

Expand Down Expand Up @@ -50,14 +50,7 @@ export class ConfigAstService {
}

async save(loaded: LoadedConfig): Promise<void> {
if (loaded.isNew) {
// For a fresh file, write the generated code instead of using
// `writeFile` — writeFile expects an existing file as a starting point.
const { code } = generateCode(loaded.mod);
await fs.writeFile(loaded.filepath, code, "utf8");
return;
}
await writeFile(loaded.mod.$ast, loaded.filepath);
await fs.writeFile(loaded.filepath, tidy(generateCode(loaded.mod).code), "utf8");
}

/**
Expand All @@ -84,6 +77,7 @@ export class ConfigAstService {
// Ensure the import binding exists.
if (!mod.imports[entry.exportName]) {
mod.imports.$add({ from: entry.pkgName, imported: "default", local: entry.exportName });
this.#placeImport(mod, entry.pkgName);
}

// Ensure `defineConfig` import + default export shape.
Expand Down Expand Up @@ -151,9 +145,30 @@ export class ConfigAstService {
return opts.plugins;
}

/**
* Moves the import of `source`, which `imports.$add` puts first, to its
* sorted place among the other imports, leaving those in their order: a
* sorted file stays sorted, so `rr jsc` passes on what rr wrote.
*/
#placeImport(mod: ProxifiedModule, source: string): void {
const program = mod.$ast;
if (program.type !== "Program") return;
const { body } = program;
const from = body.findIndex((node) => node.type === "ImportDeclaration" && node.source.value === source);
if (from === -1) return;
const [added] = body.splice(from, 1);
if (!added) return;
const before = body.findIndex(
(node) => node.type === "ImportDeclaration" && compareImportSources(node.source.value, source) > 0,
);
const lastImport = body.reduce((last, node, i) => (node.type === "ImportDeclaration" ? i : last), -1);
body.splice(before === -1 ? lastImport + 1 : before, 0, added);
}

#ensureDefineConfig(mod: ProxifiedModule): void {
if (!mod.imports.defineConfig) {
mod.imports.$add({ from: "@rrlab/cli/config", imported: "defineConfig", local: "defineConfig" });
this.#placeImport(mod, "@rrlab/cli/config");
}
// biome-ignore lint/suspicious/noExplicitAny: magicast proxies are opaque
const def = (mod.exports as any).default;
Expand Down Expand Up @@ -194,6 +209,32 @@ export class ConfigAstService {
}
}

/**
* Undoes two recast habits that formatters reject: it keeps the blank line that
* followed an import when rr inserts another after it, and it drops the final
* newline.
*/
function tidy(code: string): string {
const joined = code.replace(/^(import\b[^;]*;)\n(?:[ \t]*\n)+(?=import\b)/gm, "$1\n");
return joined.endsWith("\n") ? joined : `${joined}\n`;
}

/**
* Biome's organizeImports order: protocol imports (`node:fs`), then packages,
* then aliases (`#src/…`, `@/…`, `~/…`), then paths. Within a group, plain
* code-unit order, which matches Biome for these names (`ts-plugin` before
* `tsdown-plugin`; `localeCompare` flips them).
*/
function compareImportSources(a: string, b: string): number {
const group = (source: string) => {
if (/^[a-z]+:/.test(source)) return 0;
if (/^(#|@\/|~)/.test(source)) return 2;
if (source.startsWith(".") || source.startsWith("/")) return 3;
return 1;
};
return group(a) - group(b) || (a < b ? -1 : a > b ? 1 : 0);
}

const MINIMAL_TEMPLATE = `import { defineConfig } from "@rrlab/cli/config";

export default defineConfig({
Expand Down
9 changes: 9 additions & 0 deletions run-run/cli/src/services/prompts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import * as clack from "@clack/prompts";
import type { ClackPrompts } from "#src/lib/plugin/types.ts";

/**
* Whether a prompt can run: clack reads keys from stdin and draws on stdout,
* so both have to be terminals. An agent, a script or CI usually has neither,
* and a prompt there waits forever or exits on a closed stdin.
*/
export function canPrompt(): boolean {
return !!process.stdin.isTTY && !!process.stdout.isTTY;
}

/**
* Adapter that exposes the subset of `@clack/prompts` matching the
* `ClackPrompts` contract from `@rrlab/cli/plugin`. The contract intentionally
Expand Down
2 changes: 2 additions & 0 deletions run-run/cli/test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const WORKSPACE_NODE_MODULES = resolve(dirnameOf(import.meta), "../../../node_mo

type CliOptions = {
cwd?: string;
env?: NodeJS.ProcessEnv;
};

export function createTestCli() {
Expand All @@ -23,6 +24,7 @@ export function createTestCli() {
NODE_ENV: "production",
TEST: undefined,
NO_COLOR: "1",
...opts.env,
},
});
};
Expand Down
28 changes: 28 additions & 0 deletions run-run/cli/test/integration/format.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createTestCli, fixtures, makeFixture } from "#test/helpers.ts";

Expand Down Expand Up @@ -31,3 +33,29 @@ describe("rr format", () => {
expect(r.status).toBe(0);
});
});

describe("rr format (oxc)", () => {
let fixture: { dir: string; cleanup: () => void };

beforeEach(() => {
fixture = makeFixture("format-oxc", {
"package.json": fixtures.pkg(),
"run-run.config.mts": fixtures.config(["oxc"]),
"src/ugly.ts": "export const ugly=1\n",
});
});

afterEach(() => fixture.cleanup());

test("checks with oxfmt and fails on an unformatted file", () => {
const r = cli("format", { cwd: fixture.dir });
expect(r.stdout + r.stderr).toContain("src/ugly.ts");
expect(r.status).not.toBe(0);
});

test("--fix writes the file formatted", () => {
const r = cli("format --fix", { cwd: fixture.dir });
expect(r.status).toBe(0);
expect(readFileSync(path.join(fixture.dir, "src/ugly.ts"), "utf8")).toBe("export const ugly = 1;\n");
});
});
25 changes: 25 additions & 0 deletions run-run/cli/test/integration/lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,28 @@ describe("rr lint", () => {
expect(r.status).toBe(0);
});
});

describe("rr lint (oxc)", () => {
let fixture: { dir: string; cleanup: () => void };

beforeEach(() => {
fixture = makeFixture("lint-oxc", {
// oxlint skips a real node_modules but walks the fixture's symlinked one, unless it's ignored.
".gitignore": "node_modules\n",
"package.json": fixtures.pkg(),
"run-run.config.mts": fixtures.config(["oxc"]),
"src/debug.ts": "export function f() {\n debugger;\n}\n",
});
});

afterEach(() => fixture.cleanup());

// oxlint has no `--check` flag: it checks by default, and a warning doesn't fail it.
test("runs oxlint in its default check mode", () => {
const r = cli("lint", { cwd: fixture.dir });
const combined = r.stdout + r.stderr;
expect(combined).toContain("$ oxlint --report-unused-disable-directives\n");
expect(combined).toContain("no-debugger");
expect(r.status).toBe(0);
});
});
Loading
Loading