diff --git a/package.json b/package.json index 69bc62e8..e58ea449 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@metamask/eslint-config-jest": "^12.0.0", "@metamask/eslint-config-nodejs": "^12.0.0", "@metamask/eslint-config-typescript": "^12.0.0", + "@npmcli/package-json": "^5.0.0", "@types/jest": "^28.1.6", "@types/node": "^16", "@typescript-eslint/eslint-plugin": "^5.43.0", diff --git a/scripts/use-preview-builds.ts b/scripts/use-preview-builds.ts new file mode 100755 index 00000000..0cb9ae0c --- /dev/null +++ b/scripts/use-preview-builds.ts @@ -0,0 +1,234 @@ +#!yarn ts-node + +import PackageJson from '@npmcli/package-json'; +import execa from 'execa'; +import fs from 'node:fs/promises'; + +// Previews object displayed by the CI when you ask for preview builds. +type Arguments = { + // Path to the project that will use the preview builds. + path: string; + // Previews object. + previews: Previews; +}; + +// Previews object displayed by the CI when you ask for preview builds. +type Previews = Record; + +// A `yarn why --json` line entry. +type YarnWhyEntry = { + children: Record< + string, + { + descriptor: string; + } + >; +}; + +class UsageError extends Error { + constructor(message: string) { + // 1 because `ts-node` is being used as a launcher, so argv[0] is ts-node "bin.js" + const bin: string = process.argv[1]; + + super( + `usage: ${bin} \n${ + message ? `\nerror: ${message}\n` : '' + }`, + ); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); + +/** + * Checks if a path is a directory or not. + * + * @param path - Path to check. + * @returns True if path is a directory, false otherwise. + */ +async function isDir(path: string) { + return (await fs.stat(path)).isDirectory(); +} + +/** + * Checks if a path is a file or not. + * + * @param path - Path to check. + * @returns True if path is a file, false otherwise. + */ +async function isFile(path: string) { + // We check for a directory here, since the path could be a symlink or a "sort-of" file (when using the <(...) notation) + return !(await isDir(path)); +} + +/** + * Verify and read previews JSON file. + * + * @param path - Previews JSON file path. + * @returns Previews object. + * @throws If the previews JSON file cannot be read. + */ +async function verifyAndReadPreviewsJson(path: string) { + if (!(await isFile(path))) { + throw new UsageError(`${path}: is not a file`); + } + const fileContent = await fs.readFile(path); + + // Not 100% type safe, but we assume the caller knows how to use the script + return JSON.parse(fileContent.toString()) as Previews; +} + +/** + * Verify project path. + * + * @param path - Project path. + * @throws If the project path is not compatible. + */ +async function verifyProjectPath(path: string) { + if (!(await isDir(path))) { + throw new UsageError(`${path}: is not a directory`); + } + + const pkgJsonPath = `${path}/package.json`; + if (!(await isFile(pkgJsonPath))) { + throw new UsageError(`${pkgJsonPath}: no such file`); + } +} + +/** + * Parse and verifies that each arguments is well-formatted. + * + * @returns Parsed arguments as an `Arguments` object. + */ +async function parseAndVerifyArguments(): Promise { + if (process.argv.length !== 4) { + throw new UsageError('not enough arguments'); + } + // 1: ts-node (bin.js), 2: This script, 3: Project path, 4: Previews JSON path + const [, , path, previewsJsonPath] = process.argv as [ + string, + string, + string, + string, + ]; + + await verifyProjectPath(path); + const previews = await verifyAndReadPreviewsJson(previewsJsonPath); + + return { path, previews }; +} + +/** + * Compute the list of in-use versions for a given package. + * + * @param path - Project path. + * @param pkg - Package name. + * @returns The list of in-use versions for the given package. + */ +async function getPkgVersions(path: string, pkg: string): Promise { + const { stdout } = await execa('yarn', ['--cwd', path, 'why', pkg, '--json']); + + // Stops early, to avoid having JSON parsing error on empty lines + if (stdout.trim() === '') { + return []; + } + + // Each `yarn why --json` lines is a JSON object, so parse it and "type" it + const entries = stdout + .split('\n') + .map((line) => JSON.parse(line) as YarnWhyEntry); + + const versions: Set = new Set(); + for (const entry of entries) { + const { children } = entry; + + for (const [key, value] of Object.entries(children)) { + const { descriptor } = value; + + // We only keep the current package information and skip those "virtual" resolutions (which + // seems internal to yarn) + if (key.startsWith(pkg) && !descriptor.includes('@virtual:')) { + versions.add(descriptor); + } + } + } + + return Array.from(versions); +} + +/** + * Gets the original package name from its preview package name. + * + * @param pkgPreviewName - Preview package name. + * @returns The original package name. + */ +function getPkgOriginalName(pkgPreviewName: string): string { + return pkgPreviewName.replace('@metamask-previews/', '@metamask/'); +} + +/** + * Gets the preview package name from its original package name. + * + * @param pkg - Package name. + * @param previews - Records of all preview packages that will be used to "infere" the preview package name. + * @returns The preview package name. + */ +function getPkgPreviewName(pkg: string, previews: Previews): string { + const pkgPreviewName = pkg.replace('@metamask/', '@metamask-previews/'); + + if (!(pkgPreviewName in previews)) { + throw new Error( + `unable to find package "${pkgPreviewName}" ("${pkg}") in previews`, + ); + } + // At this point, we know it's defined so we can safely force the type + const pkgPreviewVersion: string = previews[pkgPreviewName]; + + return `npm:${pkgPreviewName}@${pkgPreviewVersion}`; +} + +/** + * Update the "resolutions" entry from a "package.json" file. + * + * @param path - Project path that will be used to find the "package.json" file. + * @param previews - Records of all preview packages. + */ +async function updateResolutions(path: string, previews: Previews) { + const pkgJson = await PackageJson.load(path); + + const resolutions = {}; + // for (const pkg of PACKAGES) { + for (const pkg of Object.keys(previews).map(getPkgOriginalName)) { + console.log(`:: updating resolutions for "${pkg}"`); + + const pkgVersions = await getPkgVersions(path, pkg); + for (const pkgVersion of pkgVersions) { + resolutions[pkgVersion] = getPkgPreviewName(pkg, previews); + } + } + console.log(':: resolutions will be updated with:'); + console.log(resolutions); + + pkgJson.update({ + resolutions: { + ...pkgJson.content.resolutions, + ...resolutions, + }, + }); + await pkgJson.save(); +} + +/** + * The entrypoint to this script. + */ +async function main() { + const { previews, path } = await parseAndVerifyArguments(); + + console.log(`:: will update project: ${path}`); + console.log(':: with previews: ', previews); + + await updateResolutions(path, previews); +} diff --git a/yarn.lock b/yarn.lock index 4a030da9..ec4fc3b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1683,6 +1683,7 @@ __metadata: "@metamask/eslint-config-jest": "npm:^12.0.0" "@metamask/eslint-config-nodejs": "npm:^12.0.0" "@metamask/eslint-config-typescript": "npm:^12.0.0" + "@npmcli/package-json": "npm:^5.0.0" "@types/jest": "npm:^28.1.6" "@types/node": "npm:^16" "@typescript-eslint/eslint-plugin": "npm:^5.43.0"