diff --git a/bin/cli.mjs b/bin/cli.mjs index 8100125f..297e37d4 100755 --- a/bin/cli.mjs +++ b/bin/cli.mjs @@ -5,44 +5,21 @@ import process from 'node:process'; import { Command, Option } from 'commander'; import commands from './commands/index.mjs'; -import { errorWrap } from './utils.mjs'; import { LogLevel } from '../src/logger/constants.mjs'; import logger from '../src/logger/index.mjs'; -const logLevelOption = new Option('--log-level ', 'Log level') - .choices(Object.keys(LogLevel)) - .default('info'); - const program = new Command() .name('@nodejs/doc-kit') .description('CLI tool to generate the Node.js API documentation') - .addOption(logLevelOption) + .addOption( + new Option('--log-level ', 'Log level') + .default('info') + .choices(Object.keys(LogLevel)) + ) .hook('preAction', cmd => logger.setLogLevel(cmd.opts().logLevel)); // Registering commands -commands.forEach(({ name, description, options, action }) => { - const cmd = program.command(name).description(description); - - // Add options to the command - Object.values(options).forEach(({ flags, desc, prompt }) => { - const option = new Option(flags.join(', '), desc).default( - prompt.initialValue - ); - - if (prompt.required) { - option.makeOptionMandatory(); - } - - if (prompt.type === 'multiselect') { - option.choices(prompt.options.map(({ value }) => value)); - } - - cmd.addOption(option); - }); - - // Set the action for the command - cmd.action(errorWrap(action)); -}); +commands.forEach(command => program.addCommand(command)); // Parse and execute command-line arguments program.parse(process.argv); diff --git a/bin/commands/__tests__/index.test.mjs b/bin/commands/__tests__/index.test.mjs deleted file mode 100644 index 87287899..00000000 --- a/bin/commands/__tests__/index.test.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; - -import { Option } from 'commander'; - -import commands from '../index.mjs'; - -describe('Commands', () => { - it('should have unique command names', () => { - const names = new Set(); - - commands.forEach(({ name }) => { - assert.equal(names.has(name), false, `Duplicate command name: "${name}"`); - names.add(name); - }); - }); - - it('should use correct option names', () => { - commands.forEach(({ name: cmdName, options }) => { - Object.entries(options).forEach(([optName, { flags }]) => { - const expectedName = new Option(flags.at(-1)).attributeName(); - assert.equal( - optName, - expectedName, - `In "${cmdName}" command: option "${flags}" should be named "${expectedName}", not "${optName}"` - ); - }); - }); - }); -}); diff --git a/bin/commands/generate.mjs b/bin/commands/generate.mjs index 965ede11..9f25ffaf 100644 --- a/bin/commands/generate.mjs +++ b/bin/commands/generate.mjs @@ -1,6 +1,6 @@ import { cpus } from 'node:os'; -import { resolve } from 'node:path'; +import { Command, Option } from 'commander'; import { coerce } from 'semver'; import { NODE_CHANGELOG_URL, NODE_VERSION } from '../../src/constants.mjs'; @@ -10,153 +10,82 @@ import logger from '../../src/logger/index.mjs'; import { parseTypeMap } from '../../src/parsers/json.mjs'; import { parseChangelog, parseIndex } from '../../src/parsers/markdown.mjs'; import { DEFAULT_TYPE_MAP } from '../../src/utils/parser/constants.mjs'; +import { errorWrap } from '../utils.mjs'; -const availableGenerators = Object.keys(publicGenerators); +export default new Command('generate') + .description('Generate API docs') + .addOption( + new Option( + '-i, --input ', + 'Input file patterns (glob)' + ).makeOptionMandatory() + ) + .addOption( + new Option('--ignore ', 'Ignore file patterns (glob)') + ) + .addOption(new Option('-o, --output ', 'The output directory')) + .addOption( + new Option( + '-p, --threads ', + 'Number of threads to use (minimum: 1)' + ) + .default(cpus().length) + .argParser(parseInt) + ) + .addOption( + new Option( + '--chunk-size ', + 'Number of items to process per worker thread (minimum: 1)' + ) + .default(10) + .argParser(parseInt) + ) + .addOption( + new Option('-v, --version ', 'Target Node.js version').default( + NODE_VERSION + ) + ) + .addOption( + new Option('-c, --changelog ', 'Changelog URL or path').default( + NODE_CHANGELOG_URL + ) + ) + .addOption( + new Option('--git-ref', 'Git ref URL').default( + 'https://github.com/nodejs/node/tree/HEAD' + ) + ) + .addOption( + new Option('-t, --target ', 'Target generator(s)') + .makeOptionMandatory() + .choices(Object.keys(publicGenerators)) + ) + .addOption(new Option('--index ', 'index.md URL or path')) + .addOption( + new Option('--type-map ', 'Type map URL or path').default( + DEFAULT_TYPE_MAP + ) + ) + .action( + errorWrap(async opts => { + logger.debug('Starting doc-kit', opts); -/** - * @type {import('./types').Command} - */ -export default { - description: 'Generate API docs', - name: 'generate', - options: { - input: { - flags: ['-i', '--input '], - desc: 'Input file patterns (glob)', - prompt: { - type: 'text', - message: 'Enter input glob patterns', - variadic: true, - required: true, - }, - }, - ignore: { - flags: ['--ignore [patterns...]'], - desc: 'Ignore patterns (comma-separated)', - prompt: { - type: 'text', - message: 'Enter ignore patterns', - variadic: true, - }, - }, - output: { - flags: ['-o', '--output '], - desc: 'Output directory', - prompt: { type: 'text', message: 'Enter output directory' }, - }, - threads: { - flags: ['-p', '--threads '], - desc: 'Number of threads to use (minimum: 1)', - prompt: { - type: 'text', - message: 'How many threads to allow', - initialValue: String(cpus().length), - }, - }, - chunkSize: { - flags: ['--chunk-size '], - desc: 'Number of items to process per worker thread (default: auto)', - prompt: { - type: 'text', - message: 'Items per worker thread', - initialValue: '10', - }, - }, - version: { - flags: ['-v', '--version '], - desc: 'Target Node.js version', - prompt: { - type: 'text', - message: 'Enter Node.js version', - initialValue: NODE_VERSION, - }, - }, - changelog: { - flags: ['-c', '--changelog '], - desc: 'Changelog URL or path', - prompt: { - type: 'text', - message: 'Enter changelog URL', - initialValue: NODE_CHANGELOG_URL, - }, - }, - gitRef: { - flags: ['--git-ref '], - desc: 'Git ref/commit URL', - prompt: { - type: 'text', - message: 'Enter Git ref URL', - initialValue: 'https://github.com/nodejs/node/tree/HEAD', - }, - }, - target: { - flags: ['-t', '--target [modes...]'], - desc: 'Target generator modes', - prompt: { - required: true, - type: 'multiselect', - message: 'Choose target generators', - options: availableGenerators.map(g => ({ - value: g, - label: `${publicGenerators[g].name || g} (v${publicGenerators[g].version}) - ${publicGenerators[g].description}`, - })), - }, - }, - index: { - flags: ['--index '], - desc: 'The index document, for getting the titles of various API docs', - prompt: { - message: 'Path to doc/api/index.md', - type: 'text', - }, - }, - typeMap: { - flags: ['--type-map '], - desc: 'The mapping of types to links', - prompt: { - message: 'Path to doc/api/type_map.json', - type: 'text', - initialValue: DEFAULT_TYPE_MAP, - }, - }, - }, + const { runGenerators } = createGenerator(); - /** - * @typedef {Object} Options - * @property {Array|string} input - Specifies the glob/path for input files. - * @property {Array|string} [ignore] - Specifies the glob/path for ignoring files. - * @property {Array} target - Specifies the generator target mode. - * @property {string} version - Specifies the target Node.js version. - * @property {string} changelog - Specifies the path to the Node.js CHANGELOG.md file. - * @property {string} typeMap - Specifies the path to the Node.js Type Map. - * @property {string} index - Specifies the path to the index document. - * @property {string} [gitRef] - Git ref/commit URL. - * @property {number} [threads] - Number of threads to allow. - * @property {number} [chunkSize] - Number of items to process per worker thread. - * - * Handles the action for generating API docs - * @param {Options} opts - The options to generate API docs. - * @returns {Promise} - */ - async action(opts) { - logger.debug('Starting doc-kit', opts); + logger.debug('Starting generation', { targets: opts.target }); - const { runGenerators } = createGenerator(); - - logger.debug('Starting generation', { targets: opts.target }); - - await runGenerators({ - generators: opts.target, - input: opts.input, - ignore: opts.ignore, - output: opts.output && resolve(opts.output), - version: coerce(opts.version), - releases: await parseChangelog(opts.changelog), - gitRef: opts.gitRef, - threads: Math.max(parseInt(opts.threads, 10), 1), - chunkSize: Math.max(parseInt(opts.chunkSize, 10), 1), - index: await parseIndex(opts.index), - typeMap: await parseTypeMap(opts.typeMap), - }); - }, -}; + await runGenerators({ + generators: opts.target, + input: opts.input, + ignore: opts.ignore, + output: opts.output, + version: coerce(opts.version), + releases: await parseChangelog(opts.changelog), + gitRef: opts.gitRef, + threads: Math.max(opts.threads, 1), + chunkSize: Math.max(opts.chunkSize, 1), + index: await parseIndex(opts.index), + typeMap: await parseTypeMap(opts.typeMap), + }); + }) + ); diff --git a/bin/commands/index.mjs b/bin/commands/index.mjs index ece48157..3e6d9d97 100644 --- a/bin/commands/index.mjs +++ b/bin/commands/index.mjs @@ -1,4 +1,3 @@ import generate from './generate.mjs'; -import interactive from './interactive.mjs'; -export default [generate, interactive]; +export default [generate]; diff --git a/bin/commands/interactive.mjs b/bin/commands/interactive.mjs deleted file mode 100644 index fef401cb..00000000 --- a/bin/commands/interactive.mjs +++ /dev/null @@ -1,194 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import process from 'node:process'; - -import { - intro, - outro, - select, - multiselect, - text, - confirm, - isCancel, - cancel, -} from '@clack/prompts'; - -import logger from '../../src/logger/index.mjs'; - -/** - * Validates that a string is not empty. - * @param {string} value The input string to validate. - * @returns {string|undefined} A validation message or undefined if valid. - */ -function requireValue(value) { - if (value.length === 0) { - return 'Value is required!'; - } -} - -/** - * Retrieves the prompt message based on whether the field is required or has an initial value. - * @param {Object} prompt The prompt definition. - * @param {string} prompt.message The message to display. - * @param {boolean} prompt.required Whether the input is required. - * @param {string} [prompt.initialValue] The initial value of the input field. - * @returns {string} The message to display in the prompt. - */ -function getMessage({ message, required, initialValue }) { - return required || initialValue ? message : `${message} (Optional)`; -} - -/** - * Escapes shell argument to ensure it's safe for inclusion in shell commands. - * @param {string} arg The argument to escape. - * @returns {string} The escaped argument. - */ -function escapeShellArg(arg) { - // Return the argument as is if it's alphanumeric or contains safe characters - if (/^[a-zA-Z0-9_/-]+$/.test(arg)) { - return arg; - } - // Escape single quotes in the argument - return `'${arg.replace(/'/g, `'\\''`)}'`; -} - -/** - * @type {import('../utils.mjs').Command} - */ -export default { - name: 'interactive', - description: 'Launch guided CLI wizard', - options: {}, - /** - * Main interactive function for the API Docs Tooling command line interface. - * Guides the user through a series of prompts, validates inputs, and generates a command to run. - * @returns {Promise} Resolves once the command is generated and executed. - */ - async action() { - // Import commands dynamically to avoid circular dependency - const { default: commands } = await import('./index.mjs'); - - // Filter out the interactive command itself - const availableCommands = commands.filter( - cmd => cmd.name !== 'interactive' - ); - - // Step 1: Introduction to the tool - intro('Welcome to API Docs Tooling'); - - // Step 2: Choose the action based on available command definitions - const actionOptions = availableCommands.map((cmd, i) => ({ - label: cmd.description, - value: i, - })); - - const selectedAction = await select({ - message: 'What would you like to do?', - options: actionOptions, - }); - - if (isCancel(selectedAction)) { - cancel('Cancelled.'); - process.exit(0); - } - - // Retrieve the options for the selected action - const { options, name } = availableCommands[selectedAction]; - const answers = {}; // Store answers from user prompts - - // Step 3: Collect input for each option - for (const [key, { prompt }] of Object.entries(options)) { - let response; - const promptMessage = getMessage(prompt); - - switch (prompt.type) { - case 'text': - response = await text({ - message: promptMessage, - initialValue: prompt.initialValue || '', - validate: prompt.required ? requireValue : undefined, - }); - if (response) { - // Store response; split into an array if variadic - answers[key] = prompt.variadic - ? response.split(',').map(s => s.trim()) - : response; - } - break; - - case 'confirm': - response = await confirm({ - message: promptMessage, - initialValue: prompt.initialValue, - }); - answers[key] = response; - break; - - case 'multiselect': - response = await multiselect({ - message: promptMessage, - options: prompt.options, - required: !!prompt.required, - }); - answers[key] = response; - break; - - case 'select': - response = await select({ - message: promptMessage, - options: prompt.options, - }); - answers[key] = response; - break; - } - - // Handle cancellation - if (isCancel(response)) { - cancel('Cancelled.'); - process.exit(0); - } - } - - // Step 4: Build the final command by escaping values - const cmdParts = ['npx', 'doc-kit', name]; - const executionArgs = [name]; - - for (const [key, { flags }] of Object.entries(options)) { - const value = answers[key]; - // Skip empty values - if (value == null || (Array.isArray(value) && value.length === 0)) { - continue; - } - - const flag = flags[0].split(/[\s,]+/)[0]; // Use the first flag - - // Handle different value types (boolean, array, string) - if (typeof value === 'boolean') { - if (value) { - cmdParts.push(flag); - executionArgs.push(flag); - } - } else if (Array.isArray(value)) { - for (const item of value) { - cmdParts.push(flag, escapeShellArg(item)); - executionArgs.push(flag, item); - } - } else { - cmdParts.push(flag, escapeShellArg(value)); - executionArgs.push(flag, value); - } - } - - const finalCommand = cmdParts.join(' '); - - logger.info(`\nGenerated command:\n${finalCommand}\n`); - - // Step 5: Confirm and execute the generated command - if (await confirm({ message: 'Run now?', initialValue: true })) { - spawnSync(process.execPath, [process.argv[1], ...executionArgs], { - stdio: 'inherit', - }); - } - - outro('Done!'); - }, -}; diff --git a/bin/commands/types.d.ts b/bin/commands/types.d.ts deleted file mode 100644 index e7ce0d7c..00000000 --- a/bin/commands/types.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Represents a command-line option for the CLI. - */ -export interface Option { - flags: string[]; - desc: string; - prompt?: { - type: 'text' | 'confirm' | 'select' | 'multiselect'; - message: string; - variadic?: boolean; - required?: boolean; - initialValue?: boolean; - options?: { label: string; value: string }[]; - }; -} - -/** - * Represents a command-line subcommand - */ -export interface Command { - options: { [key: string]: Option }; - name: string; - description: string; - action: Function; -} diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 52663243..f82aa911 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -7,7 +7,6 @@ "name": "@nodejs/doc-kit", "dependencies": { "@actions/core": "^3.0.0", - "@clack/prompts": "^1.0.0", "@heroicons/react": "^2.2.0", "@node-core/rehype-shiki": "1.3.0", "@node-core/ui-components": "1.5.8", @@ -128,27 +127,6 @@ "node": ">=18" } }, - "node_modules/@clack/core": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.0.tgz", - "integrity": "sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ==", - "license": "MIT", - "dependencies": { - "picocolors": "^1.0.0", - "sisteransi": "^1.0.5" - } - }, - "node_modules/@clack/prompts": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.0.tgz", - "integrity": "sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A==", - "license": "MIT", - "dependencies": { - "@clack/core": "1.0.0", - "picocolors": "^1.0.0", - "sisteransi": "^1.0.5" - } - }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", @@ -981,7 +959,6 @@ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -1081,7 +1058,6 @@ "resolved": "https://registry.npmjs.org/@orama/cuid2/-/cuid2-2.2.3.tgz", "integrity": "sha512-Lcak3chblMejdlSHgYU2lS2cdOhDpU6vkfIJH4m+YKvqQyLqs1bB8+w6NT1MG5bO12NUK2GFc34Mn2xshMIQ1g==", "license": "MIT", - "peer": true, "dependencies": { "@noble/hashes": "^1.1.5" } @@ -1099,8 +1075,7 @@ "version": "0.0.5", "resolved": "https://registry.npmjs.org/@orama/oramacore-events-parser/-/oramacore-events-parser-0.0.5.tgz", "integrity": "sha512-yAuSwog+HQBAXgZ60TNKEwu04y81/09mpbYBCmz1RCxnr4ObNY2JnPZI7HmALbjAhLJ8t5p+wc2JHRK93ubO4w==", - "license": "AGPL-3.0", - "peer": true + "license": "AGPL-3.0" }, "node_modules/@orama/stopwords": { "version": "3.1.16", @@ -3531,6 +3506,7 @@ "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.54.0", @@ -3920,6 +3896,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4461,8 +4438,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", @@ -4665,6 +4641,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7582,6 +7559,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7631,6 +7609,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-11.0.0-beta.0.tgz", "integrity": "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -8133,8 +8112,7 @@ "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "7.7.3", @@ -8243,12 +8221,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "license": "MIT" - }, "node_modules/slice-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", @@ -8728,6 +8700,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9082,6 +9055,7 @@ "integrity": "sha512-VUyWiTNQD7itdiMuJy+EuLEErLj3uwX/EpHQF8EOf33Dq3Ju6VW1GXm+swk6+1h7a49uv9fKZ+dft9jU7esdLA==", "dev": true, "hasInstallScript": true, + "peer": true, "dependencies": { "napi-postinstall": "^0.2.4" }, @@ -9512,7 +9486,6 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", "license": "ISC", - "peer": true, "peerDependencies": { "zod": "^3.24.1" } diff --git a/package.json b/package.json index 5a5fc5c8..cdfa0aa0 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ }, "dependencies": { "@actions/core": "^3.0.0", - "@clack/prompts": "^1.0.0", "@heroicons/react": "^2.2.0", "@node-core/rehype-shiki": "1.3.0", "@node-core/ui-components": "1.5.8",