Replace commander with node's builtin parseArgs

The deploy CLI gets a hand-rolled verb dispatch over per-verb
parseArgs option tables and one usage text; trim and
smogdexspritesheet are plain parseArgs calls. tools/build only
listed the dep without importing it.

The smogdex sheet rule depends on its tool's source, so it reran
once; the rebuild was byte-identical (stamps unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Christopher Monsanto
2026-08-17 17:32:42 -04:00
parent 561b7ba0fd
commit c1dfaaec8c
8 changed files with 272 additions and 200 deletions

18
pnpm-lock.yaml generated
View File

@@ -51,9 +51,6 @@ importers:
better-sqlite3:
specifier: ^12.4.1
version: 12.11.1
commander:
specifier: ^5.1.0
version: 5.1.0
debug:
specifier: ^4.1.1
version: 4.4.3
@@ -72,9 +69,6 @@ importers:
base32-encode:
specifier: ^2.0.0
version: 2.0.0
commander:
specifier: ^5.1.0
version: 5.1.0
debug:
specifier: ^4.1.1
version: 4.4.3
@@ -105,9 +99,6 @@ importers:
'@types/spritesmith':
specifier: ^3.4.1
version: 3.4.5
commander:
specifier: ^5.0.0
version: 5.1.0
spritesmith:
specifier: ^3.4.1
version: 3.5.1
@@ -117,9 +108,6 @@ importers:
'@types/node':
specifier: ^24.10.7
version: 24.13.3
commander:
specifier: ^5.1.0
version: 5.1.0
packages:
@@ -377,10 +365,6 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@5.1.0:
resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
engines: {node: '>= 6'}
concat-stream@1.5.2:
resolution: {integrity: sha512-H6xsIBfQ94aESBG8jGHXQ7i5AEpy5ZeVaLDOisDICiTCKpqEfr34/KmTrspKQNoLKNu9gTkovlpQcUi630AKiQ==}
engines: {'0': node >= 0.8}
@@ -1019,8 +1003,6 @@ snapshots:
dependencies:
delayed-stream: 1.0.0
commander@5.1.0: {}
concat-stream@1.5.2:
dependencies:
inherits: 2.0.4

View File

@@ -4,7 +4,6 @@
"@types/debug": "^4.1.5",
"@types/node": "^24.10.7",
"better-sqlite3": "^12.4.1",
"commander": "^5.1.0",
"debug": "^4.1.1"
},
"scripts": {

View File

@@ -4,8 +4,7 @@ import fs from 'fs';
import os from 'os';
import nodePath from 'path';
import {fileURLToPath, pathToFileURL} from 'url';
import {program} from 'commander';
import {parseArgs} from 'util';
import {type RuleDecl, getDecls} from '../build/artifact.ts';
import {casPath} from '../build/cas.ts';
@@ -36,13 +35,60 @@ interface CommonOpts {
verbose? : boolean;
}
function common(cmd : ReturnType<typeof program.command>) : ReturnType<typeof program.command> {
return cmd
.option('-j, --jobs <n>', 'number of parallel jobs', String(os.availableParallelism()))
.option('-n, --dry-run', 'print what would run without changing anything')
.option('--fail-fast', 'stop scheduling new rules after the first failure')
.option('--config <file>', 'config file', 'build.config')
.option('-v, --verbose', 'print more detail');
interface VerbOpts extends CommonOpts {
output? : string;
link? : boolean;
tar? : boolean;
}
const USAGE = `usage: node tools/deploy/index.ts <command> [options]
commands:
build [files...] build the rules of the given deploys (default: all *.build.ts)
deploy [names...] build, finish, and pipe each subset tar (or %d dir) to its
command from deploy.json5 (no names: list the deploys)
run <file> -o <dir> build, finish, and materialize to a directory (or tar file)
inspect <paths...> -o <dir> build every rule touching the given source paths and copy
the outputs out
options:
-j, --jobs <n> number of parallel jobs (default: all cores)
-n, --dry-run print what would run without changing anything
--fail-fast stop scheduling new rules after the first failure
--config <file> config file (default: build.config)
-o, --output <dir> run/inspect: output directory (a file with --tar)
--link run: hardlink instead of copying
--tar run: write a tar file
-v, --verbose print more detail
-h, --help show this help`;
const COMMON_OPTIONS = {
jobs: {type: 'string', short: 'j', default: String(os.availableParallelism())},
'dry-run': {type: 'boolean', short: 'n'},
'fail-fast': {type: 'boolean'},
config: {type: 'string', default: 'build.config'},
verbose: {type: 'boolean', short: 'v'},
help: {type: 'boolean', short: 'h'},
} as const;
const VERB_OPTIONS = {
build: {},
deploy: {},
run: {
output: {type: 'string', short: 'o'},
link: {type: 'boolean'},
tar: {type: 'boolean'},
},
inspect: {
output: {type: 'string', short: 'o'},
},
} as const;
function requireOutput(opts : VerbOpts) : string {
if (opts.output === undefined) {
throw new BuildError('missing -o/--output');
}
return opts.output;
}
function discoverDeployFiles() : string[] {
@@ -163,100 +209,91 @@ function waitExit(child : ChildProcess) : Promise<number | null> {
});
}
common(program.command('build [files...]'))
.description('build the rules of the given deploys (default: all *.build.ts)')
.action(async (files : string[], opts : CommonOpts) => {
setConfig(loadConfig(opts.config));
// GC needs the full rule universe: only an unfiltered union build
// can know which keys are no longer declared anywhere.
const gc = files.length === 0 && !Boolean(opts.dryRun);
await importDeploys(files.length > 0 ? files : discoverDeployFiles());
process.exitCode = await buildThen(getDecls(), opts, gc);
});
async function cmdBuild(files : string[], opts : CommonOpts) : Promise<void> {
setConfig(loadConfig(opts.config));
// GC needs the full rule universe: only an unfiltered union build
// can know which keys are no longer declared anywhere.
const gc = files.length === 0 && !Boolean(opts.dryRun);
await importDeploys(files.length > 0 ? files : discoverDeployFiles());
process.exitCode = await buildThen(getDecls(), opts, gc);
}
common(program.command('deploy [names...]'))
.description('build, finish, and pipe each subset tar to its command from deploy.json5'
+ ' (no names: list the available deploys)')
.action(async (names : string[], opts : CommonOpts) => {
const config = loadDeployConfig('deploy.json5');
if (names.length === 0) {
for (const [name, target] of config) {
console.log(`${name} (${target.buildFile})`);
for (const entry of target.deploy) {
console.log(` ${entry.subset.join(' ')} | ${entry.cmd}`);
}
async function cmdDeploy(names : string[], opts : CommonOpts) : Promise<void> {
const config = loadDeployConfig('deploy.json5');
if (names.length === 0) {
for (const [name, target] of config) {
console.log(`${name} (${target.buildFile})`);
for (const entry of target.deploy) {
console.log(` ${entry.subset.join(' ')} | ${entry.cmd}`);
}
return;
}
return;
}
for (const name of names) {
if (!config.has(name)) {
throw new BuildError(`deploy.json5: no deploy named ${name}`);
}
}
setConfig(loadConfig(opts.config));
const files = [...new Set(names.map(n => config.get(n)!.buildFile))];
const specs = await importDeploys(files);
process.exitCode = await buildThen(getDecls(), opts, false, async () => {
for (const name of names) {
if (!config.has(name)) {
throw new BuildError(`deploy.json5: no deploy named ${name}`);
}
}
setConfig(loadConfig(opts.config));
const files = [...new Set(names.map(n => config.get(n)!.buildFile))];
const specs = await importDeploys(files);
process.exitCode = await buildThen(getDecls(), opts, false, async () => {
for (const name of names) {
const target = config.get(name)!;
const aq = await runFinish(finishOf(specs, target.buildFile), Boolean(opts.verbose));
if (aq === null) {
return 1;
}
const dsts = aq.log.filter(e => e.type === 'Op').map(e => e.dst);
const subsets = matchSubsets(dsts, target.deploy);
for (const [i, entry] of target.deploy.entries()) {
const matched = subsets[i]!;
console.log(`${name}: ${matched.size} files | ${entry.cmd}`);
if (entry.dir) {
fs.mkdirSync(TMP_DIR, {recursive: true});
const tmp = fs.mkdtempSync(nodePath.join(TMP_DIR, 'deploy-'));
try {
await aq.run(tmp, 'copy', dst => matched.has(dst));
const cmd = spawn(entry.cmd.replaceAll('%d', tmp),
{shell: true, stdio: ['ignore', 'inherit', 'inherit']});
if (await waitExit(cmd) !== 0) {
return 1;
}
} finally {
fs.rmSync(tmp, {recursive: true, force: true});
}
continue;
}
const upload = spawn(entry.cmd, {shell: true, stdio: ['pipe', 'inherit', 'inherit']});
// If the command dies early we report its exit code; don't
// also crash on the resulting EPIPE, which reaches both
// stdin and (via streamx's destroy propagation) the pack.
upload.stdin!.on('error', () => {});
const pack = aq.pack(dst => matched.has(dst));
pack.on('error', () => {});
pack.pipe(upload.stdin!);
if (await waitExit(upload) !== 0) {
return 1;
}
}
}
return 0;
});
});
common(program.command('run <file>'))
.description('build, finish, and materialize to a directory (or tar file)')
.requiredOption('-o, --output <dir>', 'output directory (a file with --tar)')
.option('--link', 'hardlink instead of copying')
.option('--tar', 'write a tar file')
.action(async (file : string, opts : CommonOpts & {output : string, link? : boolean, tar? : boolean}) => {
setConfig(loadConfig(opts.config));
const specs = await importDeploys([file]);
process.exitCode = await buildThen(getDecls(), opts, false, async () => {
const aq = await runFinish(finishOf(specs, file), Boolean(opts.verbose));
const target = config.get(name)!;
const aq = await runFinish(finishOf(specs, target.buildFile), Boolean(opts.verbose));
if (aq === null) {
return 1;
}
await aq.run(opts.output, opts.tar ? 'tar' : opts.link ? 'link' : 'copy');
return 0;
});
const dsts = aq.log.filter(e => e.type === 'Op').map(e => e.dst);
const subsets = matchSubsets(dsts, target.deploy);
for (const [i, entry] of target.deploy.entries()) {
const matched = subsets[i]!;
console.log(`${name}: ${matched.size} files | ${entry.cmd}`);
if (entry.dir) {
fs.mkdirSync(TMP_DIR, {recursive: true});
const tmp = fs.mkdtempSync(nodePath.join(TMP_DIR, 'deploy-'));
try {
await aq.run(tmp, 'copy', dst => matched.has(dst));
const cmd = spawn(entry.cmd.replaceAll('%d', tmp),
{shell: true, stdio: ['ignore', 'inherit', 'inherit']});
if (await waitExit(cmd) !== 0) {
return 1;
}
} finally {
fs.rmSync(tmp, {recursive: true, force: true});
}
continue;
}
const upload = spawn(entry.cmd, {shell: true, stdio: ['pipe', 'inherit', 'inherit']});
// If the command dies early we report its exit code; don't
// also crash on the resulting EPIPE, which reaches both
// stdin and (via streamx's destroy propagation) the pack.
upload.stdin!.on('error', () => {});
const pack = aq.pack(dst => matched.has(dst));
pack.on('error', () => {});
pack.pipe(upload.stdin!);
if (await waitExit(upload) !== 0) {
return 1;
}
}
}
return 0;
});
}
async function cmdRun(file : string, opts : VerbOpts) : Promise<void> {
const output = requireOutput(opts);
setConfig(loadConfig(opts.config));
const specs = await importDeploys([file]);
process.exitCode = await buildThen(getDecls(), opts, false, async () => {
const aq = await runFinish(finishOf(specs, file), Boolean(opts.verbose));
if (aq === null) {
return 1;
}
await aq.run(output, opts.tar ? 'tar' : opts.link ? 'link' : 'copy');
return 0;
});
}
function slugOf(decl : RuleDecl) : string {
const template = decl.displayTemplate ?? decl.cmds[0]!;
@@ -265,73 +302,121 @@ function slugOf(decl : RuleDecl) : string {
return slug === '' ? 'rule' : slug;
}
common(program.command('inspect <paths...>'))
.description('build every rule touching the given source paths and copy the outputs out')
.requiredOption('-o, --output <dir>', 'output directory')
.action(async (paths : string[], opts : CommonOpts & {output : string}) => {
setConfig(loadConfig(opts.config));
await importDeploys(discoverDeployFiles());
// Accept absolute paths and paths relative to where the user ran the
// command; rules declare repo-root-relative paths.
const targets = paths.map(p => {
const target = nodePath.relative(root, nodePath.resolve(invocationCwd, p));
if (target.startsWith('..')) {
throw new BuildError(`Not under the repo root: ${p}`);
}
return target;
});
const closure = new Set<RuleDecl>();
for (const decl of getDecls()) {
const hit = [...decl.inputs, ...decl.deps].some(i => typeof i === 'string'
&& targets.some(t => i === t || i.startsWith(t + '/')));
if (hit) {
closure.add(decl);
}
async function cmdInspect(paths : string[], opts : VerbOpts) : Promise<void> {
const output = requireOutput(opts);
setConfig(loadConfig(opts.config));
await importDeploys(discoverDeployFiles());
// Accept absolute paths and paths relative to where the user ran the
// command; rules declare repo-root-relative paths.
const targets = paths.map(p => {
const target = nodePath.relative(root, nodePath.resolve(invocationCwd, p));
if (target.startsWith('..')) {
throw new BuildError(`Not under the repo root: ${p}`);
}
if (closure.size === 0) {
throw new BuildError(`No rules consume: ${targets.join(' ')}`);
}
// Transitive consumers: show everything these files end up in. The
// executor pulls in any producers the closure needs on its own.
for (let grew = true; grew;) {
grew = false;
for (const decl of getDecls()) {
if (closure.has(decl)) {
continue;
}
if ([...decl.inputs, ...decl.deps].some(i => typeof i !== 'string' && closure.has(i.decl))) {
closure.add(decl);
grew = true;
}
}
}
const decls = getDecls().filter(d => closure.has(d));
console.log(`inspect: ${decls.length} rules`);
process.exitCode = await buildThen(decls, opts, false, async () => {
for (const decl of decls) {
const dir = nodePath.join(opts.output, slugOf(decl));
fs.mkdirSync(dir, {recursive: true});
for (const artifact of decl.outputs) {
let dst = nodePath.join(dir, artifact.filename);
for (let n = 2; fs.existsSync(dst); n++) {
dst = nodePath.join(dir, `${artifact.name}-${n}.${artifact.ext}`);
}
fs.copyFileSync(casPath(CAS_DIR, artifact.hash, artifact.ext), dst);
fs.chmodSync(dst, 0o644);
console.log(`${nodePath.relative(opts.output, dst)}`);
}
}
return 0;
});
return target;
});
try {
await program.parseAsync(process.argv);
if (process.argv.slice(2).length === 0) {
program.outputHelp();
const closure = new Set<RuleDecl>();
for (const decl of getDecls()) {
const hit = [...decl.inputs, ...decl.deps].some(i => typeof i === 'string'
&& targets.some(t => i === t || i.startsWith(t + '/')));
if (hit) {
closure.add(decl);
}
}
if (closure.size === 0) {
throw new BuildError(`No rules consume: ${targets.join(' ')}`);
}
// Transitive consumers: show everything these files end up in. The
// executor pulls in any producers the closure needs on its own.
for (let grew = true; grew;) {
grew = false;
for (const decl of getDecls()) {
if (closure.has(decl)) {
continue;
}
if ([...decl.inputs, ...decl.deps].some(i => typeof i !== 'string' && closure.has(i.decl))) {
closure.add(decl);
grew = true;
}
}
}
const decls = getDecls().filter(d => closure.has(d));
console.log(`inspect: ${decls.length} rules`);
process.exitCode = await buildThen(decls, opts, false, async () => {
for (const decl of decls) {
const dir = nodePath.join(output, slugOf(decl));
fs.mkdirSync(dir, {recursive: true});
for (const artifact of decl.outputs) {
let dst = nodePath.join(dir, artifact.filename);
for (let n = 2; fs.existsSync(dst); n++) {
dst = nodePath.join(dir, `${artifact.name}-${n}.${artifact.ext}`);
}
fs.copyFileSync(casPath(CAS_DIR, artifact.hash, artifact.ext), dst);
fs.chmodSync(dst, 0o644);
console.log(`${nodePath.relative(output, dst)}`);
}
}
return 0;
});
}
async function main(argv : string[]) : Promise<void> {
const [verb, ...rest] = argv;
if (verb === undefined || verb === '-h' || verb === '--help') {
console.log(USAGE);
return;
}
if (!(verb in VERB_OPTIONS)) {
throw new BuildError(`Unknown command: ${verb} (-h for usage)`);
}
let parsed;
try {
parsed = parseArgs({
args: rest,
options: {...COMMON_OPTIONS, ...VERB_OPTIONS[verb as keyof typeof VERB_OPTIONS]},
allowPositionals: true,
});
} catch (err) {
throw new BuildError(`${(err as Error).message.split('\n')[0]} (-h for usage)`);
}
const v = parsed.values as {[k : string] : string | boolean | undefined};
const positionals = parsed.positionals;
if (v.help) {
console.log(USAGE);
return;
}
const opts : VerbOpts = {
jobs: v.jobs as string,
dryRun: Boolean(v['dry-run']),
failFast: Boolean(v['fail-fast']),
config: v.config as string,
verbose: Boolean(v.verbose),
output: v.output as string | undefined,
link: Boolean(v.link),
tar: Boolean(v.tar),
};
switch (verb) {
case 'build':
return cmdBuild(positionals, opts);
case 'deploy':
return cmdDeploy(positionals, opts);
case 'run':
if (positionals.length !== 1) {
throw new BuildError('run takes exactly one deploy file');
}
return cmdRun(positionals[0]!, opts);
case 'inspect':
if (positionals.length === 0) {
throw new BuildError('inspect takes at least one source path');
}
return cmdInspect(positionals, opts);
}
}
try {
await main(process.argv.slice(2));
} catch (err) {
if (err instanceof BuildError) {
console.error(err.message);

View File

@@ -4,7 +4,6 @@
"@types/node": "^24.10.7",
"@types/tar-stream": "^2.2.2",
"base32-encode": "^2.0.0",
"commander": "^5.1.0",
"debug": "^4.1.1",
"json5": "^2.2.3",
"tar-stream": "^3.0.0"

View File

@@ -1,20 +1,25 @@
import program from 'commander'
import spritesmith from 'spritesmith'
import path from 'path'
import fs from 'fs'
import util from 'util';
import * as spritedata from '@smogon/sprite-data/index.ts';
program
.option('--image <file>', 'where to put image')
.option('--stylesheet <file>', 'where to put stylesheet')
.parse(process.argv)
const {values: opts, positionals: srcs} = util.parseArgs({
options: {
image: {type: 'string'},
stylesheet: {type: 'string'},
},
allowPositionals: true,
});
if (opts.image === undefined || opts.stylesheet === undefined) {
throw new Error('usage: --image <file> --stylesheet <file> -- <sprites...>');
}
const run = util.promisify(spritesmith.run);
let result = await run({
src: program.args
src: srcs
});
const spaceRe = /[ _]+/g
@@ -63,5 +68,5 @@ for (let [id, sprite] of sprites) {
}`;
}
fs.writeFileSync(program.image, result.image, 'binary');
fs.writeFileSync(program.stylesheet, stylesheet);
fs.writeFileSync(opts.image, result.image, 'binary');
fs.writeFileSync(opts.stylesheet, stylesheet);

View File

@@ -4,7 +4,6 @@
"@smogon/sprite-data": "workspace:*",
"@types/node": "^24.10.7",
"@types/spritesmith": "^3.4.1",
"commander": "^5.0.0",
"spritesmith": "^3.4.1"
}
}

View File

@@ -1,21 +1,25 @@
import program from 'commander';
import {parseArgs} from 'util';
import * as image from './image.ts';
program
.option('-c, --check', 'Check only')
.option('-v, --verbose', 'Print info')
.option('-f, --force', 'Force write even when already cropped')
.parse(process.argv);
const {values: opts, positionals: files} = parseArgs({
options: {
check: {type: 'boolean', short: 'c'},
verbose: {type: 'boolean', short: 'v'},
force: {type: 'boolean', short: 'f'},
},
allowPositionals: true,
});
let retVal = 0;
for (const file of program.args) {
for (const file of files) {
const dims = image.getDims(file);
const alreadyCropped = (dims.left === 0 || dims.right === 0) &&
(dims.top === 0 || dims.bottom === 0);
if (program.verbose) {
if (opts.verbose) {
let msg = `${file}: ${dims.width}x${dims.height}, `;
if (alreadyCropped) {
msg += `displacement horiz ${dims.left - dims.right}, vert ${dims.top - dims.bottom}`;
@@ -25,14 +29,14 @@ for (const file of program.args) {
console.log(msg);
}
if (program.check && !alreadyCropped) {
if (opts.check && !alreadyCropped) {
retVal = 1;
if (!program.verbose) {
if (!opts.verbose) {
break;
}
}
if (!program.check && (!alreadyCropped || program.force)) {
if (!opts.check && (!alreadyCropped || opts.force)) {
const trimDims = image.losslessTrim(dims);
image.crop(file, trimDims, file);
}

View File

@@ -1,7 +1,6 @@
{
"dependencies": {
"@types/node": "^24.10.7",
"commander": "^5.1.0"
"@types/node": "^24.10.7"
},
"type": "module"
}