UNPKG

prepare-package

Version:

Prepare a Node.js package before being published

319 lines (276 loc) 10.6 kB
const jetpack = require('fs-jetpack'); const fetch = require('wonderful-fetch'); const path = require('path'); const { spawn } = require('child_process'); const { default: chalk } = require('chalk'); const logger = require('./logger'); /** * Run a single shell command and resolve/reject when it exits. * Streams stdio to the parent process so output is visible during prepare/publish. * * @param {string} cmd - Shell command to run * @param {string} cwd - Working directory for the command * @returns {Promise<void>} - Resolves on exit code 0, rejects otherwise */ function runCommand(cmd, cwd) { return new Promise((resolve, reject) => { const child = spawn(cmd, { cwd: cwd, stdio: 'inherit', shell: true, }); child.on('error', reject); child.on('exit', (code) => { if (code === 0) { return resolve(); } return reject(new Error(`Command exited with code ${code}`)); }); }); } /** * Run a hook command (or array of commands) sequentially from the consumer's cwd. * * @param {string|string[]} command - Shell command(s) to run * @param {string} cwd - Working directory for the command * @param {string} label - 'before' or 'after' (used in logs) * @param {boolean} blocking - If true, throws on failure; if false, warns and continues */ async function runHook(command, cwd, label, blocking) { if (!command) { return; } const commands = Array.isArray(command) ? command : [command]; for (const cmd of commands) { logger.log(chalk.cyan(`Running ${label} hook: ${cmd}`)); try { await runCommand(cmd, cwd); } catch (e) { if (blocking) { logger.error(`${label} hook failed: ${cmd}`); throw e; } logger.error(chalk.yellow(`${label} hook failed (non-blocking): ${cmd}${e.message}`)); } } } // const argv = require('yargs').argv; // Helper function to check for local dependencies function checkForLocalDependencies(packageJSON) { const localDeps = []; const depTypes = [ 'dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies' ]; depTypes.forEach(type => { if (!packageJSON[type]) { return; } Object.entries(packageJSON[type]).forEach(([name, version]) => { // Check for file: protocol or relative/absolute paths if (version.startsWith('file:') || version.startsWith('../') || version.startsWith('./') || version.startsWith('/') || version.startsWith('~')) { localDeps.push({ type, name, value: version }); } }); }); return localDeps; } module.exports = async function (options) { // Set the options options = options || {}; options.purge = typeof options.purge === 'undefined' ? true : options.purge; options.cwd = options.cwd || process.cwd(); options.isPostInstall = typeof options.isPostInstall === 'undefined' ? false : options.isPostInstall; options.singleFile = options.singleFile || null; // For watch mode - single file to process // Detect if running via npm publish // When npm publish runs, it sets npm_command to 'publish' even though lifecycle_event is 'prepare' const isPublishing = process.env.npm_command === 'publish' || process.env.npm_lifecycle_event === 'prepublishOnly' || process.env.npm_lifecycle_event === 'prepublish'; const isManualPrepare = process.env.npm_lifecycle_event === 'prepare' && !isPublishing; // Set the paths const theirPackageJSONPath = path.resolve(options.cwd, 'package.json'); const theirPackageJSONExists = jetpack.exists(theirPackageJSONPath); // Get the package.json files const thisPackageJSON = require('../package.json'); const theirPackageJSON = theirPackageJSONExists ? require(theirPackageJSONPath) : {}; const isLivePreparation = theirPackageJSON.name !== 'prepare-package'; // Check for local dependencies when publishing if (isPublishing) { const hasLocalDeps = checkForLocalDependencies(theirPackageJSON); if (hasLocalDeps.length > 0) { logger.error('Cannot publish with local dependencies!'); logger.error('The following dependencies use local file references:'); hasLocalDeps.forEach(dep => { logger.error(` - ${dep.type}: ${dep.name} -> ${dep.value}`); }); throw new Error('Publishing blocked: Remove local file dependencies before publishing to npm'); } // Clean up files that should never be published (search recursively, exclude node_modules) const filesToRemove = [ 'firebase-debug.log', '.DS_Store', 'Thumbs.db', '.env', '.env.local', '.env.development', '.env.production', ]; filesToRemove.forEach(fileName => { const foundFiles = jetpack.find(options.cwd, { matching: [`**/${fileName}`, `!node_modules/**`], }); foundFiles.forEach(filePath => { jetpack.remove(filePath); logger.log(chalk.yellow(`Removed ${path.relative(options.cwd, filePath)}`)); }); }); } // const options = { // purge: argv['--purge'] || argv['-p'], // }; // Fix their package.json theirPackageJSON.main = theirPackageJSON.main || './dist/index.js'; theirPackageJSON.preparePackage = theirPackageJSON.preparePackage || {}; theirPackageJSON.preparePackage.input = theirPackageJSON.preparePackage.input || './src'; theirPackageJSON.preparePackage.output = theirPackageJSON.preparePackage.output || './dist'; theirPackageJSON.preparePackage.replace = theirPackageJSON.preparePackage.replace || {}; theirPackageJSON.preparePackage.type = theirPackageJSON.preparePackage.type || 'copy'; theirPackageJSON.preparePackage.hooks = theirPackageJSON.preparePackage.hooks || {}; // Add script theirPackageJSON.scripts = theirPackageJSON.scripts || {}; theirPackageJSON.scripts.prepare = `node -e \"require('prepare-package')()\"`; theirPackageJSON.scripts['prepare:watch'] = `node -e \"require('prepare-package/watch')()\"` // Resolve type const type = theirPackageJSON.preparePackage.type; const isBundleMode = type === 'bundle'; // Skip bundle mode during postinstall — esbuild bundling only runs via prepare/build scripts if (isBundleMode && options.isPostInstall) { return; } if (!options.singleFile) { logger.log(`Starting (${type} mode)...${isPublishing ? ' (via npm publish)' : ''}`); logger.log({ type: type, purge: options.purge, input: theirPackageJSON.preparePackage.input, output: theirPackageJSON.preparePackage.output, main: theirPackageJSON.main, isPublishing: isPublishing, }); } // Run the `before` hook // Only runs on live preparation (not on prepare-package's own build) and never in postinstall // or single-file watch mode (which runs on every file change). if (isLivePreparation && !options.isPostInstall && !options.singleFile) { await runHook(theirPackageJSON.preparePackage.hooks.before, options.cwd, 'before', true); } // --- BUNDLE MODE --- if (isBundleMode) { const { build } = require('./build'); await build({ cwd: options.cwd, packageJSON: theirPackageJSON, config: theirPackageJSON.preparePackage, }); // Write updated package.json (scripts, defaults) if (isLivePreparation && theirPackageJSONExists) { jetpack.write( theirPackageJSONPath, `${JSON.stringify(theirPackageJSON, null, 2)}\n` ); } } // --- COPY MODE --- else { // Set the paths relative to the cwd const mainPath = path.resolve(options.cwd, theirPackageJSON.main); const outputPath = path.resolve(options.cwd, theirPackageJSON.preparePackage.output); const inputPath = path.resolve(options.cwd, theirPackageJSON.preparePackage.input); // Check if paths exist const mainPathExists = jetpack.exists(mainPath); const outputPathExists = jetpack.exists(outputPath); const inputPathExists = jetpack.exists(inputPath); // Handle single file mode (for watch) if (options.singleFile) { const relativePath = path.relative(inputPath, options.singleFile); const destPath = path.join(outputPath, relativePath); // Copy single file if (jetpack.exists(options.singleFile)) { jetpack.copy(options.singleFile, destPath, { overwrite: true }); logger.log(`Updated: ${relativePath}`); } } else { // Normal mode - full copy if (outputPathExists && inputPathExists) { jetpack.remove(outputPath); } if (inputPathExists) { jetpack.copy(inputPath, outputPath); } } // Only do this part on the actual package that is using THIS package if (isLivePreparation) { if (options.singleFile) { const destPath = path.join(outputPath, path.relative(inputPath, options.singleFile)); if (destPath === mainPath && jetpack.exists(destPath)) { jetpack.write( destPath, jetpack.read(destPath).replace(/{version}/igm, theirPackageJSON.version) ); } } else { if (mainPathExists) { jetpack.write( mainPath, jetpack.read(mainPath) .replace(/{version}/igm, theirPackageJSON.version), ); } if (theirPackageJSONExists) { jetpack.write( theirPackageJSONPath, `${JSON.stringify(theirPackageJSON, null, 2)}\n` ); } } } } // Handle post install if (options.isPostInstall) { // Send analytics // ... moveed to another package } // Run the `after` hook // Non-blocking — failures warn but don't break the prepare flow (same philosophy as CDN purge). if (isLivePreparation && !options.isPostInstall && !options.singleFile) { await runHook(theirPackageJSON.preparePackage.hooks.after, options.cwd, 'after', false); } // If purge is disabled, then return if (options.purge === false) { return; } // Purge the CDN return fetch(`https://purge.jsdelivr.net/npm/${theirPackageJSON.name}@latest`, { response: 'json', tries: 3, }) .then((r) => { // console.log(chalk.green(`[prepare-package]: Purged ${theirPackageJSON.name}`)); logger.log(chalk.green(`Purged ${theirPackageJSON.name}: status=${r.status}, id=${r.id}, timestamp=${r.timestamp}`)); }) .catch((e) => { // console.log(chalk.red(`[prepare-package]: Failed to purge ${theirPackageJSON.name}`, e)); logger.error(`Failed to purge ${theirPackageJSON.name}!`, e.stack); }) }