warp-drive
Version:
WarpDrive is the data framework for ambitious web applications
493 lines (473 loc) • 16.1 kB
JavaScript
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 };