[READ-ONLY] Mirror of https://github.com/bombshell-dev/tools. Internal CLI to standardize tooling across all Bombshell projects
0

Configure Feed

Select the types of activity you want to include in your feed.

wip(clink): migrate to clink

Nate Moore (Mar 4, 2026, 6:04 PM EST) 0f12e49c 5f439690

+141 -206
+49
UPSTREAM.md
··· 1 + # Clink Feature Gaps Found During @bomb.sh/tools Migration 2 + 3 + ## Already Implemented (in this migration) 4 + 5 + These changes were made directly to clink during the migration and need to be committed upstream. 6 + 7 + ### 1. `remaining` args in HandlerContext 8 + **Problem:** The trie walker computes remaining/unconsumed args but dropped them before calling the handler. CLI tools commonly need to pass through unknown flags to underlying commands (e.g., `bsh build --dts` passes `--dts` through to tsdown). 9 + 10 + **Changes made:** 11 + - `src/api.ts`: Added `remaining: string[]` to `HandlerContext` type, `defineCommand` handler signature, and `exec()` function 12 + - `src/framework/build/manifest.ts`: Updated `generateBin()` to pass `remaining` array to `command.handler()` 13 + - `src/commands/exec.ts`: Rewrote to avoid parsing flags (they belong to target command), threads raw passthrough args into `remaining` 14 + - `src/commands/index.ts`: Passes `match.remaining` through to `exec()` 15 + 16 + ### 2. Public `tools.exec` API (powered by tinyexec) 17 + **Problem:** The `Exec` class was internal-only (used by `git` and `pm`). Command handlers had no way to shell out to arbitrary binaries. 18 + 19 + **Changes made:** 20 + - `src/framework/tools/exec.ts`: Replaced custom `spawn`-based implementation with `tinyexec`. Added automatic `node_modules/.bin` PATH prepending (like `zx`'s `preferLocal`). Added `stream()` method returning `AsyncIterable<string>` for line-by-line output processing. 21 + - `src/framework/tools/index.ts`: Added `exec: Exec` to public `Tools` type and lazy loader 22 + - `package.json`: Added `tinyexec` dependency 23 + 24 + ### 3. Flag passthrough in `clink exec` 25 + **Problem:** `clink exec format --check` parsed `--check` via `@bomb.sh/args`, consuming it as an option instead of passing it through to the target command. This meant passthrough flags were silently swallowed. 26 + 27 + **Changes made:** 28 + - `src/commands/exec.ts`: Removed `@bomb.sh/args` parsing. Now splits argv into positional segments (for routing) and everything else (passthrough). Flags are never parsed — they belong to the target command. 29 + 30 + ## Remaining Gaps (nice-to-have) 31 + 32 + ### 4. Binary path resolution helper 33 + **Problem:** CLI tools that wrap other CLI tools need to resolve `.bin` paths relative to their own package (not the consumer's project). After bundling, `import.meta.url`-based resolution may point to the wrong location. 34 + 35 + **Current workaround:** The new `Exec` class automatically prepends `node_modules/.bin` to PATH, so bare command names (`oxlint`, `tsdown`, etc.) resolve correctly. This covers the common case. 36 + 37 + **Edge case not covered:** When a CLI package needs binaries from its *own* `node_modules` (not the consumer's), the PATH prepending uses `session.cwd` which points to the consumer's project. For the tools use case this works because the binaries are direct dependencies, but a more robust solution would walk up from the CLI package's install location. 38 + 39 + **Proposed API (if needed):** 40 + ```typescript 41 + // Resolve .bin path relative to a specific package 42 + const oxlint = tools.exec.bin('oxlint'); 43 + await tools.exec.runOrThrow(oxlint, args); 44 + ``` 45 + 46 + ### 5. No-command help text 47 + **Problem:** Running `bsh` with no arguments shows `Unknown command:` and exits. The old implementation listed available commands. The generated `bin.js` has no awareness of what commands exist for help text. 48 + 49 + **Proposed:** Generate a help handler in `bin.js` that lists available commands from the trie when no args are provided.
+5 -10
package.json
··· 4 4 "type": "module", 5 5 "license": "MIT", 6 6 "bin": { 7 - "bsh": "dist/bin.mjs" 7 + "bsh": "dist/bin.js" 8 8 }, 9 9 "description": "The internal dev, build, and lint CLI for Bombshell projects", 10 10 "keywords": [ ··· 26 26 "access": "public" 27 27 }, 28 28 "exports": { 29 - ".": { 30 - "import": "./dist/bin.mjs" 31 - }, 32 29 "./*": "./dist/*", 33 30 "./package.json": "./package.json", 34 31 "./tsconfig.json": "./tsconfig.json" 35 32 }, 36 33 "scripts": { 37 - "bsh": "node --experimental-strip-types --no-warnings ./src/bin.ts", 34 + "bsh": "clink exec", 38 35 "dev": "pnpm run bsh dev", 39 - "build": "pnpm run bsh build", 36 + "build": "clink build", 40 37 "format": "pnpm run bsh format", 41 38 "init": "pnpm run bsh init", 42 39 "lint": "pnpm run bsh lint", ··· 44 41 }, 45 42 "devDependencies": { 46 43 "@changesets/cli": "^2.28.1", 47 - "@types/node": "^22.13.14" 44 + "@types/node": "^22.13.14", 45 + "clink": "link:../clink/packages/clink" 48 46 }, 49 47 "dependencies": { 50 - "@bomb.sh/args": "^0.3.1", 51 - "escalade": "^3.2.0", 52 48 "oxfmt": "^0.36.0", 53 49 "oxlint": "^1.51.0", 54 50 "publint": "^0.3.18", 55 - "tinyexec": "^1.0.1", 56 51 "tsdown": "^0.21.0-beta.2", 57 52 "vitest": "^4.0.18" 58 53 },
+3 -25
pnpm-lock.yaml
··· 8 8 9 9 .: 10 10 dependencies: 11 - '@bomb.sh/args': 12 - specifier: ^0.3.1 13 - version: 0.3.1 14 - escalade: 15 - specifier: ^3.2.0 16 - version: 3.2.0 17 11 oxfmt: 18 12 specifier: ^0.36.0 19 13 version: 0.36.0 ··· 23 17 publint: 24 18 specifier: ^0.3.18 25 19 version: 0.3.18 26 - tinyexec: 27 - specifier: ^1.0.1 28 - version: 1.0.1 29 20 tsdown: 30 21 specifier: ^0.21.0-beta.2 31 22 version: 0.21.0-beta.2(publint@0.3.18) ··· 39 30 '@types/node': 40 31 specifier: ^22.13.14 41 32 version: 22.13.14 33 + clink: 34 + specifier: link:../clink/packages/clink 35 + version: link:../clink/packages/clink 42 36 43 37 packages: 44 38 ··· 67 61 resolution: {integrity: sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw==} 68 62 engines: {node: ^20.19.0 || >=22.12.0} 69 63 70 - '@bomb.sh/args@0.3.1': 71 - resolution: {integrity: sha512-CwxKrfgcorUPP6KfYD59aRdBYWBTsfsxT+GmoLVnKo5Tmyoqbpo0UNcjngRMyU+6tiPbd18RuIYxhgAn44wU/Q==} 72 - 73 64 '@changesets/apply-release-plan@7.0.10': 74 65 resolution: {integrity: sha512-wNyeIJ3yDsVspYvHnEz1xQDq18D9ifed3lI+wxRQRK4pArUcuHgCTrHv0QRnnwjhVCQACxZ+CBih3wgOct6UXw==} 75 66 ··· 913 904 engines: {node: '>=18'} 914 905 hasBin: true 915 906 916 - escalade@3.2.0: 917 - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} 918 - engines: {node: '>=6'} 919 - 920 907 esprima@4.0.1: 921 908 resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} 922 909 engines: {node: '>=4'} ··· 1289 1276 tinybench@2.9.0: 1290 1277 resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} 1291 1278 1292 - tinyexec@1.0.1: 1293 - resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==} 1294 - 1295 1279 tinyexec@1.0.2: 1296 1280 resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} 1297 1281 engines: {node: '>=18'} ··· 1479 1463 dependencies: 1480 1464 '@babel/helper-string-parser': 8.0.0-rc.2 1481 1465 '@babel/helper-validator-identifier': 8.0.0-rc.2 1482 - 1483 - '@bomb.sh/args@0.3.1': {} 1484 1466 1485 1467 '@changesets/apply-release-plan@7.0.10': 1486 1468 dependencies: ··· 2162 2144 '@esbuild/win32-ia32': 0.27.3 2163 2145 '@esbuild/win32-x64': 0.27.3 2164 2146 2165 - escalade@3.2.0: {} 2166 - 2167 2147 esprima@4.0.1: {} 2168 2148 2169 2149 estree-walker@3.0.3: ··· 2538 2518 term-size@2.2.1: {} 2539 2519 2540 2520 tinybench@2.9.0: {} 2541 - 2542 - tinyexec@1.0.1: {} 2543 2521 2544 2522 tinyexec@1.0.2: {} 2545 2523
-31
src/bin.ts
··· 1 - #!/usr/bin/env node 2 - import { argv } from "node:process"; 3 - import { build } from "./commands/build.ts"; 4 - import { dev } from "./commands/dev.ts"; 5 - import { format } from "./commands/format.ts"; 6 - import { init } from "./commands/init.ts"; 7 - import { lint } from "./commands/lint.ts"; 8 - import { test } from "./commands/test.ts"; 9 - 10 - const commands = { build, dev, format, init, lint, test }; 11 - 12 - async function main() { 13 - const [command, ...args] = argv.slice(2); 14 - 15 - if (!command) { 16 - console.log(`No command provided. Available commands: ${Object.keys(commands).join(", ")}\n`); 17 - return; 18 - } 19 - 20 - const run = commands[command as keyof typeof commands]; 21 - if (!run) { 22 - console.log( 23 - `Unknown command: ${command}. Available commands: ${Object.keys(commands).join(", ")}`, 24 - ); 25 - return; 26 - } 27 - 28 - await run({ args }); 29 - } 30 - 31 - main();
+19 -19
src/commands/build.ts
··· 1 - import { x } from "tinyexec"; 2 - import type { CommandContext } from "../context.ts"; 3 - import { local } from "../utils.ts"; 1 + import { defineCommand } from "clink"; 4 2 5 - export async function build(ctx: CommandContext) { 6 - const stdio = x(local("tsdown"), [ 7 - "src/bin.ts", 8 - "--format", 9 - "esm", 10 - "--sourcemap", 11 - "--clean", 12 - "--unbundle", 13 - "--no-config", 14 - ...ctx.args, 15 - ]); 16 - 17 - for await (const line of stdio) { 18 - console.log(line); 19 - } 20 - } 3 + export default defineCommand({ 4 + async handler({ tools, remaining }) { 5 + await tools.exec.runOrThrow( 6 + "tsdown", 7 + [ 8 + "src/bin.ts", 9 + "--format", 10 + "esm", 11 + "--sourcemap", 12 + "--clean", 13 + "--unbundle", 14 + "--no-config", 15 + ...remaining, 16 + ], 17 + { stdio: "inherit" }, 18 + ); 19 + }, 20 + });
+28 -29
src/commands/dev.ts
··· 1 - import { x } from "tinyexec"; 2 - import type { CommandContext } from "../context.ts"; 1 + import { defineCommand } from "clink"; 2 + 3 + export default defineCommand({ 4 + async handler({ tools, remaining }) { 5 + const [file = "./src/index.ts", ...rest] = remaining; 6 + console.log( 7 + `node --experimental-transform-types --disable-warning=ExperimentalWarning ${remaining.join(" ")}`, 8 + ); 9 + console.log("Starting dev server..."); 10 + console.log("Press Ctrl+C to stop the server."); 3 11 4 - // standardized `dev` command, shells out to `node --strip-types` 5 - export async function dev(ctx: CommandContext) { 6 - const { args } = ctx; 7 - const [file = "./src/index.ts", ...rest] = args; 8 - // console.clear(); 9 - console.log( 10 - `node --experimental-transform-types --disable-warning=ExperimentalWarning ${args.join(" ")}`, 11 - ); 12 - const stdio = x("node", [ 13 - "--experimental-transform-types", 14 - "--no-warnings", 15 - "--watch-path=./src/", 16 - file, 17 - ...rest, 18 - ]); 19 - console.log("Starting dev server..."); 20 - console.log("Press Ctrl+C to stop the server."); 12 + const stdio = tools.exec.stream("node", [ 13 + "--experimental-transform-types", 14 + "--no-warnings", 15 + "--watch-path=./src/", 16 + file, 17 + ...rest, 18 + ]); 21 19 22 - for await (const line of stdio) { 23 - if (line.startsWith("Restarting")) { 20 + for await (const line of stdio) { 21 + if (line.startsWith("Restarting")) { 22 + console.log(line); 23 + continue; 24 + } 25 + if (line.startsWith("Completed")) { 26 + console.log(); 27 + continue; 28 + } 24 29 console.log(line); 25 - continue; 26 30 } 27 - if (line.startsWith("Completed")) { 28 - console.log(); 29 - continue; 30 - } 31 - console.log(line); 32 - } 33 - } 31 + }, 32 + });
+8 -10
src/commands/format.ts
··· 1 1 import { fileURLToPath } from "node:url"; 2 - import { x } from "tinyexec"; 3 - import type { CommandContext } from "../context.ts"; 4 - import { local } from "../utils.ts"; 2 + import { defineCommand } from "clink"; 5 3 6 4 const config = fileURLToPath(new URL("../../oxfmtrc.json", import.meta.url)); 7 5 8 - export async function format(ctx: CommandContext) { 9 - const stdio = x(local("oxfmt"), ["-c", config, "./src", ...ctx.args]); 10 - 11 - for await (const line of stdio) { 12 - console.log(line); 13 - } 14 - } 6 + export default defineCommand({ 7 + async handler({ tools, remaining }) { 8 + await tools.exec.runOrThrow("oxfmt", ["-c", config, "./src", ...remaining], { 9 + stdio: "inherit", 10 + }); 11 + }, 12 + });
+14 -32
src/commands/init.ts
··· 1 - import { readFile, writeFile } from "node:fs/promises"; 2 - import { cwd } from "node:process"; 3 - import { pathToFileURL } from "node:url"; 4 - import { x } from "tinyexec"; 5 - import type { CommandContext } from "../context.ts"; 1 + import { defineCommand } from "clink"; 6 2 7 - export async function init(ctx: CommandContext) { 8 - const [_name = "."] = ctx.args; 9 - const cwdUrl = pathToFileURL(`${cwd()}/`); 10 - const name = _name === "." ? new URL("../", cwdUrl).pathname.split("/").filter(Boolean).pop()! : _name; 11 - const dest = new URL("./.temp/", cwdUrl); 12 - for await (const line of x("pnpx", ["giget@latest", "gh:bombshell-dev/template", name])) { 13 - console.log(line); 14 - } 3 + export default defineCommand({ 4 + async handler({ tools, remaining }) { 5 + const [_name = "."] = remaining; 6 + const cwd = process.cwd(); 7 + const name = _name === "." ? cwd.split("/").filter(Boolean).pop()! : _name; 15 8 16 - const promises: Promise<void>[] = []; 17 - for (const file of ["package.json", "README.md"]) { 18 - promises.push( 19 - postprocess(new URL(file, dest), (contents) => { 20 - return contents.replaceAll("$name", name); 21 - }), 22 - ); 23 - } 24 - await Promise.all(promises); 25 - } 9 + await tools.pm.execute("giget@latest", ["gh:bombshell-dev/template", name]); 26 10 27 - async function postprocess( 28 - file: URL, 29 - transform: (contents: string) => string | undefined | Promise<string | undefined>, 30 - ) { 31 - const contents = await readFile(file, { encoding: "utf8" }); 32 - const result = await transform(contents); 33 - if (!result) return; 34 - await writeFile(file, result, { encoding: "utf8" }); 35 - } 11 + for (const file of ["package.json", "README.md"]) { 12 + const filePath = `.temp/${file}`; 13 + const contents = await tools.fs.read(filePath); 14 + await tools.fs.write(filePath, contents.replaceAll("$name", name)); 15 + } 16 + }, 17 + });
+9 -16
src/commands/lint.ts
··· 1 1 import { fileURLToPath } from "node:url"; 2 - import { x } from "tinyexec"; 3 - import type { CommandContext } from "../context.ts"; 4 - import { local } from "../utils.ts"; 2 + import { defineCommand } from "clink"; 5 3 6 4 const config = fileURLToPath(new URL("../../oxlintrc.json", import.meta.url)); 7 5 8 - export async function lint(ctx: CommandContext) { 9 - const oxlint = x(local("oxlint"), ["-c", config, "./src", ...ctx.args]); 10 - 11 - for await (const line of oxlint) { 12 - console.log(line); 13 - } 14 - 15 - const publint = x(local("publint"), ["--strict"]); 16 - 17 - for await (const line of publint) { 18 - console.log(line); 19 - } 20 - } 6 + export default defineCommand({ 7 + async handler({ tools, remaining }) { 8 + await tools.exec.runOrThrow("oxlint", ["-c", config, "./src", ...remaining], { 9 + stdio: "inherit", 10 + }); 11 + await tools.exec.runOrThrow("publint", ["--strict"], { stdio: "inherit" }); 12 + }, 13 + });
+6 -10
src/commands/test.ts
··· 1 - import { x } from "tinyexec"; 2 - import type { CommandContext } from "../context.ts"; 3 - import { local } from "../utils.ts"; 4 - 5 - export async function test(ctx: CommandContext) { 6 - const stdio = x(local("vitest"), ["run", ...ctx.args]); 1 + import { defineCommand } from "clink"; 7 2 8 - for await (const line of stdio) { 9 - console.log(line); 10 - } 11 - } 3 + export default defineCommand({ 4 + async handler({ tools, remaining }) { 5 + await tools.exec.runOrThrow("vitest", ["run", ...remaining], { stdio: "inherit" }); 6 + }, 7 + });
-3
src/context.ts
··· 1 - export interface CommandContext { 2 - args: string[]; 3 - }
-21
src/utils.ts
··· 1 - import { cwd } from "node:process"; 2 - import { fileURLToPath, pathToFileURL } from "node:url"; 3 - import escalade from "escalade"; 4 - 5 - export function local(file: string) { 6 - return fileURLToPath(new URL(`../node_modules/.bin/${file}`, import.meta.url)); 7 - } 8 - 9 - export async function getPackageJSON() { 10 - const pkg = await escalade(cwd(), (dir, files) => { 11 - return files.find((file) => file === "package.json"); 12 - }); 13 - if (!pkg) { 14 - throw new Error("No package.json found"); 15 - } 16 - const pkgPath = pathToFileURL(pkg); 17 - const { default: json } = await import(pkgPath.toString(), { 18 - with: { type: "json" }, 19 - }); 20 - return json; 21 - }