UNPKG

warp-drive

Version:

WarpDrive is the data framework for ambitious web applications

493 lines (473 loc) 16.1 kB
import makeDebug from 'debug'; import fs from 'fs'; import { execSync } from 'node:child_process'; import path from 'path'; const FalseyStrings = new Set(['false', '0', 'no', 'n', 'off', '']); function processRawValue(config, raw_value) { if (raw_value === undefined) { if (config.type === Boolean) { return config.invert_boolean ? false : true; } else if (config.default_value !== undefined && typeof config.default_value !== 'function') { return config.default_value; } raw_value = ''; } if (config.type === Boolean) { return !FalseyStrings.has(raw_value.toLowerCase()); } else if (config.type === Number) { return Number(raw_value); } else { return raw_value; } } async function processMissingFlag(config, values) { if (config.default_value !== undefined) { if (typeof config.default_value === 'function') { return await config.default_value(values); } return config.default_value; } else if (config.type === Boolean && config.invert_boolean) { return true; } else { throw new Error(`Flag ${config.name} (${config.flag}) had no default value and was not provided by the user`); } } /** * process the config to create mappings for aliases and misspellings */ function createMappings(flags_config) { const aliases = new Map(); const spellings = new Map(); const seen_positions = new Set(); const positional = []; const all = new Map(); Object.keys(flags_config).forEach(f => { const flag = normalizeFlag(f); const config = flags_config[f]; if (config.flag !== flag) { throw new Error(`Expected configuration key ${flag} for ${config.name} to match ${config.flag}`); } all.set(flag, config); // TODO validate flag config structure more thoroughly // esp for non-optional fields if (config.positional) { if (typeof config.positional_index !== 'number') { throw new Error(`Positional flag ${config.name} must specify positional_index in its config`); } if (seen_positions.has(config.positional_index)) { throw new Error(`Positional flag ${config.name} has a duplicate positional_index`); } seen_positions.add(config.positional_index); positional.push(config); } if (Array.isArray(config.flag_aliases)) { config.flag_aliases.forEach(a => { const alias = normalizeFlag(a); if (alias.length !== 1) { throw new Error(`Flag aliases must be a single character, found ${alias} for ${flag}`); } if (aliases.has(alias)) { throw new Error(`Alias ${alias} is already in use by ${aliases.get(alias)}`); } aliases.set(alias, flag); }); } // always add ourself to the spellings map spellings.set(flag, flag); if (Array.isArray(config.flag_mispellings)) { config.flag_mispellings.forEach(msp => { const misspelling = normalizeFlag(msp); if (misspelling.length < 2) { throw new Error(`Flag misspellings must be at least two characters, found ${misspelling} for ${flag}`); } if (spellings.has(misspelling)) { throw new Error(`Misspelling ${misspelling} is already in use by ${spellings.get(misspelling)}`); } spellings.set(misspelling, flag); }); } }); positional.sort((a, b) => { return a.positional_index > b.positional_index ? 1 : -1; }); return { aliases, spellings, positional, all }; } /** * normalize a string to lowercase and replace dashes with underscores * */ function normalizeFlag(str) { let normalized = str.replace(/([A-Z])/g, '_$1').toLowerCase().replaceAll('-', '_'); while (normalized.charAt(0) === '_') { normalized = normalized.slice(1); } return normalized; } /** * Process raw user provided command line arguments into a populated config object */ async function parseRawFlags(raw, flags_config) { let current_position = 0; const processed_flags = new Map(); const { aliases, spellings, positional, all } = createMappings(flags_config); for (let i = 0; i < raw.length; i++) { const raw_arg = raw[i]; // handle named args if (raw_arg.startsWith('--')) { const arg = raw_arg.slice(2); const parts = arg.split('='); const spelling = normalizeFlag(parts[0]); const flag = spellings.get(spelling) || aliases.get(spelling); if (!flag) { throw new Error(`Unknown flag: ${spelling}`); } const config = flags_config[flag]; let raw_value = parts[1]; if (config) { if (processed_flags.has(flag)) { throw new Error(`Flag ${flag} was provided more than once`); } // scan ahead for a value // scan ahead is not valid for boolean flags if (raw_value === undefined && config.type !== Boolean) { const potential_value = raw[i + 1]; if (potential_value && !potential_value.startsWith('-')) { raw_value = potential_value; i++; } } processed_flags.set(flag, processRawValue(config, raw_value)); } else { throw new Error(`Unknown flag: ${flag}`); } // treat as aliases } else if (raw_arg.startsWith('-')) { const arg = normalizeFlag(raw_arg.slice(1)); // we only allow one non-boolean flag per alias group let has_found_non_boolean_flag = false; for (let j = 0; j < arg.length; j++) { const alias = arg[j]; const flag = aliases.get(alias); if (!flag) { throw new Error(`Unknown flag alias: ${alias}`); } const config = flags_config[flag]; if (!config) { throw new Error(`Unknown flag: ${flag} found for alias ${alias}`); } if (processed_flags.has(flag)) { throw new Error(`Flag ${flag} was provided more than once (discovered via alias '${alias}')`); } let raw_value = undefined; if (config.type !== Boolean) { if (has_found_non_boolean_flag) { throw new Error(`An alias group may only contain one non-boolean flag alias`); } // scan ahead for the value const potential_value = raw[i + 1]; if (potential_value && !potential_value.startsWith('-')) { raw_value = potential_value; i++; } else { throw new Error(`The non-boolean flag alias ${alias} was provided for ${flag} without a corresponding value as the next argument`); } has_found_non_boolean_flag = true; } processed_flags.set(flag, processRawValue(config, raw_value)); } // treat as positional } else { const config = positional[current_position++]; if (!config) { throw new Error(`Unknown positional argument: ${raw_arg}`); } const value = processRawValue(config, raw_arg); processed_flags.set(config.flag, value); } } const full_flags = new Map(processed_flags); // process full flags for (const [flag, config] of all) { if (processed_flags.has(flag)) { await config.validate?.(processed_flags.get(flag), processed_flags); continue; } if (config.required) { throw new Error(config.required_error || `Missing required flag: ${flag}`); } const val = await processMissingFlag(config, full_flags); full_flags.set(flag, val); } return { specified: processed_flags, full: full_flags }; } const DEFAULT = Symbol('Default'); function getCommands(command_config) { const keys = Object.keys(command_config); const commands = new Map(); keys.forEach(key => { const cmd = normalizeFlag(key); commands.set(cmd, cmd); commands.set(command_config[key].cmd, cmd); if (command_config[cmd].alt) { command_config[cmd].alt.forEach(alt => { const alternate = normalizeFlag(alt); if (commands.has(alternate) && commands.get(alternate) !== cmd) { throw new Error(`Duplicate command alias ${alternate} for ${cmd} and ${commands.get(alternate)}`); } commands.set(alternate, cmd); }); } if (command_config[cmd].default) { commands.set(DEFAULT, cmd); } }); return commands; } const EMPTY_FLAGS = { full: new Map(), specified: new Map() }; async function runBinCommand(config) { const args = process.argv.slice(2); const commandArg = args.length === 0 ? DEFAULT : normalizeFlag(args[0]); const commands = getCommands(config.commands); const cmdString = commands.get(commandArg); if (!cmdString) { throw new Error(commandArg === DEFAULT ? `${config.name} has no default command to run` : `Unknown command ${commandArg}`); } const cmd = config.commands[cmdString]; if (args.length && commands.has(commandArg)) { args.shift(); } const cmdFn = await cmd.load(); const flags = cmd.options ? await parseRawFlags(args, cmd.options) : EMPTY_FLAGS; await cmdFn(flags); process.exit(0); } const debug = makeDebug('warp-drive'); const InfoCache = {}; // eslint-disable-next-line @typescript-eslint/require-await async function exec(cmd, args) { debug(`exec: ${cmd}`); return execSync(cmd, { ...args }); } async function getTags(project) { if (!InfoCache[project]) { const start = performance.now(); const info = await exec(`npm view ${project} --json`); const end = performance.now(); debug(`Fetched info for ${project} in ${end - start}ms`); InfoCache[project] = JSON.parse(String(info)); } const keys = Object.keys(InfoCache[project]['dist-tags']); return new Set(keys); } async function getInfo(project) { if (!InfoCache[project]) { const start = performance.now(); const info = await exec(`npm view ${project} --json`); const end = performance.now(); debug(`Fetched info for ${project} in ${end - start}ms`); InfoCache[project] = JSON.parse(String(info)); } return InfoCache[project]; } function getPackageManagerFromLockfile() { const dir = String(execSync('git rev-parse --show-toplevel')).trim(); if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) { return 'pnpm'; } else if (fs.existsSync(path.join(dir, 'package-lock.json'))) { return 'npm'; } else if (fs.existsSync(path.join(dir, 'yarn.lock'))) { return 'yarn'; } else if (fs.existsSync(path.join(dir, 'bun.lock'))) { return 'bun'; } return 'npm'; } const INSTALL_OPTIONS = { help: { name: 'Help', flag: 'help', flag_aliases: ['h', 'm'], flag_mispellings: ['desc', 'describe', 'doc', 'docs', 'dsc', 'guide', 'halp', 'he', 'hel', 'hlp', 'man', 'mn', 'usage'], type: Boolean, default_value: false, description: 'Print this usage manual.', examples: ['npx warp-drive install --help'] } }; const RETROFIT_COMMANDS = ['types', 'mirror']; const RETROFIT_OPTIONS = { help: { name: 'Help', flag: 'help', flag_aliases: ['h'], flag_mispellings: ['desc', 'describe', 'doc', 'docs', 'dsc', 'guide', 'halp', 'he', 'hel', 'hlp', 'man', 'mn', 'usage'], type: Boolean, default_value: false, description: 'Print this usage manual.', examples: ['npx warp-drive retrofit --help'] }, command_string: { name: 'Command String', flag: 'command_string', type: String, description: '<cmd@version> positional shorthand for fits that take a version arg', examples: [], default_value() { return null; }, validate: async value => { if (typeof value !== 'string') { throw new Error(`Expected <cmdString> to be a string`); } const [cmd, version] = value.split('@'); if (!RETROFIT_COMMANDS.includes(cmd)) { throw new Error(`Command in <cmd@version> (${value}) must be one of ${RETROFIT_COMMANDS.join(', ')}`); } if (!version && !value.includes('@')) { return; } const distTags = await getTags('ember-data'); if (!distTags.has(version)) { throw new Error(`version in <cmd@version> (${value}) must be a valid NPM dist-tag`); } }, positional: true, positional_index: 0 }, fit: { name: 'Fit', flag: 'fit', type: String, description: '', examples: [], default_value: options => { const cmdString = options.get('command_string'); if (!cmdString || typeof cmdString !== 'string') { throw new Error(`Must specify a fit to retrofit`); } const [cmd] = cmdString.split('@'); if (!RETROFIT_COMMANDS.includes(cmd)) { throw new Error(`Command in <cmd@version> (${cmdString}) must be one of ${RETROFIT_COMMANDS.join(', ')}`); } return cmd; }, validate: value => { if (!value || typeof value !== 'string' || !RETROFIT_COMMANDS.includes(value)) { throw new Error(`Command (${value}) must be one of ${RETROFIT_COMMANDS.join(', ')}`); } } }, version: { name: 'Version', flag: 'version', type: String, description: '', examples: [], default_value: async options => { const cmdString = options.get('command_string'); if (!cmdString || typeof cmdString !== 'string') { throw new Error(`Must specify a fit to retrofit`); } const [, version] = cmdString.split('@'); if (!version) { throw new Error(`Expected a version to be included in <cmd@version>`); } const distTags = await getTags('ember-data'); if (!distTags.has(version)) { throw new Error(`version in <cmd@version> (${version}) must be a valid NPM dist-tag`); } return version; }, validate: async value => { if (!value || typeof value !== 'string') { throw new Error(`version must be a string`); } const distTags = await getTags('ember-data'); if (!distTags.has(value)) { throw new Error(`version (${value}) must be a valid NPM dist-tag: available ${Array.from(distTags).join(', ')}`); } } }, monorepo: { name: 'Monorepo', flag: 'monorepo', flag_aliases: ['m'], type: Boolean, description: 'Retrofit a monorepo setup', examples: [], default_value: false } }; const COMMANDS = { help: { name: 'Help', cmd: 'help', description: 'Output This Manual', alt: ['doc', 'docs', 'guide', 'h', 'halp', 'he', 'hel', 'help', 'hlp', 'm', 'man', 'mn', 'usage'], example: '$ npx warp-drive help', default: true, load: () => import('./help-Cl0wOcJP.js').then(v => v.help(Bin)) }, about: { name: 'About', cmd: 'about', description: 'Print Information About This Script', alt: ['about', 'abt', 'abut', 'aboot', 'abt', 'describe', 'desc', 'dsc', 'dscr', 'dscrb', 'why', 'y', 'a', 'd'], example: '$ npx warp-drive about', load: () => import('./about-DJVz6hPn.js').then(v => v.about) }, install: { name: 'Install', cmd: 'install', description: 'Adds WarpDrive files and packages to your project based on selections made during install', alt: ['i', 'instal', 'insatll'], example: '$ npx warp-drive install', options: INSTALL_OPTIONS, load: () => import('./install-N8P5yQEg.js').then(v => v.install) }, retrofit: { name: 'Retrofit', cmd: 'retrofit', description: 'Updates WarpDrive packages in your project based on selections made during retrofit and existing dependencies in package.json', alt: ['r', 'retro', 'update', 'upgrade', 'refit'], example: '$ npx warp-drive retrofit', options: RETROFIT_OPTIONS, load: () => import('./retrofit-CFimLd28.js').then(v => v.retrofit) }, eject: { name: 'Eject', cmd: 'eject', description: 'Removes the ember-data package from your project, installing and configuring individual dependencies instead', alt: [], options: {}, load: () => import('./eject-80oBbKPH.js').then(v => v.eject) } }; const Bin = { name: 'warp-drive', alt: ['warpdrive', 'wd'], commands: COMMANDS }; function main() { return runBinCommand(Bin); } void main(); export { getInfo as a, getPackageManagerFromLockfile as b, exec as e, getTags as g };