UNPKG

build-a-npm

Version:

A tool to create and publish Node packages with automatic version bumping and GitHub integration.

604 lines (590 loc) 20.7 kB
const { getInquirer, fs, logger, fetchNpmPackage, createGitHubRepo, } = require('./utils'); const { Octokit } = require('@octokit/rest'); async function promptPackageDetails(config = {}) { const inquirer = await getInquirer(); if (!inquirer || !inquirer.prompt) { logger.error('Failed to initialize inquirer module'); throw new Error( 'Failed to initialize inquirer module. Please ensure @inquirer/prompts is installed correctly.' ); } logger.debug('Config passed to promptPackageDetails:', config); const dependencyList = []; const questions = [ { type: 'list', name: 'useNewDir', message: 'Create a new directory for your project?', choices: ['Yes, same as my Package Name', 'Yes, a Custom Name', 'No'], default: config.useNewDir || 'Yes, same as my Package Name', }, { type: 'input', name: 'projectDir', message: 'Enter the new directory name:', default: config.projectDir, when: answers => answers.useNewDir === 'Yes, a Custom Name', validate: input => { if (input.trim() === '') return 'Directory name is required'; if (fs.existsSync(input)) return 'Directory already exists'; if (!/^[a-z0-9-._]+$/.test(input)) return 'Directory name must contain only lowercase letters, numbers, hyphens, periods, or underscores'; return true; }, }, { type: 'list', name: 'useMonorepo', message: 'Is this package part of a monorepo?', choices: ['Yes', 'No'], default: config.useMonorepo ? 'Yes' : 'No', }, { type: 'input', name: 'monorepoRoot', message: 'Enter the monorepo root directory (relative to current directory):', default: config.monorepoRoot || '.', when: answers => answers.useMonorepo === 'Yes', validate: input => { if (input.trim() === '') return 'Monorepo root directory is required'; return true; }, }, { type: 'list', name: 'packageManager', message: 'Choose a package manager for the monorepo:', choices: ['npm', 'pnpm', 'yarn'], default: config.packageManager || 'npm', when: answers => answers.useMonorepo === 'Yes', }, { type: 'list', name: 'publishTo', message: 'Where do you want to publish your package?', choices: ['npmjs', 'GitHub Packages', 'Both'], default: config.publishTo || 'Both', }, { type: 'list', name: 'access', message: 'Package access level:', choices: ['public', 'private'], default: config.access || 'public', }, { type: 'list', name: 'createGitHubWorkflow', message: 'Create a GitHub Actions workflow file (publish.yml)?', choices: ['Yes', 'No'], default: config.createGitHubWorkflow !== undefined ? config.createGitHubWorkflow ? 'Yes' : 'No' : 'Yes', when: answers => ['GitHub Packages', 'Both'].includes(answers.publishTo), }, { type: 'list', name: 'createGitHubRepo', message: 'Create a GitHub repository automatically?', choices: ['Yes', 'No'], default: config.createGitHubRepo ? 'Yes' : 'No', when: answers => ['GitHub Packages', 'Both'].includes(answers.publishTo), }, { type: 'list', name: 'createGitHubPages', message: 'Do you want to Publish a Documentation on GitHub Pages?', choices: ['Yes', 'No'], default: config.createGitHubPages ? 'Yes' : 'No', }, { type: 'list', name: 'ciProvider', message: 'Choose a CI/CD provider for workflows:', choices: ['GitHub Actions', 'GitLab CI', 'CircleCI', 'None'], default: config.ciProvider || 'GitHub Actions', }, { type: 'input', name: 'githubUsername', message: 'Enter your GitHub username:', default: config.githubUsername, when: answers => ['GitHub Packages', 'Both'].includes(answers.publishTo) || answers.createGitHubPages === 'Yes', validate: input => input.trim() !== '' ? true : 'GitHub username is required', filter: input => input.toLowerCase(), }, { type: 'input', name: 'name', message: 'Enter your Package Name', default: config && config.name ? config.name : undefined, validate: async (input, answers = {}) => { logger.debug('Validating package name:', { input, answers }); if (input.trim() === '') return 'Package name is required'; if (input.length > 214) return 'Package name must be 214 characters or less'; const reservedNames = ['node', 'npm', 'http', 'https', 'core', 'js']; const namePart = input.startsWith('@') ? input.split('/')[1] : input; if (reservedNames.includes(namePart)) return `Package name "${namePart}" is reserved by npm`; if ( (answers.publishTo === 'npmjs' || answers.publishTo === 'Both') && input.startsWith('@') ) { if ( !/^@[a-z0-9-][a-z0-9-]{0,49}\/[a-z0-9][a-z0-9-]{0,49}$/.test(input) ) return 'Scoped package must be in the format @scope/package, with scope and package name containing only lowercase letters, numbers, or hyphens (e.g., @myscope/my-package)'; if ( answers.publishTo === 'Both' && answers.githubUsername && input.split('/')[0] !== `@${answers.githubUsername}` ) return `For GitHub Packages, scoped package must start with @${answers.githubUsername} (e.g., @${answers.githubUsername}/my-package)`; const exists = await fetchNpmPackage(input); if (exists) { logger.warn(`Package ${input} already exists on npm`); return `Warning: Package ${input} already exists on npm. Ensure you have permission to publish it.`; } return true; } if (!/^[a-z0-9][a-z0-9-]{0,49}$/.test(input)) return 'Package name must start with a lowercase letter or number, followed by lowercase letters, numbers, or hyphens (e.g., my-package)'; if (answers.publishTo === 'GitHub Packages' && answers.githubUsername) return `GitHub Packages requires scoped packages in the format @${answers.githubUsername}/package (e.g., @${answers.githubUsername}/my-package)`; const exists = await fetchNpmPackage(input); if (exists) { logger.warn(`Package ${input} already exists on npm`); return `Warning: Package ${input} already exists on npm. Ensure you have permission to publish it.`; } return true; }, when: answers => !answers.name, }, { type: 'input', name: 'githubRepoName', message: 'Enter your GitHub repository name:', default: answers => (config && config.githubRepoName) || answers.name, when: answers => ['GitHub Packages', 'Both'].includes(answers.publishTo) || answers.createGitHubPages === 'Yes', validate: input => input.trim() !== '' ? true : 'GitHub repository name is required', }, { type: 'input', name: 'githubRepoToken', message: "Enter your GitHub Classic Personal Access Token with 'repo' scope for GitHub Packages or repository creation (or 'NA' to skip):", default: config && config.githubRepoToken ? config.githubRepoToken : 'NA', when: answers => ['GitHub Packages', 'Both'].includes(answers.publishTo) || answers.createGitHubRepo === 'Yes', validate: async (input, answers) => { input = input.trim(); if (input.toLowerCase() === 'na') return true; if (input === '') return "GitHub classic token is required (or enter 'NA' to skip)"; if (!/^(ghp_|ghf_)[A-Za-z0-9_]{36}$/.test(input)) return "Invalid GitHub classic token format. It should start with 'ghp_' or 'ghf_' and be 40 characters long. Create a classic token at https://github.com/settings/tokens."; try { const octokit = new Octokit({ auth: input }); const { data } = await octokit.users.getAuthenticated(); logger.debug(`Authenticated as ${data.login}`); const scopes = await octokit.request('HEAD /'); const tokenScopes = scopes.headers['x-oauth-scopes']?.split(', ') || []; if (!tokenScopes.includes('repo')) { return "GitHub classic token must have 'repo' scope. Create a token at https://github.com/settings/tokens with 'repo' permission."; } return true; } catch (err) { logger.warn(`GitHub classic token validation failed: ${err.message}`); return "Invalid GitHub classic token. Ensure it has 'repo' permission or enter 'NA' to skip. Create a token at https://github.com/settings/tokens."; } }, }, { type: 'input', name: 'githubPagesToken', message: "Enter your GitHub Fine-grained Personal Access Token with 'pages:write' scope for GitHub Pages (or 'NA' to skip):", default: config && config.githubPagesToken ? config.githubPagesToken : 'NA', when: answers => answers.createGitHubPages === 'Yes', validate: async (input, answers) => { input = input.trim(); if (input.toLowerCase() === 'na') return true; if (input === '') return "GitHub fine-grained token is required (or enter 'NA' to skip)"; if (!/^github_pat_[A-Za-z0-9_]{80,}$/.test(input)) return "Invalid GitHub fine-grained token format. It should start with 'github_pat_' and be at least 84 characters long. Create a fine-grained token at https://github.com/settings/tokens?type=beta."; try { const octokit = new Octokit({ auth: input }); const { data } = await octokit.users.getAuthenticated(); logger.debug(`Authenticated as ${data.login}`); // Fine-grained tokens don't expose scopes in headers; we can only verify authentication // Assume pages:write if token is valid and user is authenticated return true; } catch (err) { logger.warn( `GitHub fine-grained token validation failed: ${err.message}` ); return "Invalid GitHub fine-grained token. Ensure it has 'pages:write' permission for the repository or enter 'NA' to skip. Create a token at https://github.com/settings/tokens?type=beta."; } }, }, { type: 'list', name: 'version', message: 'Select initial version:', choices: ['0.0.1 (Recommended)', '0.1.0', '1.0.0', 'Custom'], default: config.version || '0.0.1 (Recommended)', }, { type: 'input', name: 'customVersion', message: 'Enter custom version (e.g., 1.0.0):', when: answers => answers.version === 'Custom', validate: input => { if (!/^\d+\.\d+\.\d+$/.test(input)) return 'Version must be in the format x.y.z (e.g., 1.0.0)'; return true; }, }, { type: 'list', name: 'moduleType', message: 'Choose module type:', choices: ['ES Modules', 'CommonJS'], default: config.moduleType || 'CommonJS', }, { type: 'input', name: 'description', message: 'Enter package description:', default: config.description, }, { type: 'input', name: 'authorName', message: 'Enter author name:', default: config.authorName, }, { type: 'input', name: 'authorEmail', message: 'Enter author email:', default: config.authorEmail, validate: input => { if (!input) return true; // Email is optional const emailRegex = /^[^\s@]+@[^\s@]+\.[^\\s@]+$/; return emailRegex.test(input) ? true : 'Please enter a valid email address'; }, }, { type: 'input', name: 'authorUrl', message: 'Enter author URL:', default: config.authorUrl, filter: input => { if (!input) return input; return input.match(/^https?:\/\//) ? input : `https://${input}`; }, }, { type: 'input', name: 'homepage', message: 'Enter homepage URL:', default: config.homepage, when: answers => answers.createGitHubPages !== 'Yes', filter: input => { if (!input) return input; return input.match(/^https?:\/\//) ? input : `https://${input}`; }, }, { type: 'input', name: 'keywords', message: 'Enter keywords (comma-separated):', default: config.keywords ? config.keywords.join(', ') : '', filter: input => input .split(',') .map(k => k.trim()) .filter(k => k), }, { type: 'list', name: 'license', message: 'Choose a license:', choices: ['MIT', 'ISC', 'Apache-2.0', 'GPL-3.0', 'Unlicense'], default: config.license || 'MIT', }, { type: 'list', name: 'useTypeScript', message: 'Use TypeScript for your project?', choices: ['Yes', 'No'], default: config.useTypeScript ? 'Yes' : 'No', }, { type: 'list', name: 'useESLint', message: 'Add ESLint for linting?', choices: ['Yes', 'No'], default: config.useESLint ? 'Yes' : 'No', }, { type: 'list', name: 'usePrettier', message: 'Add Prettier for code formatting?', choices: ['Yes', 'No'], default: config.usePrettier ? 'Yes' : 'No', }, { type: 'list', name: 'testFramework', message: 'Choose a testing framework:', choices: ['Jest', 'Mocha', 'Vitest', 'None'], default: config.testFramework || 'None', }, { type: 'list', name: 'addDependency', message: 'Add a dependency to your project?', choices: ['Yes', 'No'], default: 'No', }, { type: 'input', name: 'dependencyName', message: 'Enter dependency name (e.g., lodash):', when: answers => answers.addDependency === 'Yes', validate: async input => { if (input.trim() === '') return 'Dependency name is required'; const valid = await fetchNpmPackage(input); if (!valid) return `Package "${input}" not found in npm registry`; return true; }, }, { type: 'input', name: 'dependencyVersion', message: "Enter dependency version (e.g., ^4.17.21, or 'latest' to skip):", default: 'latest', when: answers => answers.addDependency === 'Yes', }, { type: 'list', name: 'addAnotherDependency', message: 'Add another dependency?', choices: ['Yes', 'No'], default: 'No', when: answers => answers.addDependency === 'Yes', loop: true, }, { type: 'list', name: 'addDevDependency', message: 'Add a devDependency to your project?', choices: ['Yes', 'No'], default: 'No', }, { type: 'input', name: 'devDependencyName', message: 'Enter devDependency name (e.g., eslint):', when: answers => answers.addDevDependency === 'Yes', validate: async input => { if (input.trim() === '') return 'DevDependency name is required'; const valid = await fetchNpmPackage(input); if (!valid) return `Package "${input}" not found in npm registry`; return true; }, }, { type: 'input', name: 'devDependencyVersion', message: "Enter devDependency version (e.g., ^8.0.0, or 'latest' to skip):", default: 'latest', when: answers => answers.addDevDependency === 'Yes', }, { type: 'list', name: 'addAnotherDevDependency', message: 'Add another devDependency?', choices: ['Yes', 'No'], default: 'No', when: answers => answers.addDevDependency === 'Yes', loop: true, }, { type: 'list', name: 'addCustomScripts', message: 'Do you want to add custom npm scripts?', choices: ['Yes', 'No'], default: 'No', }, { type: 'input', name: 'customScripts', message: 'Enter custom npm scripts (e.g., lint:eslint ., test:jest):', when: answers => answers.addCustomScripts === 'Yes', default: config && config.customScripts ? Object.entries(config.customScripts) .map(([k, v]) => `${k}:${v}`) .join(', ') : '', filter: input => { if (!input) return {}; const scripts = {}; input.split(',').forEach(pair => { const [key, value] = pair.split(':').map(s => s.trim()); if (key && value) scripts[key] = value; }); return scripts; }, }, ]; const answers = await inquirer.prompt(questions); // Handle dependency loops while ( answers.addDependency === 'Yes' && answers.addAnotherDependency === 'Yes' ) { const depAnswers = await inquirer.prompt([ { type: 'input', name: 'dependencyName', message: 'Enter dependency name (e.g., lodash):', validate: async input => { if (input.trim() === '') return 'Dependency name is required'; const valid = await fetchNpmPackage(input); if (!valid) return `Package "${input}" not found in npm registry`; return true; }, }, { type: 'input', name: 'dependencyVersion', message: "Enter dependency version (e.g., ^4.17.21, or 'latest' to skip):", default: 'latest', }, { type: 'list', name: 'addAnotherDependency', message: 'Add another dependency?', choices: ['Yes', 'No'], default: 'No', }, ]); dependencyList.push({ name: depAnswers.dependencyName, version: depAnswers.dependencyVersion, type: 'dependency', }); answers.addAnotherDependency = depAnswers.addAnotherDependency; } if (answers.addDependency === 'Yes') { dependencyList.push({ name: answers.dependencyName, version: answers.dependencyVersion, type: 'dependency', }); } while ( answers.addDevDependency === 'Yes' && answers.addAnotherDevDependency === 'Yes' ) { const devDepAnswers = await inquirer.prompt([ { type: 'input', name: 'devDependencyName', message: 'Enter devDependency name (e.g., eslint):', validate: async input => { if (input.trim() === '') return 'DevDependency name is required'; const valid = await fetchNpmPackage(input); if (!valid) return `Package "${input}" not found in npm registry`; return true; }, }, { type: 'input', name: 'devDependencyVersion', message: "Enter devDependency version (e.g., ^8.0.0, or 'latest' to skip):", default: 'latest', }, { type: 'list', name: 'addAnotherDevDependency', message: 'Add another devDependency?', choices: ['Yes', 'No'], default: 'No', }, ]); dependencyList.push({ name: devDepAnswers.devDependencyName, version: devDepAnswers.devDependencyVersion, type: 'devDependency', }); answers.addAnotherDevDependency = devDepAnswers.addAnotherDevDependency; } if (answers.addDevDependency === 'Yes') { dependencyList.push({ name: answers.devDependencyName, version: answers.devDependencyVersion, type: 'devDependency', }); } // Map version choice to actual version answers.version = answers.version === 'Custom' ? answers.customVersion : answers.version.split(' ')[0]; answers.dependencies = dependencyList .filter(dep => dep.type === 'dependency') .reduce((acc, dep) => { acc[dep.name] = dep.version; return acc; }, {}); answers.devDependencies = dependencyList .filter(dep => dep.type === 'devDependency') .reduce((acc, dep) => { acc[dep.name] = dep.version; return acc; }, {}); delete answers.customVersion; delete answers.dependencyName; delete answers.dependencyVersion; delete answers.addDependency; delete answers.addAnotherDependency; delete answers.devDependencyName; delete answers.devDependencyVersion; delete answers.addDevDependency; delete answers.addAnotherDevDependency; logger.debug('Final answers:', answers); return answers; } module.exports = { promptPackageDetails };