@tylertech/forge-upgrade
Version:
Automated upgrade utility for the Tyler Forge™ based projects.
271 lines (238 loc) • 9.96 kB
JavaScript
import { replaceInFile } from 'replace-in-file';
import ora from 'ora';
import path from 'canonical-path';
import chalk from 'chalk';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import fs from 'fs';
import { glob } from 'glob';
import { fileURLToPath } from 'url';
import { executeHtmlMigrations, executeJscodeshiftMigrations } from './migration-utils.mjs';
import { logInfo, logError, logSuccess, logWarn, logBreak } from './log.mjs';
const filename = fileURLToPath(import.meta.url);
const packageRoot = path.join(path.dirname(fs.realpathSync(filename)));
const argv = yargs(hideBin(process.argv)).argv;
const DEFAULT_UPGRADE_CONFIGURATION = 'forge-3.0';
if (argv.usage || argv.help) {
console.log(`
Usage: forge-upgrade [options]
Options:
--path Specify the root path for the upgrade (default: current directory)
--dry-run Perform a dry run without modifying any files
--no-replace Disables replace operations.
--no-migrate Disables code migrations.
--ignore Comma-separated list of globs to ignore during the upgrade
--configuration Specify the upgrade configuration (default: ${DEFAULT_UPGRADE_CONFIGURATION})
--verbose Enable verbose logging
`);
process.exit(0);
}
const CONFIGURATION_MIGRATION_MAP = {
'forge-3.0': {
html: [
{ name: 'Forge Typography Class', path: './migrations/html/v3/posthtml-forge-typography.mjs' },
{ name: 'Forge Card', path: './migrations/html/v3/posthtml-forge-card.mjs' },
{ name: 'Forge Density', path: './migrations/html/v3/posthtml-forge-density.mjs' },
{ name: 'Forge Buttons', path: './migrations/html/v3/posthtml-forge-button.mjs' },
{ name: 'Forge Checkboxes, Radios, Switches', path: './migrations/html/v3/posthtml-forge-checkbox-radio-switch.mjs' },
{ name: 'Forge Lists and List Items', path: './migrations/html/v3/posthtml-forge-list.mjs' },
{ name: 'Forge Tabs', path: './migrations/html/v3/posthtml-forge-tabs.mjs' },
{ name: 'Forge Tooltip', path: './migrations/html/v3/posthtml-forge-tooltip.mjs' },
{ name: 'Forge Label Value', path: './migrations/html/v3/posthtml-forge-label-value.mjs' },
{ name: 'Forge Badge', path: './migrations/html/v3/posthtml-forge-badge.mjs' },
{ name: 'Forge Field', path: './migrations/html/v3/posthtml-forge-field.mjs' },
{ name: 'Forge Button Toggle', path: './migrations/html/v3/posthtml-forge-button-toggle.mjs' },
],
jsx: [
{ name: 'Forge Card', path: './migrations/jsx/v3/jscodeshift-forge-card.cjs' },
{ name: 'Forge Density', path: './migrations/jsx/v3/jscodeshift-forge-density.cjs' },
{ name: 'Forge Buttons', path: './migrations/jsx/v3/jscodeshift-forge-button.cjs' },
{ name: 'Forge Checkboxes, Radios, Switches', path: './migrations/jsx/v3/jscodeshift-forge-checkbox-radio-switch.cjs' },
{ name: 'Forge Lists and List Items', path: './migrations/jsx/v3/jscodeshift-forge-list.cjs' },
{ name: 'Forge Tabs', path: './migrations/jsx/v3/jscodeshift-forge-tabs.cjs' },
{ name: 'Forge Tooltip', path: './migrations/jsx/v3/jscodeshift-forge-tooltip.cjs' },
{ name: 'Forge Label Value', path: './migrations/jsx/v3/jscodeshift-forge-label-value.cjs' },
{ name: 'Forge Badge', path: './migrations/jsx/v3/jscodeshift-forge-badge.cjs' },
{ name: 'Forge Field', path: './migrations/jsx/v3/jscodeshift-forge-field.cjs' },
{ name: 'Forge Button Toggle', path: './migrations/jsx/v3/jscodeshift-forge-button-toggle.cjs' },
],
js: [
{ name: 'Dialog Options', path: './migrations/js/v3/jscodeshift-dialog-options.cjs' },
]
}
}
const NODE_MODULES_GLOB = '**/node_modules/**';
try {
const dryRun = argv.dryRun ?? false;
const replace = argv.replace ?? true;
const migrate = argv.migrate ?? true;
const verbose = argv.verbose ?? false;
const ignoreGlobs = argv.ignore ? argv.ignore.split(',').map(p => p.trim()).filter(p => !!p) : [];
const configuration = argv.configuration ?? DEFAULT_UPGRADE_CONFIGURATION;
const filePath = path.join(packageRoot, 'configurations', `${configuration}.json`);
const file = fs.readFileSync(filePath, 'utf-8');
const { name, operations } = JSON.parse(file);
const rootPath = argv.path ?? '.';
const changedFiles = new Set();
if (!name || !operations || !operations.length) {
throw new Error(`Invalid upgrade configuration specified: "${configuration}"`);
}
if (!rootPath) {
throw new Error('A valid `--path` argument must be provided.');
}
if (dryRun) {
logWarn('This is a dry run. No files will be modified.\n');
}
logInfo(`Using path: "${path.resolve(rootPath)}"`);
logInfo(`Using upgrade configuration: "${name}" (${configuration})`);
// Replace operations
if (replace && operations.length) {
logInfo(`Found ${operations.length} replace operation(s)\n`);
const initial = await prompt('Is this the first time running this upgrade for this project? (y/n) ', 'boolean');
logBreak();
if (!initial) {
logInfo(`Skipping all one-time upgrade operations.\n`);
} else {
logInfo(`Performing all upgrade operations, including one-time operations.\n`);
}
try {
const spinner = ora(`Performing upgrade operation (this may take a while)...\n\n`);
spinner.start();
for (const { files, patterns, once } of operations) {
const sources = path.join(rootPath, files);
const from = patterns.map(p => new RegExp(p.from, 'g'));
if (once && !initial) {
continue;
}
const to = patterns.map(p => p.to);
const modifiedFiles = await executeReplaceOperation({ files: sources, from, to, dry: dryRun, ignore: ignoreGlobs });
modifiedFiles.forEach(file => changedFiles.add(file));
}
spinner.succeed();
} catch (e) {
spinner.fail();
logBreak()
logError(e.stack);
}
}
// Migrations
if (migrate && CONFIGURATION_MIGRATION_MAP[configuration]) {
// HTML
const htmlMigrations = CONFIGURATION_MIGRATION_MAP[configuration].html;
if (htmlMigrations?.length) {
const globPath = path.join(rootPath, '**/*.{html, html.erb}');
const htmlFiles = await glob(globPath, { ignore: [NODE_MODULES_GLOB, ...ignoreGlobs] });
if (htmlFiles.length) {
logInfo(`Found ${htmlMigrations.length} HTML migration(s)\n`);
const modifiedHtmlFiles = await executeHtmlMigrations({
files: htmlFiles,
migrations: htmlMigrations,
dryRun
});
modifiedHtmlFiles.forEach(file => changedFiles.add(file));
}
}
// JSX/TSX
const jsxMigrations = CONFIGURATION_MIGRATION_MAP[configuration].jsx;
if (jsxMigrations?.length) {
const globPath = path.join(rootPath, '**/*.{jsx,tsx}');
const jsxFiles = await glob(globPath, { ignore: [NODE_MODULES_GLOB, ...ignoreGlobs] });
if (jsxFiles.length) {
logInfo(`Found ${jsxMigrations.length} JSX/TSX migration(s)\n`);
const modifiedJSXFiles = await executeJscodeshiftMigrations({
files: jsxFiles,
migrations: jsxMigrations,
dryRun,
verbose,
parser: 'tsx'
});
modifiedJSXFiles.forEach(file => changedFiles.add(file));
}
}
// JS/TS
const jsMigrations = CONFIGURATION_MIGRATION_MAP[configuration].js;
if (jsMigrations?.length) {
const globPath = path.join(rootPath, '**/*.{js,ts}');
const jsFiles = await glob(globPath, { ignore: [NODE_MODULES_GLOB, ...ignoreGlobs] });
if (jsFiles.length) {
logInfo(`Found ${jsMigrations.length} JS/TS migration(s)\n`);
const modifiedJSFiles = await executeJscodeshiftMigrations({
files: jsFiles,
migrations: jsMigrations,
dryRun,
verbose,
parser: 'ts'
});
modifiedJSFiles.forEach(file => changedFiles.add(file));
}
}
}
logBreak()
if (changedFiles.size) {
const prefix = dryRun ? 'Would have modified' : 'Modified';
logWarn(`${prefix} ${changedFiles.size} file${changedFiles.size > 1 ? 's' : ''}:`);
changedFiles.forEach(file => console.log(` ${chalk.greenBright('[Modified]')} ${file}`));
logBreak()
logSuccess(`Upgrade complete${dryRun ? chalk.yellow(' (dry run)') : ''}.`);
} else {
logBreak()
if (dryRun) {
logSuccess(`Upgrade complete ${chalk.yellow('(dry run)')}.`);
} else {
logWarn('Upgrade complete. No files were modified.');
}
}
} catch (e) {
logBreak()
logError(e.message ?? e);
}
async function executeReplaceOperation({ files, from, to, dry, ignore }) {
const options = {
files,
from,
to,
dry,
allowEmptyPaths: true,
ignore: [
'node_modules/**/*',
'**/*/node_modules/**/*',
...ignore
]
};
const results = await replaceInFile(options);
return results
.filter(result => result.hasChanged)
.map(result => result.file);
}
/**
* Prompts the user for input.
* @param {string} question - The question to ask the user.
* @param {string} type - The type of input to expect.
* @returns {Promise<string|boolean>} The user's input.
*/
async function prompt(question, type) {
if (!question) {
throw new Error('A question must be provided');
}
if (!type) {
throw new Error('A type must be provided');
}
question = `${chalk.greenBright('?')} ${question}`;
process.stdout.write(question);
process.stdin.resume();
process.stdin.setEncoding('utf-8');
return new Promise(resolve => {
process.stdin.once('data', data => {
process.stdin.pause();
const value = data.trim().toLowerCase();
switch (type) {
case 'boolean':
resolve(value.toLowerCase() === 'y');
break;
default:
resolve(value);
}
});
});
}