diff --git a/tasks/framework-tools/frameworkSyncToProject.mjs b/tasks/framework-tools/frameworkSyncToProject.mjs index 324e4629e818..b244753687fa 100644 --- a/tasks/framework-tools/frameworkSyncToProject.mjs +++ b/tasks/framework-tools/frameworkSyncToProject.mjs @@ -1,129 +1,298 @@ #!/usr/bin/env node /* eslint-env node */ +// @ts-check -import fs from 'node:fs' +import { execSync } from 'node:child_process' import path from 'node:path' import c from 'ansi-colors' import chokidar from 'chokidar' +import fs from 'fs-extra' +import { hideBin } from 'yargs/helpers' +import yargs from 'yargs/yargs' import { REDWOOD_PACKAGES_PATH, - packageJsonName, + getPackageJsonName, resolvePackageJsonPath, buildPackages, + REDWOOD_FRAMEWORK_PATH, } from './lib/framework.mjs' -import { cleanPackages } from './lib/framework.mjs' import { - installProjectPackages, addDependenciesToPackageJson, copyFrameworkFilesToProject, } from './lib/project.mjs' -const projectPath = process.argv?.[2] ?? process.env.RWJS_CWD +const IGNORE_EXTENSIONS = ['.DS_Store'] -if (!projectPath) { - console.log('Error: Please specify the path to your Redwood Project') - console.log(`Usage: ${process.argv?.[1]} /path/to/rwjs/project`) - process.exit(1) -} +// Add to this array of strings, RegExps, or functions (whichever makes the most sense) +// to ignore files that we don't want triggering package rebuilds. +const ignored = [ + /node_modules/, -// Cache the original package.json and restore it when this process exits. -const projectPackageJsonPath = path.join(projectPath, 'package.json') + /dist/, -const projectPackageJson = fs.readFileSync(projectPackageJsonPath, 'utf-8') -process.on('SIGINT', () => { - console.log() - console.log(`Removing framework packages from 'package.json'...`) - fs.writeFileSync(projectPackageJsonPath, projectPackageJson) - // TODO: Delete `node_modules/@redwoodjs` - console.log("...Done. Run 'yarn install'") - process.exit(0) -}) + /__fixtures__/, + /__mocks__/, + /__tests__/, + /.test./, + /jest.config.{js,ts}/, -function logStatus(m) { - console.log(c.bgYellow(c.black('rwfw ')), c.yellow(m)) -} + /README.md/, -function logError(m) { - console.log(c.bgRed(c.black('rwfw ')), c.red(m)) -} + // esbuild emits meta.json files that we sometimes suffix. + /meta.(\w*\.?)json/, -chokidar - .watch(REDWOOD_PACKAGES_PATH, { - ignoreInitial: true, - persistent: true, - awaitWriteFinish: true, - ignored: (file) => - file.includes('/node_modules/') || - file.includes('/dist/') || - file.includes('/dist') || - file.includes('/__tests__/') || - file.includes('/__fixtures__/') || - file.includes('/.test./') || - ['.DS_Store'].some((ext) => file.endsWith(ext)), - }) - .on('ready', async () => { - logStatus('Cleaning Framework...') - cleanPackages() + (filePath) => IGNORE_EXTENSIONS.some((ext) => filePath.endsWith(ext)), +] - logStatus('Building Framework...') - buildPackages() +const separator = '-'.repeat(process.stdout.columns) - console.log() - logStatus('Adding dependencies...') - addDependenciesToPackageJson(projectPackageJsonPath) - installProjectPackages(projectPath) +async function main() { + const { _: positionals, ...options } = yargs(hideBin(process.argv)) + .options({ + cleanFramework: { + description: + 'Clean any built framework packages before watching for changes', + type: 'boolean', + default: true, + }, + buildFramework: { + description: + 'Build all the framework packages before watching for changes', + type: 'boolean', + default: true, + }, + addDependencies: { + description: + "Add the framework's dependencies to the project and yarn install before watching for changes", + type: 'boolean', + default: true, + }, + copyFiles: { + description: + "Copy the framework packages' files to the project's node_modules before watching for changes", + type: 'boolean', + default: true, + }, + cleanUp: { + description: + "Restore the project's package.json when this process exits", + type: 'boolean', + default: true, + }, + verbose: { + description: 'Print more', + type: 'boolean', + default: true, + }, + }) + .parseSync() + + const redwoodProjectPath = positionals[0] ?? process.env.RWJS_CWD + + // Mostly just making TS happy with the second condition. + if (!redwoodProjectPath || typeof redwoodProjectPath !== 'string') { + process.exitCode = 1 + console.error([ + 'Error: Please specify the path to your Redwood project', + `Usage: ${process.argv?.[1]} ./path/to/rw/project`, + ]) + return + } + + if (options.cleanFramework) { + logStatus('Cleaning the Redwood framework...') + execSync('yarn build:clean', { + stdio: options.verbose ? 'inherit' : 'pipe', + cwd: REDWOOD_FRAMEWORK_PATH, + }) + } + if (options.buildFramework) { + try { + logStatus('Building the Redwood framework...') + execSync('yarn build', { + stdio: options.verbose ? 'inherit' : 'pipe', + cwd: REDWOOD_FRAMEWORK_PATH, + }) + console.log() + } catch (e) { + // Temporary error handling for. + // > Lerna (powered by Nx) ENOENT: no such file or directory, open '/Users/dom/projects/redwood/redwood/node_modules/lerna/node_modules/nx/package.json' + process.exitCode = 1 + console.error( + [ + c.bgYellow(c.black('Heads up ')), + '', + "If this failed because Nx couldn't find its package.json file in node_modules, it's a known issue. The workaround is just trying again.", + ].join('\n') + ) + return + } + } + + // Settig up here first before we add the first SIGINT handler + // just for visual output. + process.on('SIGINT', () => { console.log() - logStatus('Copying files...') - await copyFrameworkFilesToProject(projectPath) + }) + + if (options.addDependencies) { + // Save the project's package.json so that we can restore it when this process exits. + const redwoodProjectPackageJsonPath = path.join( + redwoodProjectPath, + 'package.json' + ) + const redwoodProjectPackageJson = fs.readFileSync( + redwoodProjectPackageJsonPath, + 'utf-8' + ) + + if (options.cleanUp) { + logStatus('Setting up clean up on SIGINT or process exit...') + + const cleanUp = createCleanUp({ + redwoodProjectPackageJsonPath, + redwoodProjectPackageJson, + }) + + process.on('SIGINT', cleanUp) + process.on('exit', cleanUp) + } + + logStatus("Adding the Redwood framework's dependencies...") + addDependenciesToPackageJson(redwoodProjectPackageJsonPath) + try { + execSync('yarn install', { + cwd: redwoodProjectPath, + stdio: options.verbose ? 'inherit' : 'pipe', + }) + console.log() + } catch (e) { + process.exitCode = 1 + console.error(e) + return + } + } + + if (options.copyFiles) { + logStatus('Copying the Redwood framework files...') + await copyFrameworkFilesToProject(redwoodProjectPath) console.log() - logStatus('Done, and waiting for changes...') - console.log('-'.repeat(80)) + } + + logStatus('Waiting for changes') + console.log(separator) + + const watcher = chokidar.watch(REDWOOD_PACKAGES_PATH, { + ignored, + // We don't want chokidar to emit events as it discovers paths, only as they change. + ignoreInitial: true, + // Debounce the events. + awaitWriteFinish: true, }) - .on('all', async (_event, filePath) => { + + let closedWatcher = false + + async function closeWatcher() { + if (closedWatcher) { + return + } + + logStatus('Closing the watcher...') + await watcher.close() + closedWatcher = true + } + + process.on('SIGINT', closeWatcher) + process.on('exit', closeWatcher) + + watcher.on('all', async (_event, filePath) => { logStatus(filePath) if (filePath.endsWith('package.json')) { logStatus( - `${c.red( - 'Warning:' - )} You modified a package.json file. If you've modified the ${c.underline( - 'dependencies' - )}, then you must run ${c.underline('yarn rwfw project:sync')} again.` + [ + `${c.red('Warning:')} You modified a package.json file.`, + `If you've modified the ${c.underline('dependencies')}`, + `then you must run ${c.underline('yarn rwfw project:sync')} again.`, + ].join(' ') ) } const packageJsonPath = resolvePackageJsonPath(filePath) - const packageName = packageJsonName(packageJsonPath) + const packageName = getPackageJsonName(packageJsonPath) logStatus(c.magenta(packageName)) + console.log() - let hasHadError = false + let errored = false try { - console.log() - logStatus(`Cleaning ${packageName}...`) - cleanPackages([packageJsonPath]) - - console.log() logStatus(`Building ${packageName}...`) buildPackages([packageJsonPath]) - console.log() logStatus(`Copying ${packageName}...`) - await copyFrameworkFilesToProject(projectPath, [packageJsonPath]) - } catch (error) { - hasHadError = true - console.log(error) + await copyFrameworkFilesToProject(redwoodProjectPath, [packageJsonPath]) console.log() - logError(`Error building ${packageName}...`) + } catch (error) { + errored = true } - if (!hasHadError) { - console.log() - logStatus(`Done, and waiting for changes...`) - console.log('-'.repeat(80)) + if (errored) { + logError(`Error building ${packageName}`) } + + logStatus(`Done, and waiting for changes...`) + console.log(separator) }) +} + +/** + * @param {string} m + */ +function logStatus(m) { + console.log(c.bgYellow(c.black('rwfw ')), c.yellow(m)) +} + +/** + * @param {string} m + */ +function logError(m) { + console.error(c.bgRed(c.black('rwfw ')), c.red(m)) +} + +function createCleanUp({ + redwoodProjectPackageJsonPath, + redwoodProjectPackageJson, +}) { + let cleanedUp = false + + return function () { + if (cleanedUp) { + return + } + + logStatus("Restoring the Redwood project's package.json...") + + fs.writeFileSync(redwoodProjectPackageJsonPath, redwoodProjectPackageJson) + + console.log( + [ + '', + 'To get your project back to its original state...', + "- undo the changes to project's your yarn.lock file", + "- remove your project's node_modules directory", + "- run 'yarn install'", + '', + ].join('\n') + ) + + cleanedUp = true + } +} + +// ------------------------ + +main() diff --git a/tasks/framework-tools/lib/framework.mjs b/tasks/framework-tools/lib/framework.mjs index cec877983cec..e6b940c3701b 100644 --- a/tasks/framework-tools/lib/framework.mjs +++ b/tasks/framework-tools/lib/framework.mjs @@ -1,110 +1,121 @@ /* eslint-env node */ -import fs from 'node:fs' +import { execSync } from 'node:child_process' import path from 'node:path' import url from 'node:url' import Arborist from '@npmcli/arborist' -import c from 'ansi-colors' import execa from 'execa' -import fg from 'fast-glob' +import fs from 'fs-extra' import packlist from 'npm-packlist' const __dirname = path.dirname(url.fileURLToPath(import.meta.url)) -export const REDWOOD_PACKAGES_PATH = path.resolve( - __dirname, - '../../../packages' +export const REDWOOD_FRAMEWORK_PATH = path.resolve(__dirname, '../../../') + +export const REDWOOD_PACKAGES_PATH = path.join( + REDWOOD_FRAMEWORK_PATH, + 'packages' ) +const IGNORE_PACKAGES = ['@redwoodjs/codemods', 'create-redwood-app'] + /** - * A list of the `@redwoodjs` package.json files that are published to npm + * Returns a list of the `@redwoodjs` package.json files that are published to npm * and installed into a Redwood Project. * * The reason there's more logic here than seems necessary is because we have package.json files * like packages/web/toast/package.json that aren't real packages, but just entry points. + * + * @returns {string[]} A list of package.json file paths. */ -export function frameworkPkgJsonFiles() { - let pkgJsonFiles = fg.sync('**/package.json', { - cwd: REDWOOD_PACKAGES_PATH, - ignore: [ - '**/node_modules/**', - '**/create-redwood-app/**', - '**/codemods/**', - ], - absolute: true, +export function getFrameworkPackageJsonPaths() { + let output = execSync('yarn workspaces list --json', { + encoding: 'utf-8', }) - for (const pkgJsonFile of pkgJsonFiles) { - try { - JSON.parse(fs.readFileSync(pkgJsonFile)) - } catch (e) { - throw new Error(pkgJsonFile + ' is not a valid package.json file.') + const packageLocationsAndNames = output + .trim() + .split('\n') + .map(JSON.parse) + // Fliter out the root package. + .filter(({ location }) => location !== '.') + // Some packages we won't bother copying into Redwood projects. + .filter(({ name }) => !IGNORE_PACKAGES.includes(name)) + + const frameworkPackageJsonPaths = packageLocationsAndNames.map( + ({ location }) => { + return path.join(REDWOOD_FRAMEWORK_PATH, location, 'package.json') } - } - - pkgJsonFiles = pkgJsonFiles.filter((pkgJsonFile) => { - const pkgJson = JSON.parse(fs.readFileSync(pkgJsonFile)) - return !!pkgJson.name - }) + ) - return pkgJsonFiles + return frameworkPackageJsonPaths } /** * The dependencies used by `@redwoodjs` packages. + * + * @returns {{ [key: string]: string }?} A map of package names to versions. */ -export function frameworkDependencies(packages = frameworkPkgJsonFiles()) { +export function getFrameworkDependencies( + packageJsonPaths = getFrameworkPackageJsonPaths() +) { const dependencies = {} - for (const packageJsonPath of packages) { - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath)) + for (const packageJsonPath of packageJsonPaths) { + const packageJson = fs.readJSONSync(packageJsonPath) for (const [name, version] of Object.entries( packageJson?.dependencies ?? {} )) { - // Skip `@redwoodjs/*` packages, since these are processed - // by the workspace. - if (!name.startsWith('@redwoodjs/')) { - dependencies[name] = version - - // Warn if the packages are duplicated and are not the same version. - if (dependencies[name] && dependencies[name] !== version) { - console.warn( - c.yellow('Warning:'), - name, - 'dependency version mismatched, please make sure the versions are the same!' - ) - } + // Skip `@redwoodjs` packages, since these are processed by the workspace. + if (name.startsWith('@redwoodjs/')) { + continue + } + + dependencies[name] = version + + // Throw if there's duplicate dependencies that aren't same version. + if (dependencies[name] && dependencies[name] !== version) { + throw new Error( + `${name} dependency version mismatched, please make sure the versions are the same` + ) } } } - return sortObjectKeys(dependencies) + + return dependencies } /** * The files included in `@redwoodjs` packages. * Note: The packages must be built. + * + * @returns {{ [key: string]: string[] }} A map of package names to files. */ -export async function frameworkPackagesFiles( - packages = frameworkPkgJsonFiles() +export async function getFrameworkPackagesFiles( + packageJsonPaths = getFrameworkPackageJsonPaths() ) { - const fileList = {} - for (const packageFile of packages) { - const packageJson = JSON.parse(fs.readFileSync(packageFile)) + const frameworkPackageFiles = {} - const arborist = new Arborist({ path: path.dirname(packageFile) }) + for (const packageJsonPath of packageJsonPaths) { + const packageJson = fs.readJSONSync(packageJsonPath) + const arborist = new Arborist({ path: path.dirname(packageJsonPath) }) const tree = await arborist.loadActual() - fileList[packageJson.name] = await packlist(tree) + frameworkPackageFiles[packageJson.name] = await packlist(tree) } - return fileList + + return frameworkPackageFiles } /** * Returns execute files for `@redwoodjs` packages. **/ -export function frameworkPackagesBins(packages = frameworkPkgJsonFiles()) { +export function getFrameworkPackagesBins( + packages = getFrameworkPackageJsonPaths() +) { let bins = {} + for (const packageFile of packages) { let packageJson @@ -142,48 +153,38 @@ export function resolvePackageJsonPath(filePath) { return path.join(REDWOOD_PACKAGES_PATH, packageName, 'package.json') } -export function packageJsonName(packageJsonPath) { - return JSON.parse(fs.readFileSync(packageJsonPath), 'utf-8').name +/** + * @param {string} packageJsonPath + * @returns {string?} The package name if it has one + */ +export function getPackageJsonName(packageJsonPath) { + return fs.readJSONSync(packageJsonPath).name } /** * Clean Redwood packages. */ -export function cleanPackages(packages = frameworkPkgJsonFiles()) { - const packageNames = packages.map(packageJsonName) +export function cleanPackages(packages = getFrameworkPackageJsonPaths()) { + const packageNames = packages.map(getPackageJsonName) - execa.sync( - 'yarn lerna run build:clean', - ['--parallel', `--scope={${packageNames.join(',') + ','}}`], - { - shell: true, - stdio: 'inherit', - cwd: path.resolve(__dirname, '../../../'), - } - ) + execSync('yarn run rimraf ', { + stdio: 'inherit', + cwd: REDWOOD_FRAMEWORK_PATH, + }) } /** * Build Redwood packages. */ -export function buildPackages(packages = frameworkPkgJsonFiles()) { - const packageNames = packages.map(packageJsonName) - execa.sync( - 'yarn lerna run build', - ['--parallel', `--scope={${packageNames.join(',') + ','}}`], +export function buildPackages(packages = getFrameworkPackageJsonPaths()) { + const packageNames = packages.map(getPackageJsonName) + + execa.commandSync( + `yarn lerna run build --scope={${packageNames.join(',') + ','}}`, { shell: true, stdio: 'inherit', - cwd: path.resolve(__dirname, '../../../'), + cwd: REDWOOD_FRAMEWORK_PATH, } ) } - -function sortObjectKeys(obj) { - return Object.keys(obj) - .sort() - .reduce((acc, key) => { - acc[key] = obj[key] - return acc - }, {}) -} diff --git a/tasks/framework-tools/lib/project.mjs b/tasks/framework-tools/lib/project.mjs index ad10d6fc41de..be1980c12d8a 100644 --- a/tasks/framework-tools/lib/project.mjs +++ b/tasks/framework-tools/lib/project.mjs @@ -1,26 +1,27 @@ /* eslint-env node */ -import fs from 'node:fs' import path from 'node:path' import execa from 'execa' +import fs from 'fs-extra' import ora from 'ora' import { rimraf } from 'rimraf' import terminalLink from 'terminal-link' import { - frameworkDependencies, - frameworkPkgJsonFiles, - frameworkPackagesFiles, - frameworkPackagesBins, - packageJsonName, + getFrameworkDependencies, + getFrameworkPackageJsonPaths, + getFrameworkPackagesFiles, + getFrameworkPackagesBins, + getPackageJsonName, } from './framework.mjs' /** * Sets binaries as executable and creates symlinks to `node_modules/.bin` if they do not exist. */ export function fixProjectBinaries(projectPath) { - const bins = frameworkPackagesBins() + const bins = getFrameworkPackagesBins() + for (let [binName, binPath] of Object.entries(bins)) { // if the binPath doesn't exist, create it. const binSymlink = path.join(projectPath, 'node_modules/.bin', binName) @@ -38,46 +39,49 @@ export function fixProjectBinaries(projectPath) { } /** - * Append all the `@redwoodjs` dependencies to the root `package.json` in a Redwood Project. + * Add all the `@redwoodjs` packages' dependencies to the root `package.json` in a Redwood Project. + * + * @param {string} packageJsonPath - The path to the root `package.json` in a Redwood Project. + * @param {{ [key: string]: string }?} dependencies - A map of package names to versions. + * + * @returns {void} */ export function addDependenciesToPackageJson( packageJsonPath, - dependencies = frameworkDependencies() + dependencies = getFrameworkDependencies() ) { - if (!fs.existsSync(packageJsonPath)) { - console.log( - `Error: The package.json path: ${packageJsonPath} does not exist.` - ) - process.exit(1) - } - const packageJsonLink = terminalLink( 'package.json', 'file://' + packageJsonPath ) - const numOfDeps = Object.keys(dependencies).length + const numberOfDependencies = Object.keys(dependencies).length const spinner = ora( - `Adding ${numOfDeps} framework dependencies to ${packageJsonLink}...` + `Adding ${numberOfDependencies} framework dependencies to ${packageJsonLink}...` ).start() - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) + const packageJson = fs.readJSONSync(packageJsonPath) + packageJson.dependencies = { - ...(packageJson.dependencies || {}), + ...packageJson.dependencies, ...dependencies, } - fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, undefined, 2)) + + fs.writeJSONSync(packageJsonPath, packageJson, { spaces: 2 }) + spinner.succeed( - `Added ${numOfDeps} framework dependencies to ${packageJsonLink}` + `Added ${numberOfDependencies} framework dependencies to ${packageJsonLink}` ) } export function installProjectPackages(projectPath) { const spinner = ora("Running 'yarn install'...") + spinner.start() + try { - execa.sync('yarn install', { + execa.commandSync('yarn install', { cwd: projectPath, shell: true, }) @@ -89,21 +93,22 @@ export function installProjectPackages(projectPath) { 'file://' + path.join(projectPath, 'yarn-error.log') )} for more information.` ) + console.log('-'.repeat(80)) } } export async function copyFrameworkFilesToProject( projectPath, - packages = frameworkPkgJsonFiles() + packageJsonPaths = getFrameworkPackageJsonPaths() ) { // Loop over every package, delete all existing files, copy over the new files, // and fix binaries. - const packagesFiles = await frameworkPackagesFiles(packages) + const packagesFiles = await getFrameworkPackagesFiles(packageJsonPaths) - const packageNamesToPaths = packages.reduce( + const packageNamesToPaths = packageJsonPaths.reduce( (packageNamesToPaths, packagePath) => { - packageNamesToPaths[packageJsonName(packagePath)] = + packageNamesToPaths[getPackageJsonName(packagePath)] = path.dirname(packagePath) return packageNamesToPaths },