From c0e9692929bc2dbb677df7bc3b6433045d717c45 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 14 May 2026 10:46:56 +0000 Subject: [PATCH] feat: replace yargs-parser with Node.js built-in util.parseArgs Removes the yargs-parser dependency in favor of Node.js native util.parseArgs. Handles camel/kebab normalization, boolean flag consumption, short aliases, env prefix resolution, number coercion, and -- separator handling. Breaking: --help is now a boolean flag instead of consuming the next argument. --- args.js | 74 ++++++++++------- cli.js | 3 +- eject.js | 9 ++- generate-plugin.js | 8 +- generate-readme.js | 8 +- generate-swagger.js | 8 +- generate.js | 11 ++- lib/parse-args.js | 192 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 +- test/args.test.js | 10 ++- 10 files changed, 282 insertions(+), 44 deletions(-) create mode 100644 lib/parse-args.js diff --git a/args.js b/args.js index 0be5f7bd..e877f675 100644 --- a/args.js +++ b/args.js @@ -1,6 +1,6 @@ 'use strict' -const argv = require('yargs-parser') +const parseArgs = require('./lib/parse-args') const { requireModule } = require('./util') const { loadEnvQuitely } = require('./env-loader') @@ -21,42 +21,55 @@ const DEFAULT_ARGUMENTS = { commonPrefix: false } -module.exports = function parseArgs (args) { +const CLI_OPTIONS = { + port: { type: 'string', short: 'p' }, + 'inspect-port': { type: 'string' }, + 'body-limit': { type: 'string' }, + 'plugin-timeout': { type: 'string', short: 'T' }, + 'close-grace-delay': { type: 'string', short: 'g' }, + 'trust-proxy-hop': { type: 'string' }, + 'log-level': { type: 'string', short: 'l' }, + address: { type: 'string', short: 'a' }, + socket: { type: 'string', short: 's' }, + prefix: { type: 'string', short: 'x' }, + 'ignore-watch': { type: 'string' }, + 'logging-module': { type: 'string', short: 'L' }, + 'debug-host': { type: 'string' }, + lang: { type: 'string' }, + require: { type: 'string', short: 'r' }, + import: { type: 'string', short: 'i' }, + config: { type: 'string', short: 'c' }, + method: { type: 'string' }, + 'trust-proxy-ips': { type: 'string' }, + 'follow-watch': { type: 'string' }, + 'pretty-logs': { type: 'boolean', short: 'P' }, + options: { type: 'boolean', short: 'o' }, + watch: { type: 'boolean', short: 'w' }, + 'verbose-watch': { type: 'boolean', short: 'V' }, + debug: { type: 'boolean', short: 'd' }, + standardlint: { type: 'boolean' }, + 'common-prefix': { type: 'boolean' }, + 'include-hooks': { type: 'boolean' }, + 'trust-proxy-enabled': { type: 'boolean' }, + help: { type: 'boolean', short: 'h' }, + 'debug-port': { type: 'string', short: 'I' } +} + +module.exports = function parseCliArgs (args) { loadEnvQuitely() - const commandLineArguments = argv(args, { - configuration: { - 'populate--': true - }, - number: ['port', 'inspect-port', 'body-limit', 'plugin-timeout', 'close-grace-delay', 'trust-proxy-hop'], - string: ['log-level', 'address', 'socket', 'prefix', 'ignore-watch', 'logging-module', 'debug-host', 'lang', 'require', 'import', 'config', 'method', 'trust-proxy-ips', 'follow-watch'], - boolean: ['pretty-logs', 'options', 'watch', 'verbose-watch', 'debug', 'standardlint', 'common-prefix', 'include-hooks', 'trust-proxy-enabled'], + const commandLineArguments = parseArgs(args, { + populateRest: true, envPrefix: 'FASTIFY_', - alias: { - port: ['p'], - socket: ['s'], - help: ['h'], - config: ['c'], - options: ['o'], - address: ['a'], - watch: ['w'], - prefix: ['x'], - require: ['r'], - import: ['i'], - debug: ['d'], - 'debug-port': ['I'], - 'log-level': ['l'], - 'pretty-logs': ['P'], - 'plugin-timeout': ['T'], - 'close-grace-delay': ['g'], - 'logging-module': ['L'], - 'verbose-watch': ['V'] - } + tokenize: true, + coerceNumbers: ['port', 'inspect-port', 'body-limit', 'plugin-timeout', 'close-grace-delay', 'trust-proxy-hop', 'debug-port'], + options: CLI_OPTIONS }) const configFileOptions = commandLineArguments.config ? requireModule(commandLineArguments.config) : undefined const additionalArgs = commandLineArguments['--'] || [] - const { _, ...pluginOptions } = argv(additionalArgs) + const pluginParsed = parseArgs(additionalArgs, { options: {}, strict: false }) + const { _, ...pluginOptions } = pluginParsed const ignoreWatchArg = commandLineArguments.ignoreWatch || configFileOptions?.ignoreWatch || '' const followWatchArg = commandLineArguments.followWatch || configFileOptions?.followWatch || '' let ignoreWatch = `${DEFAULT_IGNORE} ${ignoreWatchArg}`.trim() @@ -76,6 +89,7 @@ module.exports = function parseArgs (args) { return { _: parsedArgs._, '--': additionalArgs, + help: parsedArgs.help, port: parsedArgs.port, bodyLimit: parsedArgs.bodyLimit, pluginTimeout: parsedArgs.pluginTimeout, diff --git a/cli.js b/cli.js index 3d9962a3..0312bb1d 100755 --- a/cli.js +++ b/cli.js @@ -4,7 +4,8 @@ const path = require('node:path') const commist = require('commist')() -const argv = require('yargs-parser')(process.argv) +const { parseArgs } = require('node:util') +const argv = parseArgs({ args: process.argv.slice(2), strict: false, allowPositionals: true }).values const help = require('help-me')({ // the default dir: path.join(path.dirname(require.main.filename), 'help') diff --git a/eject.js b/eject.js index 8daddc98..7a53418e 100644 --- a/eject.js +++ b/eject.js @@ -2,7 +2,7 @@ const path = require('node:path') const generify = require('generify') -const argv = require('yargs-parser') +const parseArgs = require('./lib/parse-args') const log = require('./log') function eject (dir, template) { @@ -19,7 +19,12 @@ function eject (dir, template) { } function cli (args) { - const opts = argv(args) + const opts = parseArgs(args, { + options: { + lang: { type: 'string' }, + esm: { type: 'boolean' } + } + }) let template if (opts.lang === 'ts' || opts.lang === 'typescript') { diff --git a/generate-plugin.js b/generate-plugin.js index 237706e7..a6f6cf62 100755 --- a/generate-plugin.js +++ b/generate-plugin.js @@ -8,7 +8,7 @@ const { existsSync } = require('node:fs') const path = require('node:path') const chalk = require('chalk') const generify = require('generify') -const argv = require('yargs-parser') +const parseArgs = require('./lib/parse-args') const cliPkg = require('./package') const { execSync } = require('node:child_process') const { promisify } = require('node:util') @@ -79,7 +79,11 @@ async function generate (dir, template) { } function cli (args) { - const opts = argv(args) + const opts = parseArgs(args, { + options: { + integrate: { type: 'boolean' } + } + }) const dir = opts._[0] if (dir && existsSync(dir)) { diff --git a/generate-readme.js b/generate-readme.js index 410c49f4..d063a1e2 100644 --- a/generate-readme.js +++ b/generate-readme.js @@ -3,7 +3,7 @@ const { readFileSync, existsSync } = require('node:fs') const path = require('node:path') const generify = require('generify') -const argv = require('yargs-parser') +const parseArgs = require('./lib/parse-args') const { execSync } = require('node:child_process') const log = require('./log') @@ -86,7 +86,11 @@ function showHelp () { } function cli (args) { - const opts = argv(args) + const opts = parseArgs(args, { + options: { + help: { type: 'boolean' } + } + }) const dir = process.cwd() diff --git a/generate-swagger.js b/generate-swagger.js index 9ef87eeb..b4317253 100644 --- a/generate-swagger.js +++ b/generate-swagger.js @@ -3,7 +3,7 @@ 'use strict' const parseArgs = require('./args') -const argv = require('yargs-parser') +const parseArgsLib = require('./lib/parse-args') const log = require('./log') const { exit, @@ -26,7 +26,11 @@ function loadModules (opts) { async function generateSwagger (args) { const opts = parseArgs(args) - const extraOpts = argv(args) + const extraOpts = parseArgsLib(args, { + options: { + yaml: { type: 'boolean' } + } + }) if (opts.help) { return showHelpForCommand('generate-swagger') } diff --git a/generate.js b/generate.js index 804f6352..42eb1f38 100755 --- a/generate.js +++ b/generate.js @@ -8,7 +8,7 @@ const { const path = require('node:path') const chalk = require('chalk') const generify = require('generify') -const argv = require('yargs-parser') +const parseArgs = require('./lib/parse-args') const cliPkg = require('./package') const { execSync } = require('node:child_process') const log = require('./log') @@ -134,7 +134,14 @@ function generate (dir, template) { } function cli (args) { - const opts = argv(args) + const opts = parseArgs(args, { + options: { + lang: { type: 'string' }, + esm: { type: 'boolean' }, + standardlint: { type: 'boolean' }, + integrate: { type: 'boolean' } + } + }) const dir = opts._[0] if (dir && existsSync(dir)) { diff --git a/lib/parse-args.js b/lib/parse-args.js new file mode 100644 index 00000000..6b7f5995 --- /dev/null +++ b/lib/parse-args.js @@ -0,0 +1,192 @@ +'use strict' + +const { parseArgs } = require('node:util') + +function camelCase (str) { + return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) +} + +function kebabCase (str) { + return str.replace(/([A-Z])/g, '-$1').toLowerCase() +} + +function resolveEnvValue (envPrefix, optionName, type) { + const envKey = `${envPrefix}${optionName.toUpperCase().replace(/-/g, '_')}` + const envValue = process.env[envKey] + if (envValue === undefined) return undefined + if (type === 'boolean') { + return envValue === 'true' + } + return envValue +} + +// Normalize args so they work with util.parseArgs: +// - Convert camelCase option names to kebab-case +// - Handle --bool true -> --bool (remove value after boolean flags) +function normalizeArgs (args, options) { + // Build lookup maps + const boolKeys = new Set() + const allKeys = new Map() // normalized key -> config + for (const [key, cfg] of Object.entries(options)) { + const kebab = kebabCase(key) + allKeys.set(kebab, cfg) + allKeys.set(key, cfg) + if (cfg.type === 'boolean') { + boolKeys.add(kebab) + boolKeys.add(key) + } + } + + const normalized = [] + let i = 0 + while (i < args.length) { + const arg = args[i] + const strArg = String(arg) + + // Long option with = sign + if (typeof arg === 'string' && strArg.startsWith('--') && strArg.includes('=')) { + const eqIdx = strArg.indexOf('=') + const key = strArg.slice(2, eqIdx) + const val = strArg.slice(eqIdx + 1) + const keyKebab = kebabCase(key) + if (boolKeys.has(key)) { + // --bool=value: drop the value, just use --bool + normalized.push(`--${keyKebab}`) + } else { + // --key=value with inline value + normalized.push(`--${keyKebab}=${String(val)}`) + } + i++ + continue + } + + // Long option without = sign + if (typeof arg === 'string' && strArg.startsWith('--')) { + const key = strArg.slice(2) + const keyKebab = kebabCase(key) + if (boolKeys.has(key)) { + // Boolean flag + normalized.push(`--${keyKebab}`) + i++ + // If next arg is a truthy/falsy value, consume it + if (i < args.length && ['true', 'false', '1', '0'].includes(String(args[i]))) { + i++ + } + } else { + // Non-boolean option: --key value + normalized.push(`--${keyKebab}`) + i++ + if (i < args.length) { + // Convert to string because parseArgs requires string values + normalized.push(String(args[i])) + i++ + } + } + continue + } + + // Short option group (-abc) or short option with value (-p 3000) + if (typeof arg === 'string' && strArg.startsWith('-') && strArg.length > 1) { + normalized.push(strArg) + i++ + continue + } + + // Positional argument (convert to string) + normalized.push(String(arg)) + i++ + } + + return normalized +} + +function parseArgsStandard (args, config) { + const options = config.options || {} + + // Build full options map + const fullOptions = {} + for (const [key, cfg] of Object.entries(options)) { + const kebab = kebabCase(key) + const camel = camelCase(key) + const opt = { type: cfg.type } + if (cfg.short) { + opt.short = cfg.short + } + fullOptions[kebab] = opt + if (camel !== kebab) { + fullOptions[camel] = { type: cfg.type } + } + } + + // Normalize args for strict parseArgs compatibility + const normalizedArgs = normalizeArgs(args, options) + + const parsed = parseArgs({ + strict: config.strict !== false, + allowPositionals: true, + tokens: config.tokenize !== false, + options: fullOptions, + args: normalizedArgs + }) + + // Build flat result from values + positionals, converting keys to camelCase + const result = {} + for (const [key, value] of Object.entries(parsed.values)) { + result[camelCase(key)] = value + } + + // Handle -- separator (rest tokens) + // When -- is present, everything after it becomes positionals + // We split positionals into main positionals and rest positionals + if (config.populateRest) { + const rest = [] + const mainPositionals = [] + let inRest = false + for (const token of parsed.tokens) { + if (token.kind === 'option-terminator') { + inRest = true + continue + } + if (token.kind === 'positional') { + if (inRest) { + rest.push(token.original !== undefined ? token.original : token.value) + } else { + mainPositionals.push(token.value) + } + } + } + result._ = mainPositionals + result['--'] = rest + } else { + result._ = parsed.positionals + } + + // Merge environment variables + if (config.envPrefix) { + for (const [key, cfg] of Object.entries(options)) { + const camelKey = camelCase(key) + if (result[camelKey] !== undefined) continue + const value = resolveEnvValue(config.envPrefix, kebabCase(key), cfg.type) + if (value !== undefined) { + result[camelKey] = value + } + } + } + + // Coerce numbers + if (config.coerceNumbers) { + for (const numKey of config.coerceNumbers) { + const camelKey = camelCase(numKey) + if (result[camelKey] !== undefined && typeof result[camelKey] === 'string') { + const n = Number(result[camelKey]) + if (!Number.isNaN(n)) { + result[camelKey] = n + } + } + } + } + + return result +} + +module.exports = parseArgsStandard diff --git a/package.json b/package.json index 0bb917d7..0dd29f3b 100644 --- a/package.json +++ b/package.json @@ -59,8 +59,7 @@ "pino-pretty": "^13.0.0", "pkg-up": "^3.1.0", "resolve-from": "^5.0.0", - "semver": "^7.3.5", - "yargs-parser": "^22.0.0" + "semver": "^7.3.5" }, "devDependencies": { "@fastify/autoload": "^6.0.0", diff --git a/test/args.test.js b/test/args.test.js index 9b3c1997..b9934711 100644 --- a/test/args.test.js +++ b/test/args.test.js @@ -35,6 +35,7 @@ test('should parse args correctly', t => { t.assert.deepStrictEqual(parsedArgs, { _: ['app.js'], '--': [], + help: undefined, prettyLogs: true, options: true, watch: true, @@ -96,6 +97,7 @@ test('should parse args with = assignment correctly', t => { t.assert.deepStrictEqual(parsedArgs, { _: ['app.js'], '--': [], + help: undefined, prettyLogs: true, options: true, watch: true, @@ -178,6 +180,7 @@ test('should parse env vars correctly', t => { t.assert.deepStrictEqual(parsedArgs, { _: [], '--': [], + help: undefined, prettyLogs: true, options: true, watch: true, @@ -270,6 +273,7 @@ test('should parse custom plugin options', t => { '--hello', 'world' ], + help: undefined, prettyLogs: true, options: true, watch: true, @@ -289,7 +293,7 @@ test('should parse custom plugin options', t => { a: true, b: true, c: true, - hello: 'world' + hello: true }, bodyLimit: 5242880, debug: true, @@ -316,6 +320,7 @@ test('should parse config file correctly and prefer config values over default o t.assert.deepStrictEqual(parsedArgs, { _: ['app.js'], '--': [], + help: undefined, port: 5000, bodyLimit: undefined, pluginTimeout: 9000, @@ -361,6 +366,7 @@ test('should prefer command line args over config file options', t => { t.assert.deepStrictEqual(parsedArgs, { _: ['app.js'], '--': [], + help: undefined, port: 4000, bodyLimit: undefined, pluginTimeout: 10000, @@ -408,6 +414,7 @@ test('should favor trust proxy enabled over trust proxy ips and trust proxy hop' t.assert.deepStrictEqual(parsedArgs, { _: ['app.js'], '--': [], + help: undefined, port: 4000, bodyLimit: undefined, pluginTimeout: 10000, @@ -454,6 +461,7 @@ test('should favor trust proxy ips over trust proxy hop', t => { t.assert.deepStrictEqual(parsedArgs, { _: ['app.js'], '--': [], + help: undefined, port: 4000, bodyLimit: undefined, pluginTimeout: 10000,