From 996830fb1c2b07efe58013758f2763174c228dc3 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 16 Sep 2026 17:57:08 -0700 Subject: [PATCH 1/2] fix(migrate): refuse unrecognized argv instead of applying every pending migration The operator migration CLI ignored any token it did not understand and fell through to its no-argument meaning, which is apply-everything, so migrate.js --help applied every pending manual migration to the target database. Unknown flags, bare filenames and an empty --file= now exit 2 with a usage block that spells out the blanket and scoped modes, and --help/-h print it and exit 0 before anything reads the DECODER_DB_* environment. The no-argument and --file behaviours are unchanged. Ports the same fix already shipped for the indexer's copy of this CLI. --- src/migrate.js | 65 +++++++++++++++++++++++++++++---- test/unit/migrate.test.js | 75 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 129 insertions(+), 11 deletions(-) diff --git a/src/migrate.js b/src/migrate.js index 90f469f..441f960 100644 --- a/src/migrate.js +++ b/src/migrate.js @@ -32,6 +32,11 @@ * blanket `node src/migrate.js` would). Repeat the flag (or comma-separate) to * target several files; an unknown name fails loudly instead of applying nothing. * + * Any argument this CLI does not recognize is REFUSED with the usage text and + * exit 2. It is never ignored: a no-argument run means APPLY EVERYTHING, so an + * ignored token (a typo, `--dry-run`, `--help`) would silently apply every + * pending manual migration the operator was only asking about. + * * Reads DECODER_DB_* from the service environment (.env). * ********************************************************************/ @@ -41,9 +46,36 @@ dotenv.config(); const Database = require('./db.js'); +// Spelled out for an operator reading it mid-incident: the difference between a +// blanket run and a scoped one is the whole risk of this command, so each mode +// says what it applies rather than naming a flag. +const USAGE = [ + 'Usage: node src/migrate.js [--file ...]', + '', + ' (no arguments) APPLY EVERYTHING. Runs every pending migration, auto', + ' AND manual, against the database in DECODER_DB_NAME.', + ' Manual migrations are the destructive / backfill ones.', + ' --file, -f APPLY ONE. Runs only the named migration file(s).', + ' Repeat the flag or comma-separate to name several.', + ' --help, -h Print this usage and exit 0. Touches no database.', + '', + 'Reads DECODER_DB_HOST / DECODER_DB_PORT / DECODER_DB_NAME / DECODER_DB_USER /', + 'DECODER_DB_PASS from the service environment (.env). Any other argument is', + 'refused with exit 2, because ignoring one would mean APPLY EVERYTHING.', +].join('\n'); + +// Print the usage and exit. Returns null so main() bails even where process.exit +// is stubbed (tests), instead of falling through to an apply-everything run. +function refuse(message){ + console.error('migrate: ' + message); + console.error(USAGE); + process.exit(2); + return null; +} + // Parse `--file ` / `--file=` / `-f ` occurrences into a list of -// migration filenames to scope the run to. Values may be comma-separated. Returns [] -// when no targeting flag is present (the default apply-everything behavior). +// migration filenames. Values may be comma-separated. [] means no targeting flag +// (the apply-everything default); null means refused or served, so main() must stop. function parseFileTargets(argv){ const targets = []; const push = (v) => { @@ -54,23 +86,44 @@ function parseFileTargets(argv){ }; for(let i = 0; i < argv.length; i++){ const a = argv[i]; + // Usage requests are served before anything else reads the environment, so + // asking what this command does never needs a loaded .env and never runs. + if(a === '--help' || a === '-h'){ + console.log(USAGE); + process.exit(0); + return null; // (unreachable when exit is real; keeps a stubbed exit from applying) + } + const named = targets.length; if(a === '--file' || a === '-f'){ const v = argv[i + 1]; if(v === undefined || v.startsWith('-')){ - console.error('migrate: ' + a + ' requires a migration filename argument.'); - process.exit(2); - return targets; // (unreachable when exit is real; guards stubbed-exit tests) + return refuse(a + ' requires a migration filename argument.'); } push(v); i++; } else if(a.startsWith('--file=')){ push(a.slice('--file='.length)); + } else { + // Refuse anything else, including a bare filename: only --file scopes a + // run, and guessing here is what turns a typo into APPLY EVERYTHING. + return refuse('unrecognized argument "' + a + '".'); + } + // A targeting flag that named nothing (`--file=`, `--file ,`) would leave the + // scope empty, and an empty scope means APPLY EVERYTHING: the opposite of + // what the operator asked for. Refuse instead of widening the run. + if(targets.length === named){ + return refuse(a + ' names no migration file.'); } } return targets; } async function main(){ + // Argv is settled first so `--help` answers without a loaded .env, and so a + // refused argument never reaches the database checks below. + const only = parseFileTargets(process.argv.slice(2)); + if(only === null) return; + const host = process.env.DECODER_DB_HOST; const port = process.env.DECODER_DB_PORT; const name = process.env.DECODER_DB_NAME; @@ -81,8 +134,6 @@ async function main(){ process.exit(2); } - const only = parseFileTargets(process.argv.slice(2)); - const db = new Database(host, port, name, user, pass); try { diff --git a/test/unit/migrate.test.js b/test/unit/migrate.test.js index 6b8ec50..927d473 100644 --- a/test/unit/migrate.test.js +++ b/test/unit/migrate.test.js @@ -221,17 +221,19 @@ describe('migrate.js operator CLI @regression', function () { beforeEach(prepareMigrateTest); afterEach(restoreMigrateTest); - it('--file with no value exits 2 before building a DB handle @regression', async function () { + it('--file with no value exits 2 before building a DB handle @regression', function () { process.env.DECODER_DB_HOST = 'db.test'; process.env.DECODER_DB_NAME = 'decoder_test'; process.env.DECODER_DB_USER = 'tester'; process.argv = ['node', 'migrate.js', '--file']; const fake = makeFakeDb({ runMigrations: async () => ({ applied: [], pending: [] }) }); - // process.exit is stubbed, so main() continues past the guard; assert the - // exit(2) signal and the actionable error fired before any migration ran. + // process.exit is stubbed, so the exit(2) does not end the process; main() + // must still bail rather than fall through to a blanket run. The refusal + // precedes main()'s first await, so it has run by the time require returns. loadMigrateWith(fake.FakeDatabase); - await fake.done; assert.strictEqual(exitStub.calledWith(2), true, 'expected process.exit(2) on a valueless --file'); + assert.strictEqual(fake.runArgs, null, 'a refused argv must apply no migrations'); + assert.strictEqual(fake.poolEnded, false, 'a refused argv must not open a DB handle'); assert.match(consoleErrStub.getCalls().map((c) => c.args[0]).join('\n'), /--file requires a migration filename argument/); }); @@ -247,3 +249,68 @@ describe('migrate.js operator CLI @regression', function () { 'a blanket run must NOT set opts.only'); }); }); + +// Unrecognized argv. An ignored token falls through to the no-argument meaning, +// which is apply-everything, so `migrate.js --help` would apply every pending +// manual migration. Each case pins the refusal by what it APPLIES, not what it says. + +describe('migrate.js operator CLI argv refusal @regression', function () { + beforeEach(prepareMigrateTest); + afterEach(restoreMigrateTest); + + // The refusal (and --help) run synchronously ahead of main()'s first await, so + // a case that must prove nothing ran asserts right after the require rather + // than awaiting a pool.end() that a correct CLI never reaches. + function loadWithArgv(args) { + process.argv = ['node', 'migrate.js', ...args]; + const fake = makeFakeDb({ runMigrations: async () => ({ applied: [], pending: [] }) }); + loadMigrateWith(fake.FakeDatabase); + return fake; + } + + it('an unknown flag applies nothing and exits 2', function () { + const fake = loadWithArgv(['--dry-run']); + assert.strictEqual(fake.runArgs, null, 'an unknown flag must not run migrations'); + assert.strictEqual(fake.poolEnded, false, 'an unknown flag must not open a DB handle'); + assert.strictEqual(exitStub.calledWith(2), true, 'expected process.exit(2)'); + }); + + it('a bare positional applies nothing and exits 2 (it is not a --file value)', function () { + const fake = loadWithArgv(['2026-06-13-dispensers-expiration-bigint.sql']); + assert.strictEqual(fake.runArgs, null, 'a bare filename must not become a blanket run'); + assert.strictEqual(exitStub.calledWith(2), true, 'expected process.exit(2)'); + }); + + it('an empty --file= value applies nothing rather than widening to everything', function () { + const fake = loadWithArgv(['--file=']); + assert.strictEqual(fake.runArgs, null, 'an empty scope must not mean apply-everything'); + assert.strictEqual(exitStub.calledWith(2), true, 'expected process.exit(2)'); + }); + + it('--help and -h apply nothing and exit 0, with no DECODER_DB_* loaded', function () { + for (const flag of ['--help', '-h']) { + const fake = loadWithArgv([flag]); + assert.strictEqual(fake.runArgs, null, flag + ' must not run migrations'); + assert.strictEqual(fake.poolEnded, false, flag + ' must not open a DB handle'); + assert.strictEqual(exitStub.calledWith(0), true, flag + ' must exit 0'); + assert.strictEqual(exitStub.calledWith(2), false, flag + ' is not an error'); + exitStub.resetHistory(); + } + }); + + it('the refusal prints both modes so an operator can tell them apart', function () { + loadWithArgv(['--dry-run']); + const printed = consoleErrStub.getCalls().map((c) => c.args[0]).join('\n'); + assert.match(printed, /APPLY EVERYTHING/, 'the usage must name the blanket mode'); + assert.match(printed, /APPLY ONE/, 'the usage must name the scoped mode'); + assert.match(printed, /--file/, 'the usage must show the flag that scopes a run'); + }); + + it('--help prints both modes and starts no run', function () { + loadWithArgv(['--help']); + const printed = consoleLogStub.getCalls().map((c) => c.args[0]).join('\n'); + assert.match(printed, /APPLY EVERYTHING/); + assert.match(printed, /APPLY ONE/); + assert.ok(!/applying pending migrations/.test(printed), '--help must not start a run'); + }); +}); From 6f871f7fca607e8dcf3034aad6f7e79b7e5614e5 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 13:58:52 -0700 Subject: [PATCH 2/2] chore(release): bump to 0.20.0, refresh the README badge and record the changelog --- CHANGELOG.md | 5 +++++ README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64619bf..2394cd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.20.0] - 2026-09-17 + +### Fixed +- The migration CLI refuses unrecognized arguments instead of applying every pending migration. + ## [0.19.0] - 2026-09-16 ### Added diff --git a/README.md b/README.md index 71f2f54..ebbff76 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # XChain Platform Decoder

- Version + Version Tests Node License diff --git a/package-lock.json b/package-lock.json index fc7b856..11bd988 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-decoder", - "version": "0.19.0", + "version": "0.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-decoder", - "version": "0.19.0", + "version": "0.20.0", "license": "AGPL-3.0-or-later", "dependencies": { "axios": "^1.18.1", diff --git a/package.json b/package.json index 856c9c5..617baef 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xchain-decoder", "description": "xchain-decoder decodes XChain platform transactions from a given blockchain and populates a database with the decoded data.", - "version": "0.19.0", + "version": "0.20.0", "license": "AGPL-3.0-or-later", "repository": { "type": "git",