import assert from 'node:assert';
import childProcess from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import url from 'node:url';
import prettier from 'prettier';
import ts from 'typescript';
export function localRepoPath(...paths: ReadonlyArray<string>): string {
const resourcesDir = path.dirname(url.fileURLToPath(import.meta.url));
const repoDir = path.join(resourcesDir, '..');
return path.join(repoDir, ...paths);
}
interface MakeTmpDirReturn {
tmpDirPath: (...paths: ReadonlyArray<string>) => string;
}
export function makeTmpDir(
name: string,
clear: boolean = true,
): MakeTmpDirReturn {
const tmpDir = path.join(os.tmpdir(), name);
if (clear) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
fs.mkdirSync(tmpDir, { recursive: true });
return {
tmpDirPath: (...paths) => path.join(tmpDir, ...paths),
};
}
interface NPMOptions extends SpawnOptions {
quiet?: boolean;
}
export function npm(options?: NPMOptions) {
let npmCmd;
let globalOptions = options?.quiet === true ? ['--quiet'] : [];
if (process.platform === 'win32') {
npmCmd = 'cmd';
globalOptions = ['/c', 'npm.cmd', ...globalOptions];
} else {
npmCmd = 'npm';
}
return {
run(...args: ReadonlyArray<string>): void {
spawn(npmCmd, [...globalOptions, 'run', ...args], options);
},
runOutput(...args: ReadonlyArray<string>): string {
return spawnOutput(npmCmd, [...globalOptions, 'run', ...args], options);
},
install(...args: ReadonlyArray<string>): void {
spawn(npmCmd, [...globalOptions, 'install', ...args], options);
},
ci(...args: ReadonlyArray<string>): void {
spawn(npmCmd, [...globalOptions, 'ci', ...args], options);
},
exec(...args: ReadonlyArray<string>): void {
spawn(npmCmd, [...globalOptions, 'exec', ...args], options);
},
version(...args: ReadonlyArray<string>): void {
spawn(npmCmd, [...globalOptions, 'version', ...args], options);
},
pack(...args: ReadonlyArray<string>): string {
return spawnOutput(npmCmd, [...globalOptions, 'pack', ...args], options);
},
diff(...args: ReadonlyArray<string>): string {
return spawnOutput(npmCmd, [...globalOptions, 'diff', ...args], options);
},
view(...args: ReadonlyArray<string>): string {
return spawnOutput(npmCmd, [...globalOptions, 'view', ...args], options);
},
};
}
interface GITOptions extends SpawnOptions {
quiet?: boolean;
}
export function git(options?: GITOptions) {
const cmdOptions = options?.quiet === true ? ['--quiet'] : [];
return {
add(...args: ReadonlyArray<string>): void {
spawn('git', ['add', ...cmdOptions, ...args], options);
},
commit(...args: ReadonlyArray<string>): void {
spawn('git', ['commit', ...cmdOptions, ...args], options);
},
clone(...args: ReadonlyArray<string>): void {
spawn('git', ['clone', ...cmdOptions, ...args], options);
},
checkout(...args: ReadonlyArray<string>): void {
spawn('git', ['checkout', ...cmdOptions, ...args], options);
},
fetch(...args: ReadonlyArray<string>): void {
spawn('git', ['fetch', ...cmdOptions, ...args], options);
},
status(...args: ReadonlyArray<string>): string {
return spawnOutput('git', ['status', ...cmdOptions, ...args], options);
},
revParse(...args: ReadonlyArray<string>): string {
return spawnOutput('git', ['rev-parse', ...cmdOptions, ...args], options);
},
revList(...args: ReadonlyArray<string>): Array<string> {
const allArgs = ['rev-list', ...cmdOptions, ...args];
const result = spawnOutput('git', allArgs, options);
return result === '' ? [] : result.split('\n');
},
tagExists(tag: string): boolean {
const result = childProcess.spawnSync(
'git',
['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`],
{
stdio: 'ignore',
...options,
},
);
return result.status === 0;
},
catFile(...args: ReadonlyArray<string>): string {
return spawnOutput('git', ['cat-file', ...cmdOptions, ...args], options);
},
log(...args: ReadonlyArray<string>): string {
return spawnOutput('git', ['log', ...cmdOptions, ...args], options);
},
};
}
interface SpawnOptions {
cwd?: string;
env?: typeof process.env;
shell?: boolean;
}
function spawnOutput(
command: string,
args: ReadonlyArray<string>,
options?: SpawnOptions,
): string {
const result = childProcess.spawnSync(command, args, {
maxBuffer: 10 * 1024 * 1024,
stdio: ['inherit', 'pipe', 'inherit'],
encoding: 'utf-8',
...options,
});
if (result.status !== 0) {
throw new Error(`Command failed: ${command} ${args.join(' ')}`);
}
return result.stdout.toString().trimEnd();
}
function spawn(
command: string,
args: ReadonlyArray<string>,
options?: SpawnOptions,
): void {
const result = childProcess.spawnSync(command, args, {
stdio: 'inherit',
...options,
});
if (result.status !== 0) {
throw new Error(`Command failed: ${command} ${args.join(' ')}`);
}
}
function* readdirRecursive(dirPath: string): Generator<{
name: string;
filepath: string;
stats: fs.Stats;
}> {
for (const name of fs.readdirSync(dirPath)) {
const filepath = path.join(dirPath, name);
const stats = fs.lstatSync(filepath);
if (stats.isDirectory()) {
yield* readdirRecursive(filepath);
} else {
yield { name, filepath, stats };
}
}
}
export function showDirStats(dirPath: string): void {
const fileTypes: {
[filetype: string]: { filepaths: Array<string>; size: number };
} = {};
let totalSize = 0;
for (const { name, filepath, stats } of readdirRecursive(dirPath)) {
const ext = name.split('.').slice(1).join('.');
const filetype = ext ? '*.' + ext : name;
fileTypes[filetype] ??= { filepaths: [], size: 0 };
totalSize += stats.size;
fileTypes[filetype].size += stats.size;
fileTypes[filetype].filepaths.push(filepath);
}
const stats: Array<[string, number]> = [];
for (const [filetype, typeStats] of Object.entries(fileTypes)) {
const numFiles = typeStats.filepaths.length;
if (numFiles > 1) {
stats.push([filetype + ' x' + numFiles, typeStats.size]);
} else {
const relativePath = path.relative(dirPath, typeStats.filepaths[0]);
stats.push([relativePath, typeStats.size]);
}
}
stats.sort((a, b) => b[1] - a[1]);
const prettyStats = stats.map(([type, size]) => [
type,
(size / 1024).toFixed(2) + ' KB',
]);
const typeMaxLength = Math.max(...prettyStats.map((x) => x[0].length));
const sizeMaxLength = Math.max(...prettyStats.map((x) => x[1].length));
for (const [type, size] of prettyStats) {
console.log(
type.padStart(typeMaxLength) + ' | ' + size.padStart(sizeMaxLength),
);
}
console.log('-'.repeat(typeMaxLength + 3 + sizeMaxLength));
const totalMB = (totalSize / 1024 / 1024).toFixed(2) + ' MB';
console.log(
'Total'.padStart(typeMaxLength) + ' | ' + totalMB.padStart(sizeMaxLength),
);
}
const prettierConfig = JSON.parse(
fs.readFileSync(localRepoPath('.prettierrc'), 'utf-8'),
);
export async function prettify(
filepath: string,
body: string,
): Promise<string> {
return prettier.format(body, {
filepath,
...prettierConfig,
});
}
export function writeGeneratedFile(filepath: string, body: string): void {
fs.mkdirSync(path.dirname(filepath), { recursive: true });
fs.writeFileSync(filepath, body);
}
export function buildCJSDevModeStub(
devModeSpecifier: string,
targetSpecifier: string,
): string {
return [
`const { enableDevMode } = require('${devModeSpecifier}');`,
'enableDevMode();',
`module.exports = require('${targetSpecifier}');`,
].join('\n');
}
export function buildESMDevModeStub(
devModeSpecifier: string,
targetSpecifier: string,
): string {
return [
`import { enableDevMode } from '${devModeSpecifier}';`,
'enableDevMode();',
`export * from '${targetSpecifier}';`,
].join('\n');
}
export function getReleaseDistTag(
version: string,
latestVersion: string,
): string {
const { major, prerelease } = parseSemVer(version);
if (prerelease != null) {
return prerelease.split('.')[0];
}
return major >= parseSemVer(latestVersion).major
? 'latest'
: `latest-${major}`;
}
export function isLatestReleaseVersion(
version: string,
latestVersion: string,
): boolean {
const { major, prerelease } = parseSemVer(version);
return prerelease == null && major >= parseSemVer(latestVersion).major;
}
export function isPrereleaseVersion(version: string): boolean {
return parseSemVer(version).prerelease != null;
}
function parseSemVer(version: string): {
major: number;
prerelease: string | null;
} {
const versionMatch =
/^(?<major>\d+)\.\d+\.\d+(?:-(?<prerelease>[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
version,
);
if (versionMatch?.groups == null) {
throw new Error('Version does not match semver spec: ' + version);
}
const { major, prerelease } = versionMatch.groups;
return {
major: Number(major),
prerelease: prerelease ?? null,
};
}
interface PackageJSON {
description: string;
version: string;
private?: boolean;
repository?: { url?: string };
scripts?: { [name: string]: string };
type?: string;
sideEffects?: false | Array<string>;
exports: ConditionalExports;
types?: string;
typesVersions: { [ranges: string]: { [path: string]: Array<string> } };
devDependencies?: { [name: string]: string };
main?: string;
module?: string;
}
export interface ConditionalExports {
[path: string]:
| string
| PlatformConditionalExports
| EnvironmentConditionalExports;
}
interface EnvironmentConditionalExports {
development: PlatformConditionalExports;
default: PlatformConditionalExports;
}
export interface PlatformConditionalExports {
module: string;
bun: string;
'module-sync': string;
node: string;
require: string;
default: string;
}
export function readPackageJSON(
dirPath: string = localRepoPath(),
): PackageJSON {
const filepath = path.join(dirPath, 'package.json');
return JSON.parse(fs.readFileSync(filepath, 'utf-8'));
}
export function readPackageJSONAtRef(ref: string): PackageJSON {
const packageJSONAtRef = git().catFile('blob', `${ref}:package.json`);
return JSON.parse(packageJSONAtRef);
}
export function readTSConfig(overrides?: any): ts.CompilerOptions {
const tsConfigPath = localRepoPath('tsconfig.json');
const { config: tsConfig, error: tsConfigError } =
ts.parseConfigFileTextToJson(
tsConfigPath,
fs.readFileSync(tsConfigPath, 'utf-8'),
);
assert(
tsConfigError === undefined,
'Fail to parse config: ' + JSON.stringify(tsConfigError),
);
assert(
tsConfig.compilerOptions !== undefined,
'"tsconfig.json" should have `compilerOptions`',
);
const { options: tsOptions, errors: tsOptionsErrors } =
ts.convertCompilerOptionsFromJson(
{ ...tsConfig.compilerOptions, ...overrides },
process.cwd(),
);
assert(
tsOptionsErrors.length === 0,
'Fail to parse options: ' + JSON.stringify(tsOptionsErrors),
);
return tsOptions;
}