@magnaboy/cli-repo
v0.0.2
Published
Repository script entrypoints, paths, argument rules and package-manager commands for Node.js CLIs.
Readme
@magnaboy/cli-repo
The runtime a repository's own scripts sit on: entrypoints that know the checkout, the three directories a path can be relative to, the argument rules Commander cannot own, and package-manager commands that survive Windows.
Install
npm i @magnaboy/cli-repoRequires Node 25+ and ESM. commander is a peer dependency, needed only for
runRepositoryCommand and parseCommand.
import { createRepository, runRepositoryCommand } from '@magnaboy/cli-repo';
import { packageManagerInvocation } from '@magnaboy/cli-repo/package-manager';The repository
import { createRepository } from '@magnaboy/cli-repo';
const repository = createRepository(import.meta.url);
repository.root; // the checkout
repository.fromRoot('apps', 'web'); // a path the repository owns
repository.sibling('other-repo', 'src'); // a checkout beside this onecreateRepository derives everything from the calling module's own location, never from
process.cwd(). A script started from a package subdirectory, or through pnpm --filter, still
resolves repository paths correctly. A module that lives in scripts/lib/ instead passes its own two locations:
export const repository = createRepository(import.meta.url, { root: '../..', scripts: '..' });Three directories, not one
A script deals with three, and using the wrong one is the usual source of "works for me" bugs:
repoRoot— for anything the repository owns. Build outputs, config, checked-in fixtures.workingDirectory— where the process is running. pnpm changes this for a filtered script, so it is the right base for package-local inputs and nothing else.invocationDirectory— where the person was when they typed the command, recovered fromINIT_CWD. A relative path inargvis relative to this, not to either of the others.
const apk = resolveWorkingPath(options.apk, context.invocationDirectory);Entrypoints
import { createRepository, runRepositoryCommand } from '@magnaboy/cli-repo';
import { Command } from 'commander';
const repository = createRepository(import.meta.url);
await runRepositoryCommand(repository, ({ repoRoot, runner }) =>
new Command('build').option('--fast').action(async (options: { fast?: boolean }) => {
await runner.run({ command: 'echo', args: [repoRoot], output: 'tee' });
})
);runRepositoryScript is the same without Commander, for a script that parses its own arguments.
runInteractiveRepositoryScript adds a Prompter that is closed even when the script throws.
All three delegate to runScript from @magnaboy/cli-core, so SIGINT exits 130, a CliError
exits 2 with one line and no stack, and cleanup runs on every path. A Commander program built this
way needs no usageHint: --help exits 0 and a usage error prints once.
Running a subcommand program with no arguments prints its help and exits 0. Commander's own behaviour there is to write help to stderr through a channel this suppresses, which would otherwise print nothing at all.
Argument rules
Commander should own argument parsing. These cover the cases where it cannot: a parser that forwards unrecognised arguments to another tool, such as passing everything it does not know to Gradle.
import { requireList, requireValue, usageRequested } from '@magnaboy/cli-repo/argv';
if (usageRequested(args, usage)) return 0;
const device = requireValue(args, index, '--device');
const suites = requireList(args, index, '--suites'); // "a, b c" -> ['a', 'b', 'c']requireValue refuses the next argument when it is itself an option, which is what turns
--device --verbose into a usage error instead of a device named --verbose. Set rejectAnyDash
for parsers whose short flags would otherwise be swallowed, and allowEmpty for an option whose
value may legitimately be blank.
parseCommand is for a parser that returns a value rather than dispatching. It stops Commander
writing or exiting, returns true when help was requested, and reports a usage error as a
CliError. It applies exitOverride to every subcommand, because Commander copies that setting
when .command() creates a child rather than looking it up on the parent.
Package managers
import { createRecursiveScriptCommand, requirePackageManager } from '@magnaboy/cli-repo/package-manager';
const pnpm = await requirePackageManager(runner);
await runChecked(runner, createRecursiveScriptCommand({
executable: pnpm,
host: await detectHost(runner),
cwd: repository.root,
script: 'build',
workspaceConcurrency: 4
}));On Windows pnpm, npm, yarn and bun are all .cmd shims, which cannot be spawned directly;
packageManagerInvocation reaches them through cmd /d /c. Arguments stay a list on every host,
so a version range like left-pad@>=1.0.0 <2 arrives as one argument instead of being split by a
shell. requirePackageManager reports a missing tool as a PrerequisiteError rather than letting
it surface as a spawn failure.
createRecursiveScriptCommand requires workspaceConcurrency rather than defaulting it. The right
number depends on what the script does, and pnpm's one-per-core default melts a machine running
database or Gradle work under every package at once.
Transcript
import { Transcript } from '@magnaboy/cli-repo/transcript';
import { Workflow } from '@magnaboy/cli-core';
const transcript = new Transcript(repository.fromRoot('meta/build.log'));
transcript.reset();
const workflow = new Workflow(transcript);A WorkflowOutput that writes to the terminal and appends the same text to a build log. The appends
are synchronous: a build killed mid-step must still leave the log of everything before it, and an
async write can be lost when the process exits. If the log becomes unwritable the build carries on
and the transcript says so once, rather than failing the build over its own logging.
Run naming
import { runTimestamp, safeArtifactName } from '@magnaboy/cli-repo/naming';
const directory = `${runTimestamp()}-${safeArtifactName(label)}`; // 20260917T123456Z-wifi_railsStamps are UTC so two machines name the same run the same way. safeArtifactName reduces a label to
characters every filesystem accepts.
