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
29 changes: 29 additions & 0 deletions .changeset/clibuddy-tinyexec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@vlandoss/clibuddy": minor
---

**Breaking:** Replace `zx` with `tinyexec` and redesign `ShellService` around array-based exec.

The previous tagged-template API (`shell.$\`...\``) and its surrounding helpers (`quote`, `isRaw`, `defaultQuote`, `getPreferLocal`, `localBaseBinPath`, `mute()`, `quiet()`, `isProcessOutput`, `./test-helpers` export) are gone. They duplicated zx internals, introduced a quoting bug for whitespace strings, and surfaced inconsistent `node_modules/.bin` resolution.

New surface:

- `shell.run(cmd, args, opts?)` — streams stdio to the terminal and prints `$ <cmd> <args>` in verbose mode. Throws `NonZeroExitError` on non-zero exit by default.
- `shell.runCaptured(cmd, args, opts?)` — silent, returns the captured `Output { stdout, stderr, exitCode }`. Same throw-by-default semantics.
- `shell.at(cwd)` / `shell.child(opts)` — child shells with merged options.
- `RunOptions`: `cwd`, `env`, `verbose`, `throwOnError`, `shell` (pass-through `shell: true` for `&&`/pipes), `stdin`, `display` (override the verbose-printed name without affecting what's spawned).
- `resolvePackageBin(pkg, { from, binName? })` — async resolver that returns the absolute path to an installed package's binary, tolerating restrictive `exports` maps (oxlint) and packages without `main`/`exports` at all (`@biomejs/biome`). Memoised per `(pkg, from, binName)`.
- `isNonZeroExitError(value)` — replaces `isProcessOutput`.

`tinyexec` automatically prepends every parent `node_modules/.bin` to `PATH`, so `localBaseBinPath` / `getPreferLocal` are no longer needed.

New dependencies: `tinyexec` (replaces `zx`), `memoize` (for `resolvePackageBin`).

**Migration**

- `await shell.$\`git init\`` → `await shell.run("git", ["init"])`
- `await shell.$\`git config\`.nothrow()` → `await shell.runCaptured("git", ["config", ...], { throwOnError: false })`
- `shell.mute()` → call `runCaptured` instead (silent by default).
- `createShellService({ localBaseBinPath: [dir] })` → drop the option; tinyexec walks up automatically.
- `isProcessOutput(err)` → `isNonZeroExitError(err)`.
- Tools wrapping a npm package (e.g. biome, tsdown) should resolve the bin path via `resolvePackageBin` and pass it as the `cmd` with `display: "<friendly-name>"` to avoid `node_modules/.bin/<name>` shim loops.
14 changes: 14 additions & 0 deletions .changeset/run-run-shell-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@vlandoss/run-run": patch
---

Internal migration to the new tinyexec-backed `ShellService` (see `@vlandoss/clibuddy`).

- `ToolService.exec` now accepts only `string[]` (the `string` overload that silently word-split on spaces is gone). All tool services (`biome`, `oxlint`, `oxfmt`, `tsdown`, `tsc`) build their flags as arrays so each flag survives as its own argv entry.
- Bin resolution moves into the base `ToolService`: subclasses declare `{ pkg, bin?, ui }` in the constructor and the base resolves the absolute path via `resolvePackageBin` (memoised). The verbose `$ <bin> <args>` line is preserved via the `display` option so users still see `$ biome check ...` instead of an absolute resolved path. Resolving to the absolute path bypasses the `node_modules/.bin/<bin>` shims that run-run itself publishes (`tools/biome` etc.), which would otherwise loop back through `rr tools <bin>` indefinitely.
- `tscheck` runs `pretsc` / `pretypecheck` package scripts through `shell: true` so they can use `&&`, pipes, and env-var substitution.
- Bump `tsdown` from `0.21.10` to `0.22.0`. `tsdown@0.21.x` depends on `unrun@^0.2.37`, which pnpm resolved to `0.2.38` — whose published tarball is missing `dist/`, producing `WARN Failed to create bin … unrun` on every install. `tsdown@0.22.0` dropped `unrun` from `dependencies` (now an optional peer), erradicating the warning.

Tests reorganised into one e2e file per command (`cli`, `jsc`, `lint`, `format`, `tsc`, `build-lib`). Each spawns the real `rr` binary against a temp fixture (`makeFixture` helper) and asserts on observable output, so we no longer rely on a `clibuddy/test-helpers` mock.

End-user CLI behaviour is unchanged.
7 changes: 7 additions & 0 deletions .changeset/vland-init-prompts-and-git-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@vlandoss/vland": minor
---

`vland init` now prompts (default-yes) for installing dependencies and initialising a git repository when those flags aren't passed on the CLI. Use `--install` / `--no-install` and `--git` / `--no-git` to skip the prompts; in non-interactive contexts both default to `true`.

Also fixes the git initialisation step: the commit message was being split on whitespace by the underlying shell layer, producing errors like `pathspec 'initial' did not match any file(s)` and leaving the repo half-initialised. The migration to `tinyexec` (via `@vlandoss/clibuddy`) makes each argv entry survive as a separate token, so the canonical first commit `chore: initial commit from vland` now lands cleanly.
8 changes: 4 additions & 4 deletions packages/clibuddy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@
"author": "rcrd <rcrd@variable.land>",
"type": "module",
"exports": {
".": "./src/index.ts",
"./test-helpers": "./test-helpers/index.ts"
".": "./src/index.ts"
},
"files": [
"dist",
Expand All @@ -33,10 +32,11 @@
"@pnpm/fs.find-packages": "1000.0.24",
"@pnpm/types": "1001.3.0",
"ansis": "4.2.0",
"memoize": "10.2.0",
"pkg-types": "2.3.0",
"std-env": "3.9.0",
"yaml": "2.8.4",
"zx": "8.8.5"
"tinyexec": "1.1.2",
"yaml": "2.8.4"
},
"publishConfig": {
"access": "public",
Expand Down
5 changes: 3 additions & 2 deletions packages/clibuddy/src/run.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isProcessOutput } from "./shell/utils.ts";
import { isNonZeroExitError } from "./shell/utils.ts";

function hasMessage(error: unknown): error is { message: string } {
return (
Expand All @@ -18,7 +18,8 @@ export async function run(fn: () => Promise<void>, logger: { error: (...args: un
try {
await fn();
} catch (error) {
if (!isProcessOutput(error)) {
// The subprocess already streamed its own stderr; don't double-print.
if (!isNonZeroExitError(error)) {
logger.error(formatError(error));
}
process.exit(1);
Expand Down
47 changes: 3 additions & 44 deletions packages/clibuddy/src/shell/create.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,9 @@
import fs from "node:fs";
import { ShellService } from "./shell.ts";
import type { CreateOptions } from "./types.ts";
import { getPreferLocal } from "./utils.ts";
import type { ShellOptions } from "./types.ts";

export const cwd = fs.realpathSync(process.cwd());

// Inspired by https://dub.sh/6tiHVgn
export function quote(arg: string) {
if (/^[\w./:=@-]+$/i.test(arg) || arg === "") {
return arg;
}

return arg
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/\f/g, "\\f")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t")
.replace(/\v/g, "\\v")
.replace(/\0/g, "\\0");
}

export const isRaw = (arg: unknown): arg is { stdout: string } =>
typeof arg === "object" && arg !== null && "stdout" in arg && typeof arg.stdout === "string";

function defaultQuote(arg: unknown) {
if (typeof arg === "string") {
return quote(arg);
}

if (isRaw(arg)) {
return arg.stdout;
}

throw TypeError(`Unsupported argument type: ${typeof arg}`);
}

export function createShellService(options: CreateOptions = {}) {
const preferLocal = getPreferLocal(options.localBaseBinPath);

return new ShellService({
verbose: true,
cwd,
preferLocal,
quote: defaultQuote,
...options,
});
export function createShellService(options: ShellOptions = {}): ShellService {
return new ShellService({ cwd, ...options });
}
1 change: 1 addition & 0 deletions packages/clibuddy/src/shell/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./create.ts";
export * from "./resolve-package-bin.ts";
export * from "./shell.ts";
export * from "./types.ts";
export * from "./utils.ts";
26 changes: 26 additions & 0 deletions packages/clibuddy/src/shell/resolve-package-bin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { createRequire } from "node:module";
import path from "node:path";
import memoize from "memoize";
import { readPackageJSON, resolvePackageJSON } from "pkg-types";

type Options = { from: string; binName?: string };

// pkg-types covers any package whose `.` entry is in `exports` (including
// restrictive ones like oxlint). The fallback handles packages without
// `main`/`exports` at all (e.g. @biomejs/biome).
async function _resolvePackageBin(pkg: string, { from, binName }: Options): Promise<string> {
let pkgJsonPath: string;
try {
pkgJsonPath = await resolvePackageJSON(pkg, { from });
} catch {
pkgJsonPath = createRequire(from).resolve(`${pkg}/package.json`);
}
const { bin } = await readPackageJSON(pkgJsonPath);
const rel = typeof bin === "string" ? bin : bin?.[binName ?? pkg.replace(/^@[^/]+\//, "")];
if (!rel) throw new Error(`No bin "${binName ?? pkg}" in ${pkg}`);
return path.join(path.dirname(pkgJsonPath), rel);
}

export const resolvePackageBin = memoize(_resolvePackageBin, {
cacheKey: ([pkg, opts]) => `${pkg}|${opts.from}|${opts.binName ?? ""}`,
});
91 changes: 51 additions & 40 deletions packages/clibuddy/src/shell/shell.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,76 @@
import { $ as make$ } from "zx";
import type { Shell, ShellOptions } from "./types.ts";
import { getPreferLocal } from "./utils.ts";
import { type Output, x as tinyexec } from "tinyexec";
import { palette } from "../colors.ts";
import type { RunOptions, ShellOptions } from "./types.ts";

export class ShellService {
#shell: Shell;
#options: ShellOptions;

constructor(options: ShellOptions) {
this.#options = Object.freeze(options);
this.#shell = make$(options);
constructor(options: ShellOptions = {}) {
this.#options = Object.freeze({
cwd: options.cwd ?? process.cwd(),
env: options.env,
verbose: options.verbose ?? true,
});
}

get options() {
get options(): ShellOptions {
return this.#options;
}

get $() {
return this.#shell;
at(cwd: string): ShellService {
return this.child({ cwd });
}

child(options: ShellOptions) {
child(options: ShellOptions): ShellService {
return new ShellService({
...this.#options,
...options,
env: options.env ? { ...this.#options.env, ...options.env } : this.#options.env,
});
}

quiet(options?: ShellOptions) {
return this.child({
...options,
verbose: false,
});
quiet(): ShellService {
return this.child({ verbose: false });
}

mute(options?: ShellOptions) {
return this.quiet({
...options,
stdio: "pipe",
async run(cmd: string, args: string[] = [], opts: RunOptions = {}): Promise<Output> {
const merged = this.#mergeRunOpts(opts);
if (merged.verbose) printCmdLine(opts.display ?? cmd, args);
return tinyexec(cmd, args, {
throwOnError: opts.throwOnError ?? true,
...(opts.stdin !== undefined && { stdin: opts.stdin }),
nodeOptions: {
cwd: merged.cwd,
...(merged.env && { env: merged.env }),
stdio: "inherit",
...(opts.shell && { shell: true }),
},
});
}

at(cwd: string, options?: ShellOptions) {
const getLocals = (locals: boolean | string | string[] | undefined) =>
// NOTE: the boolean handling is done outside when determining preferLocal
typeof locals === "boolean" ? [] : typeof locals === "undefined" ? [] : Array.isArray(locals) ? locals : [locals];

const cwdPreferLocal = getPreferLocal(cwd);

const preferLocal =
options?.preferLocal === false
? false
: [
...getLocals(this.#options.preferLocal),
...getLocals(options?.preferLocal),
...(cwdPreferLocal ? cwdPreferLocal : []),
];

return this.child({
...options,
cwd,
preferLocal,
async runCaptured(cmd: string, args: string[] = [], opts: RunOptions = {}): Promise<Output> {
const merged = this.#mergeRunOpts(opts);
return tinyexec(cmd, args, {
throwOnError: opts.throwOnError ?? true,
...(opts.stdin !== undefined && { stdin: opts.stdin }),
nodeOptions: {
cwd: merged.cwd,
...(merged.env && { env: merged.env }),
...(opts.shell && { shell: true }),
},
});
}

#mergeRunOpts(opts: RunOptions) {
return {
cwd: opts.cwd ?? this.#options.cwd ?? process.cwd(),
env: opts.env ? { ...this.#options.env, ...opts.env } : this.#options.env,
verbose: opts.verbose ?? this.#options.verbose ?? true,
};
}
}

function printCmdLine(cmd: string, args: string[]): void {
const tail = args.length === 0 ? "" : ` ${args.join(" ")}`;
process.stderr.write(`${palette.muted("$")} ${palette.highlight(cmd)}${tail}\n`);
}
17 changes: 10 additions & 7 deletions packages/clibuddy/src/shell/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { Options as ZxOptions, Shell as ZxShell } from "zx";

export type Shell = ZxShell;

export type ShellOptions = Partial<ZxOptions>;
export type ShellOptions = {
cwd?: string;
env?: NodeJS.ProcessEnv;
verbose?: boolean;
};

export type CreateOptions = ShellOptions & {
localBaseBinPath?: string | Array<string>;
export type RunOptions = ShellOptions & {
throwOnError?: boolean;
shell?: boolean;
stdin?: string;
display?: string;
};
17 changes: 4 additions & 13 deletions packages/clibuddy/src/shell/utils.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,7 @@
import path from "node:path";
import { ProcessOutput } from "zx";
import { NonZeroExitError } from "tinyexec";

export function isProcessOutput(value: unknown): value is ProcessOutput {
return value instanceof ProcessOutput;
}

const getLocalBinPath = (dirPath: string) => path.join(dirPath, "node_modules", ".bin");
export { NonZeroExitError };

export function getPreferLocal(localBaseBinPath: string | Array<string> | undefined) {
return !localBaseBinPath
? undefined
: Array.isArray(localBaseBinPath)
? localBaseBinPath.map(getLocalBinPath)
: [localBaseBinPath].map(getLocalBinPath);
export function isNonZeroExitError(value: unknown): value is NonZeroExitError {
return value instanceof NonZeroExitError;
}
35 changes: 0 additions & 35 deletions packages/clibuddy/test-helpers/index.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/run-run/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ CLI toolbox to fullstack common scripts in [Variable Land](https://variable.land

## Prerequisites

- Node.js >= 24.0.0
- Node.js >= 20.0.0

## Toolbox

Expand Down
2 changes: 1 addition & 1 deletion packages/run-run/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
"oxlint": "1.50.0",
"oxlint-tsgolint": "0.15.0",
"rimraf": "6.1.3",
"tsdown": "0.21.10",
"tsdown": "0.22.0",
"typescript": "6.0.3"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/run-run/src/program/commands/test-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ export function createTestStaticCommand(ctx: Context) {
"Runs static tests, including linting, formatting checks, and TypeScript type checking, to ensure code quality and correctness without executing the code.",
)
.action(async function testStaticAction() {
await ctx.shell.$`rr x jscheck tscheck`;
await ctx.shell.run("rr", ["x", "jscheck", "tscheck"]);
});
}
Loading
Loading