From c1dfaaec8cdef2a90e129a00829465289f292466 Mon Sep 17 00:00:00 2001 From: Christopher Monsanto Date: Mon, 17 Aug 2026 17:32:42 -0400 Subject: [PATCH] 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 --- pnpm-lock.yaml | 18 -- tools/build/package.json | 1 - tools/deploy/index.ts | 401 ++++++++++++++++---------- tools/deploy/package.json | 1 - tools/smogdexspritesheet/index.ts | 21 +- tools/smogdexspritesheet/package.json | 1 - tools/trim/index.ts | 26 +- tools/trim/package.json | 3 +- 8 files changed, 272 insertions(+), 200 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53ad154e..744e9ad6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/tools/build/package.json b/tools/build/package.json index bc8e929b..50fa4957 100644 --- a/tools/build/package.json +++ b/tools/build/package.json @@ -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": { diff --git a/tools/deploy/index.ts b/tools/deploy/index.ts index 6788831b..945098a8 100644 --- a/tools/deploy/index.ts +++ b/tools/deploy/index.ts @@ -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) : ReturnType { - return cmd - .option('-j, --jobs ', '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 ', '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 [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 -o build, finish, and materialize to a directory (or tar file) + inspect -o build every rule touching the given source paths and copy + the outputs out + +options: + -j, --jobs 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 config file (default: build.config) + -o, --output 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 { }); } -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 { + 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 { + 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 ')) - .description('build, finish, and materialize to a directory (or tar file)') - .requiredOption('-o, --output ', '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 { + 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 ')) - .description('build every rule touching the given source paths and copy the outputs out') - .requiredOption('-o, --output ', '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(); - 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 { + 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(); + 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 { + 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); diff --git a/tools/deploy/package.json b/tools/deploy/package.json index 695f5f26..6bbc1a7b 100644 --- a/tools/deploy/package.json +++ b/tools/deploy/package.json @@ -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" diff --git a/tools/smogdexspritesheet/index.ts b/tools/smogdexspritesheet/index.ts index b804cafc..6bc24e4e 100755 --- a/tools/smogdexspritesheet/index.ts +++ b/tools/smogdexspritesheet/index.ts @@ -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 ', 'where to put image') - .option('--stylesheet ', '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 --stylesheet -- '); +} 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); diff --git a/tools/smogdexspritesheet/package.json b/tools/smogdexspritesheet/package.json index 762ebfcf..1d5cad7c 100644 --- a/tools/smogdexspritesheet/package.json +++ b/tools/smogdexspritesheet/package.json @@ -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" } } diff --git a/tools/trim/index.ts b/tools/trim/index.ts index abb5a92b..6c0f00b4 100644 --- a/tools/trim/index.ts +++ b/tools/trim/index.ts @@ -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); } diff --git a/tools/trim/package.json b/tools/trim/package.json index 7bff55db..574b99a9 100644 --- a/tools/trim/package.json +++ b/tools/trim/package.json @@ -1,7 +1,6 @@ { "dependencies": { - "@types/node": "^24.10.7", - "commander": "^5.1.0" + "@types/node": "^24.10.7" }, "type": "module" }