Skip to content

Commit

Permalink
feat: add scripts/use-preview-builds.ts
Browse files Browse the repository at this point in the history
  • Loading branch information
ccharly committed Sep 5, 2024
1 parent 1844df0 commit 29a2770
Show file tree
Hide file tree
Showing 3 changed files with 236 additions and 0 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
234 changes: 234 additions & 0 deletions scripts/use-preview-builds.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

// A `yarn why <pkg> --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} <project-path> <previews-json>\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<Arguments> {
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<string[]> {
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<string> = 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);
}
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit 29a2770

Please sign in to comment.