chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
const { readJsonSync, writeJsonSync } = require('fs-extra');
|
||||
const { join } = require('path');
|
||||
|
||||
const [package, dependency] = process.argv.slice(2);
|
||||
|
||||
const pkgPath = join(__dirname, '../packages', package, 'package.json');
|
||||
const packageJson = readJsonSync(pkgPath);
|
||||
const version = packageJson.version;
|
||||
packageJson.dependencies[dependency] = version;
|
||||
|
||||
writeJsonSync(pkgPath, packageJson, { spaces: 2 });
|
||||
@@ -0,0 +1,122 @@
|
||||
import axios from 'axios';
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { gte, major, minor, parse } from 'semver';
|
||||
|
||||
async function addMigrationPackageGroup(
|
||||
angularPackageMigrations: Record<string, any>,
|
||||
targetNxVersion: string,
|
||||
targetNxMigrationVersion: string,
|
||||
packageVersionMap: Map<string, string>,
|
||||
isPrerelease: boolean
|
||||
) {
|
||||
const existingEntry =
|
||||
angularPackageMigrations.packageJsonUpdates[targetNxVersion];
|
||||
angularPackageMigrations.packageJsonUpdates[targetNxVersion] = {
|
||||
version: `${targetNxMigrationVersion}`,
|
||||
};
|
||||
|
||||
const angularCoreRequirement = await getAngularCoreRequirement();
|
||||
if (angularCoreRequirement) {
|
||||
angularPackageMigrations.packageJsonUpdates[targetNxVersion].requires = {
|
||||
'@angular/core': angularCoreRequirement,
|
||||
};
|
||||
} else if (existingEntry?.requires) {
|
||||
// latest >= next: preserve the requires from the pre-release entry and
|
||||
// update the upper bound to the stable major.minor.0
|
||||
const angularCoreVersion = packageVersionMap.get('@angular/core');
|
||||
const { major: majorVersion, minor: minorVersion } =
|
||||
parse(angularCoreVersion)!;
|
||||
const existingReq = existingEntry.requires['@angular/core'] as string;
|
||||
const updatedReq = existingReq.replace(
|
||||
/<.*$/,
|
||||
`<${majorVersion}.${minorVersion}.0`
|
||||
);
|
||||
|
||||
angularPackageMigrations.packageJsonUpdates[targetNxVersion].requires = {
|
||||
'@angular/core': updatedReq,
|
||||
};
|
||||
console.log(
|
||||
'ℹ️ - The `@angular/core` latest version is greater than or equal to the next version. Preserving existing migration requirements.'
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
'❗️ - The `@angular/core` latest version is greater than or equal to the next version and no existing entry found.\n' +
|
||||
' Please manually add the requires field.'
|
||||
);
|
||||
}
|
||||
|
||||
angularPackageMigrations.packageJsonUpdates[targetNxVersion].packages = {};
|
||||
for (const [pkgName, version] of packageVersionMap) {
|
||||
if (
|
||||
pkgName.startsWith('@angular/') &&
|
||||
![
|
||||
'@angular/cli',
|
||||
'@angular/core',
|
||||
'@angular/material',
|
||||
'@angular/cdk',
|
||||
'@angular/google-maps',
|
||||
'@angular/ssr',
|
||||
'@angular/pwa',
|
||||
'@angular/build',
|
||||
].includes(pkgName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const packageUpdate: any = {
|
||||
version: isPrerelease ? version : `~${version}`,
|
||||
alwaysAddToPackageJson: pkgName === '@angular/core',
|
||||
};
|
||||
if (pkgName === '@angular/cli') {
|
||||
packageUpdate.ignorePackageGroup = true;
|
||||
packageUpdate.ignoreMigrations = true;
|
||||
}
|
||||
|
||||
angularPackageMigrations.packageJsonUpdates[targetNxVersion].packages[
|
||||
pkgName
|
||||
] = packageUpdate;
|
||||
}
|
||||
}
|
||||
|
||||
async function getAngularCoreRequirement(): Promise<string | null> {
|
||||
const angularCoreMetadata = await axios.get(
|
||||
'https://registry.npmjs.org/@angular/core'
|
||||
);
|
||||
const { latest, next } = angularCoreMetadata.data['dist-tags'];
|
||||
// When latest >= next (e.g. a stable GA also published to the `next` tag),
|
||||
// `latest` is the new version, not the previous one - so it can't seed the
|
||||
// requires lower bound. Bail and let the caller preserve the pre-release
|
||||
// entry's requires or warn for manual entry.
|
||||
if (gte(latest, next)) {
|
||||
return null;
|
||||
}
|
||||
return `>=${major(latest)}.${minor(latest)}.0 <${next}`;
|
||||
}
|
||||
|
||||
export async function buildMigrations(
|
||||
packageVersionMap: Map<string, string>,
|
||||
targetNxVersion: string,
|
||||
targetNxMigrationVersion: string,
|
||||
isPrerelease: boolean
|
||||
) {
|
||||
console.log('⏳ - Writing migrations...');
|
||||
const pathToMigrationsJsonFile = 'packages/angular/migrations.json';
|
||||
const angularPackageMigrations = JSON.parse(
|
||||
readFileSync(pathToMigrationsJsonFile, { encoding: 'utf-8' })
|
||||
);
|
||||
|
||||
await addMigrationPackageGroup(
|
||||
angularPackageMigrations,
|
||||
targetNxVersion,
|
||||
targetNxMigrationVersion,
|
||||
packageVersionMap,
|
||||
isPrerelease
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
pathToMigrationsJsonFile,
|
||||
JSON.stringify(angularPackageMigrations, null, 2)
|
||||
);
|
||||
|
||||
console.log('✅ - Wrote migrations');
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import axios from 'axios';
|
||||
import { coerce, gt, maxSatisfying, SemVer } from 'semver';
|
||||
|
||||
type PackageSpec = string | { main: string; children: string[] };
|
||||
const packagesToUpdate: PackageSpec[] = [
|
||||
{
|
||||
main: '@angular/cli',
|
||||
children: [
|
||||
'@angular-devkit/build-angular',
|
||||
'@angular-devkit/core',
|
||||
'@angular-devkit/schematics',
|
||||
'@angular/build',
|
||||
'@angular/pwa',
|
||||
'@angular/ssr',
|
||||
'@schematics/angular',
|
||||
],
|
||||
},
|
||||
{
|
||||
main: '@angular-devkit/architect',
|
||||
children: ['@angular-devkit/build-webpack'],
|
||||
},
|
||||
{
|
||||
main: '@angular/core',
|
||||
children: [
|
||||
'@angular/common',
|
||||
'@angular/compiler',
|
||||
'@angular/compiler-cli',
|
||||
'@angular/localize',
|
||||
'@angular/platform-browser',
|
||||
'@angular/platform-server',
|
||||
'@angular/router',
|
||||
],
|
||||
},
|
||||
{
|
||||
main: '@angular/material',
|
||||
children: ['@angular/cdk', '@angular/google-maps'],
|
||||
},
|
||||
'ng-packagr',
|
||||
];
|
||||
|
||||
// Resolved from `@angular/core`'s peerDependencies (not its own dist-tags) so it
|
||||
// stays aligned with whatever range Angular actually accepts. Only zone.js: rxjs
|
||||
// is intentionally excluded - Angular accepts a wide rxjs range (6 || 7), so we
|
||||
// don't pin/bump it from here (which would otherwise force it into the migration).
|
||||
const angularCorePeerResolvedPackages = ['zone.js'] as const;
|
||||
|
||||
export async function fetchVersionsFromRegistry(
|
||||
targetVersion: 'latest' | 'next'
|
||||
) {
|
||||
console.log('⏳ - Fetching versions from registry...');
|
||||
|
||||
const packageVersionMap = new Map<string, string>();
|
||||
await Promise.all(
|
||||
packagesToUpdate.map((pkgSpec) =>
|
||||
fetch(packageVersionMap, pkgSpec, targetVersion)
|
||||
)
|
||||
);
|
||||
|
||||
await resolveAngularCorePeerDependencies(packageVersionMap);
|
||||
|
||||
console.log('✅ - Finished fetching versions from registry');
|
||||
|
||||
return packageVersionMap;
|
||||
}
|
||||
|
||||
async function resolveAngularCorePeerDependencies(
|
||||
packageVersionMap: Map<string, string>
|
||||
) {
|
||||
const angularCoreVersion = packageVersionMap.get('@angular/core')!;
|
||||
const { data } = await axios.get(
|
||||
`https://registry.npmjs.org/@angular/core/${angularCoreVersion}`
|
||||
);
|
||||
const peerDependencies: Record<string, string> = data.peerDependencies ?? {};
|
||||
|
||||
await Promise.all(
|
||||
angularCorePeerResolvedPackages.map(async (pkgName) => {
|
||||
const peerRange = peerDependencies[pkgName];
|
||||
if (!peerRange) {
|
||||
console.warn(
|
||||
` ⚠️ @angular/core@${angularCoreVersion} declares no peerDependency for "${pkgName}", skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await axios.get(`https://registry.npmjs.org/${pkgName}`);
|
||||
const resolved = maxSatisfying(
|
||||
Object.keys(data.versions ?? {}),
|
||||
peerRange,
|
||||
{ includePrerelease: false }
|
||||
);
|
||||
if (!resolved) {
|
||||
console.warn(
|
||||
` ⚠️ Could not resolve a published version of "${pkgName}" matching peer range "${peerRange}"`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
packageVersionMap.set(pkgName, resolved);
|
||||
console.log(` ${pkgName}: ${resolved}`);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function fetch(
|
||||
packageVersionMap: Map<string, string>,
|
||||
pkgSpec: PackageSpec,
|
||||
targetVersion: 'latest' | 'next'
|
||||
) {
|
||||
function setPackageVersions(pkgSpec: PackageSpec, version: string) {
|
||||
if (typeof pkgSpec === 'string') {
|
||||
packageVersionMap.set(pkgSpec, version);
|
||||
return;
|
||||
}
|
||||
|
||||
packageVersionMap.set(pkgSpec.main, version);
|
||||
for (const child of pkgSpec.children) {
|
||||
packageVersionMap.set(child, version);
|
||||
}
|
||||
}
|
||||
|
||||
const pkgName = typeof pkgSpec === 'string' ? pkgSpec : pkgSpec.main;
|
||||
// set it to empty initially to keep the order of the specified packages
|
||||
setPackageVersions(pkgSpec, '');
|
||||
|
||||
const response = await axios.get(`https://registry.npmjs.org/${pkgName}`);
|
||||
const distTags = response.data['dist-tags'];
|
||||
const latestVersion = distTags['latest'];
|
||||
if (targetVersion === 'latest') {
|
||||
setPackageVersions(pkgSpec, latestVersion);
|
||||
console.log(` ${pkgName}: ${latestVersion}`);
|
||||
} else {
|
||||
const nextVersion = distTags['next'] ?? latestVersion;
|
||||
const coercedNextVersion = coerce(nextVersion) as SemVer;
|
||||
// check which is the greater version
|
||||
const versionToUse = gt(coercedNextVersion, latestVersion)
|
||||
? nextVersion
|
||||
: latestVersion;
|
||||
setPackageVersions(pkgSpec, versionToUse);
|
||||
console.log(` ${pkgName}: ${versionToUse}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* USAGE:
|
||||
*
|
||||
* Run the following command from the root of the workspace.
|
||||
* Replace the versions with the correct target versions
|
||||
*
|
||||
* pnpm ts-node scripts/angular-support-upgrades/init-upgrade.ts --angularVersion=next --targetNxVersion=15.5.0 --targetNxMigrationVersion=15.5.0-beta.0
|
||||
*
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import { prerelease } from 'semver';
|
||||
import { buildMigrations } from './build-migrations';
|
||||
import { fetchVersionsFromRegistry } from './fetch-versions-from-registry';
|
||||
import { updatePackageDependencies } from './update-package-jsons';
|
||||
import { updateVersionUtils } from './update-version-utils';
|
||||
|
||||
const yargs = require('yargs');
|
||||
const { hideBin } = require('yargs/helpers');
|
||||
const argv = yargs(hideBin(process.argv)).argv;
|
||||
|
||||
if (
|
||||
!argv.angularVersion &&
|
||||
!argv.targetNxVersion &&
|
||||
!argv.targetNxMigrationVersion
|
||||
) {
|
||||
throw new Error(
|
||||
'You need to provide --angularVersion=(latest|next) and --targetNxVersion=versionString and --targetNxMigrationVersion=versionString'
|
||||
);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const packageVersionMap = await fetchVersionsFromRegistry(
|
||||
argv.angularVersion
|
||||
);
|
||||
|
||||
const isPrerelease =
|
||||
prerelease(packageVersionMap.get('@angular/cli')!) !== null;
|
||||
await updatePackageDependencies(packageVersionMap, isPrerelease);
|
||||
await buildMigrations(
|
||||
packageVersionMap,
|
||||
argv.targetNxVersion,
|
||||
argv.targetNxMigrationVersion,
|
||||
isPrerelease
|
||||
);
|
||||
updateVersionUtils(packageVersionMap, isPrerelease);
|
||||
|
||||
console.log('⏳ - Installing packages...');
|
||||
execSync('pnpm install', {
|
||||
stdio: 'inherit',
|
||||
encoding: 'utf8',
|
||||
|
||||
windowsHide: false,
|
||||
});
|
||||
console.log('✅ - Finished installing packages!');
|
||||
|
||||
console.log('⏳ - Formatting files...');
|
||||
execSync('pnpm nx format', {
|
||||
stdio: 'inherit',
|
||||
encoding: 'utf8',
|
||||
windowsHide: false,
|
||||
});
|
||||
console.log('✅ - Finished creating migrations!');
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,36 @@
|
||||
import { dump, load } from '@zkochan/js-yaml';
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
|
||||
function updatePnpmCatalogDefinitions(
|
||||
packageVersionMap: Map<string, string>,
|
||||
isPrerelease: boolean
|
||||
) {
|
||||
const pnpmWorkspaceYaml = readFileSync('pnpm-workspace.yaml', 'utf-8');
|
||||
const workspaceData = load(pnpmWorkspaceYaml)!;
|
||||
|
||||
for (const [pkgName, version] of packageVersionMap.entries()) {
|
||||
const versionToUse = isPrerelease ? version : `~${version}`;
|
||||
if (workspaceData.catalogs.angular[pkgName]) {
|
||||
workspaceData.catalogs.angular[pkgName] = versionToUse;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
'pnpm-workspace.yaml',
|
||||
dump(workspaceData, {
|
||||
indent: 2,
|
||||
quotingType: '"',
|
||||
forceQuotes: true,
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
|
||||
export async function updatePackageDependencies(
|
||||
packageVersionMap: Map<string, string>,
|
||||
isPrerelease: boolean
|
||||
) {
|
||||
console.log('⏳ - Updating Pnpm Catalog definitions...');
|
||||
updatePnpmCatalogDefinitions(packageVersionMap, isPrerelease);
|
||||
console.log('✅ - Updated Pnpm Catalog definitions');
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
|
||||
function updateAngularVersionUtils(
|
||||
packageVersionMap: Map<string, string>,
|
||||
isPrerelease: boolean
|
||||
) {
|
||||
const pathToFile = 'packages/angular/src/utils/versions.ts';
|
||||
let versionUtilContents = readFileSync(pathToFile, { encoding: 'utf-8' });
|
||||
|
||||
const angularVersion = packageVersionMap.get('@angular/core')!;
|
||||
const angularDevkitVersion = packageVersionMap.get('@angular/cli')!;
|
||||
const ngPackagrVersion = packageVersionMap.get('ng-packagr')!;
|
||||
const zoneJsVersion = packageVersionMap.get('zone.js');
|
||||
const rxjsVersion = packageVersionMap.get('rxjs');
|
||||
|
||||
versionUtilContents = versionUtilContents.replace(
|
||||
/export const angularVersion = '.+';/,
|
||||
`export const angularVersion = '${
|
||||
isPrerelease ? angularVersion : `~${angularVersion}`
|
||||
}';`
|
||||
);
|
||||
versionUtilContents = versionUtilContents.replace(
|
||||
/export const angularDevkitVersion = '.+';/,
|
||||
`export const angularDevkitVersion = '${
|
||||
isPrerelease ? angularDevkitVersion : `~${angularDevkitVersion}`
|
||||
}';`
|
||||
);
|
||||
versionUtilContents = versionUtilContents.replace(
|
||||
/export const ngPackagrVersion = '.+';/,
|
||||
`export const ngPackagrVersion = '${
|
||||
isPrerelease ? ngPackagrVersion : `~${ngPackagrVersion}`
|
||||
}';`
|
||||
);
|
||||
if (zoneJsVersion) {
|
||||
versionUtilContents = versionUtilContents.replace(
|
||||
/export const zoneJsVersion = '.+';/,
|
||||
`export const zoneJsVersion = '${
|
||||
isPrerelease ? zoneJsVersion : `~${zoneJsVersion}`
|
||||
}';`
|
||||
);
|
||||
}
|
||||
if (rxjsVersion) {
|
||||
versionUtilContents = versionUtilContents.replace(
|
||||
/export const rxjsVersion = '.+';/,
|
||||
`export const rxjsVersion = '${
|
||||
isPrerelease ? rxjsVersion : `~${rxjsVersion}`
|
||||
}';`
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(pathToFile, versionUtilContents);
|
||||
}
|
||||
|
||||
function updateWorkspaceAngularVersionUtils(
|
||||
packageVersionMap: Map<string, string>,
|
||||
isPrerelease: boolean
|
||||
) {
|
||||
const pathToFile = 'packages/workspace/src/utils/versions.ts';
|
||||
let versionUtilContents = readFileSync(pathToFile, { encoding: 'utf-8' });
|
||||
|
||||
const angularDevkitVersion = packageVersionMap.get('@angular/cli')!;
|
||||
|
||||
versionUtilContents = versionUtilContents.replace(
|
||||
/export const angularCliVersion = '.+';/,
|
||||
`export const angularCliVersion = '${
|
||||
isPrerelease ? angularDevkitVersion : `~${angularDevkitVersion}`
|
||||
}';`
|
||||
);
|
||||
|
||||
writeFileSync(pathToFile, versionUtilContents);
|
||||
}
|
||||
|
||||
export function updateVersionUtils(
|
||||
packageVersionMap: Map<string, string>,
|
||||
isPrerelease: boolean
|
||||
) {
|
||||
console.log('⏳ - Writing Util Files...');
|
||||
updateAngularVersionUtils(packageVersionMap, isPrerelease);
|
||||
updateWorkspaceAngularVersionUtils(packageVersionMap, isPrerelease);
|
||||
console.log('✅ - Wrote Util Files');
|
||||
}
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
rm -rf build
|
||||
nx run-many --target=build --parallel
|
||||
@@ -0,0 +1,126 @@
|
||||
import * as globby from 'tinyglobby';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as octokit from 'octokit';
|
||||
import { output } from '@nx/devkit';
|
||||
import { Octokit } from 'octokit';
|
||||
|
||||
async function main() {
|
||||
const codeowners = fs.readFileSync(
|
||||
path.join(__dirname, '../CODEOWNERS'),
|
||||
'utf-8'
|
||||
);
|
||||
const codeownersLines = codeowners
|
||||
.split('\n')
|
||||
.filter((line) => line.trim().length > 0 && !line.startsWith('#'));
|
||||
|
||||
const mismatchedPatterns: string[] = [];
|
||||
const mismatchedOwners: string[] = [];
|
||||
|
||||
const gh = new octokit.Octokit({
|
||||
auth: process.env.GITHUB_TOKEN,
|
||||
}).rest;
|
||||
const cache: Map<string, boolean> = new Map();
|
||||
|
||||
for (const line of codeownersLines) {
|
||||
// This is perhaps a bit naive, but it should
|
||||
// work for all paths and patterns that do not
|
||||
// contain spaces.
|
||||
const [specifiedPattern, ...owners] = line.split(' ');
|
||||
let foundMatchingFiles = false;
|
||||
|
||||
const patternsToCheck = specifiedPattern.startsWith('/')
|
||||
? [`.${specifiedPattern}`]
|
||||
: [`./${specifiedPattern}`, `**/${specifiedPattern}`];
|
||||
|
||||
for (const pattern of patternsToCheck) {
|
||||
foundMatchingFiles ||=
|
||||
globby.globSync(pattern, {
|
||||
ignore: ['node_modules', 'dist', 'build', '.git'],
|
||||
cwd: path.join(__dirname, '..'),
|
||||
onlyFiles: false,
|
||||
}).length > 0;
|
||||
}
|
||||
if (!foundMatchingFiles) {
|
||||
mismatchedPatterns.push(specifiedPattern);
|
||||
}
|
||||
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
for (let owner of owners) {
|
||||
owner = owner.substring(1); // Remove the @
|
||||
if (owner.includes('/')) {
|
||||
// Its a team.
|
||||
const [org, team] = owner.split('/');
|
||||
let res = cache.get(owner);
|
||||
if (res === undefined) {
|
||||
res = await validateTeam(gh, org, team);
|
||||
cache.set(owner, res);
|
||||
}
|
||||
if (res === false) {
|
||||
mismatchedOwners.push(`${specifiedPattern}: ${owner}`);
|
||||
}
|
||||
} else {
|
||||
let res = cache.get(owner);
|
||||
if (res === undefined) {
|
||||
res = await validateUser(gh, owner);
|
||||
cache.set(owner, res);
|
||||
}
|
||||
if (res === false) {
|
||||
mismatchedOwners.push(`${specifiedPattern}: ${owner}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output.warn({
|
||||
title: `Skipping owner validation because GITHUB_TOKEN is not set.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (mismatchedPatterns.length > 0) {
|
||||
output.error({
|
||||
title: `The following patterns in CODEOWNERS do not match any files:`,
|
||||
bodyLines: mismatchedPatterns.map((e) => `- ${e}`),
|
||||
});
|
||||
}
|
||||
|
||||
if (mismatchedOwners.length > 0) {
|
||||
output.error({
|
||||
title: `The following owners in CODEOWNERS do not exist:`,
|
||||
bodyLines: mismatchedOwners.map((e) => `- ${e}`),
|
||||
});
|
||||
}
|
||||
|
||||
process.exit(mismatchedPatterns.length + mismatchedOwners.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
async function validateTeam(
|
||||
gh: Octokit['rest'],
|
||||
org: string,
|
||||
team: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await gh.teams.getByName({
|
||||
org,
|
||||
team_slug: team,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function validateUser(
|
||||
gh: Octokit['rest'],
|
||||
username: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await gh.users.getByUsername({
|
||||
username,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
function allFilesInDir(dirName) {
|
||||
let res = [];
|
||||
try {
|
||||
fs.readdirSync(dirName).forEach((c) => {
|
||||
const child = path.join(dirName, c);
|
||||
try {
|
||||
const s = fs.statSync(child);
|
||||
if (path.extname(child) === '.ts') {
|
||||
res.push({
|
||||
name: child,
|
||||
content: fs.readFileSync(child).toString(),
|
||||
});
|
||||
} else if (s.isDirectory() && c !== 'node_modules') {
|
||||
res = [...res, ...allFilesInDir(child)];
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
} catch (e) {}
|
||||
return res;
|
||||
}
|
||||
|
||||
function check() {
|
||||
const exceptions = [
|
||||
'packages/create-nx-workspace/bin/create-nx-workspaces.ts',
|
||||
'packages/create-nx-plugin/bin/create-nx-plugin.ts',
|
||||
'packages/workspace/src/command-line/affected.ts',
|
||||
'packages/workspace/src/command-line/report.ts',
|
||||
'packages/workspace/src/command-line/report.spec.ts',
|
||||
'packages/workspace/src/core/file-command-line-utils.ts',
|
||||
'packages/workspace/src/generators/preset/preset.ts',
|
||||
'packages/workspace/src/generators/new/generate-preset.ts',
|
||||
'packages/workspace/src/generators/new/new.spec.ts',
|
||||
'packages/workspace/src/generators/init/init.ts',
|
||||
'packages/workspace/src/migrations/update-8-3-0/update-8-3-0.spec.ts',
|
||||
'packages/workspace/src/migrations/update-8-3-0/update-ng-cli-8-1.ts',
|
||||
'packages/workspace/src/migrations/update-8-12-0/update-package-json-deps.spec.ts',
|
||||
'packages/workspace/src/tasks-runner/task-orchestrator.ts',
|
||||
'packages/nest/src/generators/init/lib/add-dependencies.ts',
|
||||
'packages/nest/src/migrations/update-13-2-0/update-to-nest-8.ts',
|
||||
// cypress v11 migration checks if the TestBed is imported by looking for the import
|
||||
// which is @angular/core/testing. and the tests check for this
|
||||
'packages/cypress/src/migrations/update-15-1-0/cypress-11.spec.ts',
|
||||
'packages/cypress/src/migrations/update-15-1-0/cypress-11.ts',
|
||||
// these migrations looks for projects depending on @angular/core, it doesn't require it
|
||||
'packages/cypress/src/migrations/update-16-4-0/warn-incompatible-angular-cypress.spec.ts',
|
||||
'packages/cypress/src/migrations/update-16-4-0/warn-incompatible-angular-cypress.ts',
|
||||
'packages/cypress/src/migrations/update-20-8-0/update-component-testing-mount-imports.spec.ts',
|
||||
'packages/cypress/src/migrations/update-20-8-0/update-component-testing-mount-imports.ts',
|
||||
'packages/cypress/src/migrations/update-22-1-0/update-angular-component-testing-support.spec.ts',
|
||||
'packages/cypress/src/migrations/update-22-1-0/update-angular-component-testing-support.ts',
|
||||
];
|
||||
|
||||
const files = [
|
||||
...allFilesInDir('packages/create-nx-workspace'),
|
||||
...allFilesInDir('packages/create-nx-plugin'),
|
||||
...allFilesInDir('packages/cypress'),
|
||||
...allFilesInDir('packages/express'),
|
||||
...allFilesInDir('packages/jest'),
|
||||
...allFilesInDir('packages/nest'),
|
||||
...allFilesInDir('packages/node'),
|
||||
...allFilesInDir('packages/react'),
|
||||
...allFilesInDir('packages/web'),
|
||||
...allFilesInDir('packages/workspace'),
|
||||
];
|
||||
|
||||
const invalidFiles = [];
|
||||
files.forEach((f) => {
|
||||
if (f.content.indexOf('@schematics/angular') > -1) {
|
||||
invalidFiles.push(f.name);
|
||||
}
|
||||
if (f.content.indexOf('@angular/') > -1) {
|
||||
invalidFiles.push(f.name);
|
||||
}
|
||||
if (f.content.indexOf("'@angular-devkit/build-angular';") > -1) {
|
||||
invalidFiles.push(f.name);
|
||||
}
|
||||
if (f.content.indexOf('@angular-devkit/build-angular/') > -1) {
|
||||
invalidFiles.push(f.name);
|
||||
}
|
||||
});
|
||||
|
||||
return invalidFiles.filter((f) => !exceptions.includes(f));
|
||||
}
|
||||
|
||||
const invalid = check();
|
||||
if (invalid.length > 0) {
|
||||
console.error(
|
||||
'The following files import @schematics/angular or @angular/* or @angular-devkit/build-angular'
|
||||
);
|
||||
invalid.forEach((e) => console.log(e));
|
||||
process.exit(1);
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
const fs = require('fs');
|
||||
|
||||
function checkLockFiles() {
|
||||
const errors = [];
|
||||
if (fs.existsSync('package-lock.json')) {
|
||||
errors.push(
|
||||
'Invalid occurence of "package-lock.json" file. Please remove it and use only "pnpm-lock.yaml"'
|
||||
);
|
||||
}
|
||||
if (fs.existsSync('bun.lockb')) {
|
||||
errors.push(
|
||||
'Invalid occurence of "bun.lockb" file. Please remove it and use only "pnpm-lock.yaml"'
|
||||
);
|
||||
}
|
||||
if (fs.existsSync('bun.lock')) {
|
||||
errors.push(
|
||||
'Invalid occurence of "bun.lockb" file. Please remove it and use only "pnpm-lock.yaml"'
|
||||
);
|
||||
}
|
||||
if (fs.existsSync('yarn.lock')) {
|
||||
errors.push(
|
||||
'Invalid occurence of "yarn.lock" file. Please remove it and use only "pnpm-lock.yaml"'
|
||||
);
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync('pnpm-lock.yaml', 'utf-8');
|
||||
if (content.match(/localhost:487/)) {
|
||||
errors.push(
|
||||
'The "pnpm-lock.yaml" has reference to local repository ("localhost:4873"). Please use ensure you disable local registry before running "pnpm install"'
|
||||
);
|
||||
}
|
||||
if (content.match(/resolution: \{tarball/)) {
|
||||
errors.push(
|
||||
'The "pnpm-lock.yaml" has reference to tarball package. Please use npm registry only'
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
errors.push('The "pnpm-lock.yaml" does not exist or cannot be read');
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
console.log('🔒🔒🔒 Validating lock files 🔒🔒🔒\n');
|
||||
const invalid = checkLockFiles();
|
||||
if (invalid.length > 0) {
|
||||
invalid.forEach((e) => console.log(e));
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('Lock file is valid 👍');
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
const nxBase = process.argv[2] !== 'undefined' ? process.argv[2] : 'master';
|
||||
const nxHead = process.argv[3];
|
||||
const gitDiffCount = require('child_process').execSync(
|
||||
`git diff --name-only ${nxBase} ${nxHead} | (grep -E 'packages/detox|packages/react-native|packages/expo|e2e/detox|e2e/react-native|e2e/expo' || true) | wc -l`
|
||||
);
|
||||
console.log(parseInt(gitDiffCount.toString()) > 0);
|
||||
@@ -0,0 +1,3 @@
|
||||
const fs = require('fs');
|
||||
|
||||
fs.chmodSync(process.argv[2], 0o777);
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { globSync } = require('tinyglobby');
|
||||
|
||||
/**
|
||||
* Remove TypeScript configuration and build info files from the dist directory
|
||||
*/
|
||||
|
||||
function cleanupTsConfigFiles() {
|
||||
const outputPath = 'dist/packages';
|
||||
|
||||
if (!fs.existsSync(outputPath)) {
|
||||
console.log(`Output path does not exist: ${outputPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tsConfigPattern = path
|
||||
.join(outputPath, '*/tsconfig*.json')
|
||||
.replace(/\\/g, '/');
|
||||
const tsBuildInfoPattern = path
|
||||
.join(outputPath, '*/*.tsbuildinfo')
|
||||
.replace(/\\/g, '/');
|
||||
|
||||
const tsConfigFiles = globSync(tsConfigPattern);
|
||||
const tsBuildInfoFiles = globSync(tsBuildInfoPattern);
|
||||
|
||||
const allFiles = [...tsConfigFiles, ...tsBuildInfoFiles];
|
||||
|
||||
if (allFiles.length === 0) {
|
||||
console.log(
|
||||
`No TypeScript config or build info files found in ${outputPath}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Removing ${allFiles.length} TypeScript config/build file(s) from ${outputPath}:`
|
||||
);
|
||||
|
||||
for (const file of allFiles) {
|
||||
try {
|
||||
fs.unlinkSync(file);
|
||||
console.log(`Removed: ${path.relative(outputPath, file)}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to remove ${file}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanupTsConfigFiles();
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { types, scopes } = require('./commitizen.js');
|
||||
const fs = require('fs');
|
||||
const childProcess = require('child_process');
|
||||
|
||||
function validateCommitMessage(message) {
|
||||
const allowedTypes = types.map((type) => type.value).join('|');
|
||||
const allowedScopes = scopes.map((scope) => scope.value).join('|');
|
||||
|
||||
const commitMsgRegex = `(${allowedTypes})\\((${allowedScopes})\\)!?:\\s(([a-z0-9:\\-\\s])+)`;
|
||||
|
||||
const matchCommit = new RegExp(commitMsgRegex, 'g').test(message);
|
||||
const matchRevert = /Revert/gi.test(message);
|
||||
const matchRelease = /Release/gi.test(message);
|
||||
const matchWip = /wip/gi.test(message);
|
||||
|
||||
const isValid = matchRelease || matchRevert || matchCommit || matchWip;
|
||||
|
||||
return {
|
||||
isValid,
|
||||
allowedTypes,
|
||||
allowedScopes,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
// Export for reuse
|
||||
module.exports = { validateCommitMessage };
|
||||
|
||||
// CLI execution
|
||||
if (require.main === module) {
|
||||
console.log('🐟🐟🐟 Validating git commit message 🐟🐟🐟');
|
||||
|
||||
let gitMessage;
|
||||
|
||||
// Check if a commit message file is provided (commit-msg hook)
|
||||
const commitMsgFile = process.argv[2];
|
||||
if (commitMsgFile) {
|
||||
gitMessage = fs.readFileSync(commitMsgFile, 'utf8').trim();
|
||||
} else {
|
||||
// Original logic for post-push validation
|
||||
let gitLogCmd = 'git log -1 --no-merges';
|
||||
|
||||
const gitRemotes = childProcess
|
||||
.execSync('git remote -v')
|
||||
.toString()
|
||||
.trim()
|
||||
.split('\n');
|
||||
const upstreamRemote = gitRemotes.find((remote) =>
|
||||
remote.includes('nrwl/nx.git')
|
||||
);
|
||||
if (upstreamRemote) {
|
||||
const upstreamRemoteIdentifier = upstreamRemote.split('\t')[0].trim();
|
||||
console.log(`Comparing against remote ${upstreamRemoteIdentifier}`);
|
||||
const currentBranch = childProcess
|
||||
.execSync('git branch --show-current')
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
// exclude all commits already present in upstream/master
|
||||
gitLogCmd =
|
||||
gitLogCmd + ` ${currentBranch} ^${upstreamRemoteIdentifier}/master`;
|
||||
} else {
|
||||
console.error(
|
||||
'No upstream remote found for nrwl/nx.git. Skipping comparison against upstream master.'
|
||||
);
|
||||
}
|
||||
|
||||
gitMessage = childProcess.execSync(gitLogCmd).toString().trim();
|
||||
}
|
||||
|
||||
if (!gitMessage) {
|
||||
console.log('No commits found. Skipping commit message validation.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = validateCommitMessage(gitMessage);
|
||||
const exitCode = result.isValid ? 0 : 1;
|
||||
|
||||
if (exitCode === 0) {
|
||||
console.log('Commit ACCEPTED 👍');
|
||||
} else {
|
||||
console.log(
|
||||
'[Error]: Oh no! 😦 Your commit message: \n' +
|
||||
'-------------------------------------------------------------------\n' +
|
||||
gitMessage +
|
||||
'\n-------------------------------------------------------------------' +
|
||||
'\n\n 👉️ Does not follow the commit message convention specified in the CONTRIBUTING.MD file.'
|
||||
);
|
||||
console.log('\ntype(scope): subject \n BLANK LINE \n body');
|
||||
console.log('\n');
|
||||
console.log(`possible types: ${result.allowedTypes}`);
|
||||
console.log(
|
||||
`possible scopes: ${result.allowedScopes} (if unsure use "core")`
|
||||
);
|
||||
console.log(
|
||||
'\nThe commit subject must be lowercase, unless beginning with "Revert" or "Release".'
|
||||
);
|
||||
console.log('\n');
|
||||
|
||||
console.log(
|
||||
'\nEXAMPLE: \n' +
|
||||
'feat(nx): add an option to generate lazy-loadable modules\n' +
|
||||
'fix(core)!: breaking change should have exclamation mark\n'
|
||||
);
|
||||
}
|
||||
process.exit(exitCode);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// prettier-ignore
|
||||
const scopes = [
|
||||
{ value: 'angular', name: 'angular: anything Angular specific' },
|
||||
{ value: 'angular-rspack', name: 'angular-rspack: anything Angular Rspack specific' },
|
||||
{ value: 'core', name: 'core: anything Nx core specific' },
|
||||
{ value: 'bundling', name: 'bundling: anything bundling specific (e.g. rollup, webpack, etc.)' },
|
||||
{ value: 'detox', name: 'detox: anything Detox specific' },
|
||||
{ value: 'devkit', name: 'devkit: devkit-related changes' },
|
||||
{ value: 'express', name: 'express: anything Express specific' },
|
||||
{ value: 'graph', name: 'graph: anything graph app specific' },
|
||||
{ value: 'js', name: 'js: anything TS->JS specific' },
|
||||
{ value: 'linter', name: 'linter: anything Linter specific' },
|
||||
{ value: 'misc', name: 'misc: misc stuff' },
|
||||
{ value: 'nest', name: 'nest: anything Nest specific' },
|
||||
{ value: 'nextjs', name: 'nextjs: anything Next specific' },
|
||||
{ value: 'node', name: 'node: anything Node specific' },
|
||||
{ value: 'nuxt', name: 'nuxt: anything Nuxt specific' },
|
||||
{ value: 'nx-cloud', name: 'nx-cloud: anything Nx Cloud specific' },
|
||||
{ value: 'nx-plugin', name: 'nx-plugin: anything Nx Plugin specific' },
|
||||
{ value: 'nx-dev', name: 'nx-dev: anything related to docs infrastructure' },
|
||||
{ value: 'react', name: 'react: anything React specific' },
|
||||
{ value: 'react-native', name: 'react-native: anything React Native specific' },
|
||||
{ value: 'remix', name: 'remix: anything Remix specific' },
|
||||
{ value: 'rspack', name: 'rspack: anything Rspack specific' },
|
||||
{ value: 'rsbuild', name: 'rsbuild: anything Rsbuild specific' },
|
||||
{ value: 'expo', name: 'expo: anything Expo specific' },
|
||||
{ value: 'release', name: 'release: anything related to nx release' },
|
||||
{ value: 'repo', name: 'repo: anything related to managing the repo itself' },
|
||||
{ value: 'storybook', name: 'storybook: anything Storybook specific' },
|
||||
{ value: 'testing', name: 'testing: anything testing specific (e.g. jest or cypress)' },
|
||||
{ value: 'vite', name: 'vite: anything Vite specific' },
|
||||
{ value: 'vitest', name: 'vitest: anything Vitest specific' },
|
||||
{ value: 'vue', name: 'vue: anything Vue specific' },
|
||||
{ value: 'web', name: 'web: anything Web specific' },
|
||||
{ value: 'webpack', name: 'webpack: anything Webpack specific' },
|
||||
{ value: 'gradle', name: 'gradle: anything Gradle specific'},
|
||||
{ value: 'maven', name: 'maven: anything Maven specific'},
|
||||
{ value: 'module-federation', name: 'module-federation: anything Module Federation specific'},
|
||||
{ value: 'docker', name: 'docker: anything Docker specific'},
|
||||
{ value: 'dotnet', name: 'dotnet: anything .NET specific'},
|
||||
];
|
||||
|
||||
// precomputed scope
|
||||
const scopeComplete = require('child_process')
|
||||
.execSync('git status --porcelain || true')
|
||||
.toString()
|
||||
.trim()
|
||||
.split('\n')
|
||||
.find((r) => ~r.indexOf('M packages'))
|
||||
?.replace(/(\/)/g, '%%')
|
||||
?.match(/packages%%((\w|-)*)/)?.[1];
|
||||
|
||||
/** @type {import('cz-git').CommitizenGitOptions} */
|
||||
module.exports = {
|
||||
/** @usage `pnpm commit :f` */
|
||||
alias: {
|
||||
f: 'docs(core): fix typos',
|
||||
b: 'chore(repo): bump dependencies',
|
||||
},
|
||||
scopes,
|
||||
defaultScope: scopeComplete,
|
||||
scopesSearchValue: true,
|
||||
maxSubjectLength: 100,
|
||||
allowCustomScopes: false,
|
||||
allowEmptyScopes: false,
|
||||
allowCustomIssuePrefix: false,
|
||||
allowEmptyIssuePrefix: false,
|
||||
types: [
|
||||
{ value: 'feat', name: 'feat: A new feature' },
|
||||
{ value: 'fix', name: 'fix: A bug fix' },
|
||||
{ value: 'docs', name: 'docs: Documentation only changes' },
|
||||
{
|
||||
value: 'cleanup',
|
||||
name: 'cleanup: A code change that neither fixes a bug nor adds a feature',
|
||||
},
|
||||
{
|
||||
value: 'chore',
|
||||
name: "chore: Other changes that don't modify src or test files",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { hash } from 'crypto';
|
||||
import fs, { existsSync, readFileSync } from 'fs';
|
||||
import { dirname, join, relative } from 'path';
|
||||
import { globSync } from 'tinyglobby';
|
||||
import { ProgressDisplay } from './progress';
|
||||
|
||||
export const WORKSPACE_ROOT = join(__dirname, '../..');
|
||||
|
||||
export function copyBuiltPackage(
|
||||
pkg: { pkgRoot: string; npmName: string },
|
||||
outputWorkspace: string
|
||||
) {
|
||||
const pathInDist = join(WORKSPACE_ROOT, 'dist', pkg.pkgRoot);
|
||||
const copyRoot = existsSync(pathInDist) ? pathInDist : pkg.pkgRoot;
|
||||
|
||||
if (copyRoot === pkg.pkgRoot) {
|
||||
console.warn(`No build artifacts in "${pathInDist}, checking project root`);
|
||||
}
|
||||
|
||||
// Copy all files from dist/packages/<pkg> to the output package root
|
||||
const files = globSync(`**/*.js`, {
|
||||
cwd: copyRoot,
|
||||
dot: true,
|
||||
ignore: ['**/node_modules/**'],
|
||||
});
|
||||
|
||||
if (!files.length) {
|
||||
throw new Error(
|
||||
`Unable to find built artifacts for ${pkg.npmName}. Did you forget to build?`
|
||||
);
|
||||
}
|
||||
|
||||
const outputPkgRoot = join(outputWorkspace, 'node_modules', pkg.npmName);
|
||||
fs.mkdirSync(outputPkgRoot, { recursive: true });
|
||||
|
||||
// Get native files count for accurate total
|
||||
const nativeFiles = getNativeFiles(pkg.pkgRoot);
|
||||
const totalFiles = files.length + nativeFiles.length;
|
||||
const progress = new ProgressDisplay(totalFiles);
|
||||
|
||||
files.forEach((file) => {
|
||||
const destFile = join(outputPkgRoot, file);
|
||||
const destDir = dirname(destFile);
|
||||
const copySrc = join(copyRoot, file);
|
||||
const relativeFilePath = relative(copyRoot, copySrc);
|
||||
progress.update(relativeFilePath);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
safeCopyFileSync(copySrc, destFile, progress);
|
||||
});
|
||||
|
||||
// Copy native files
|
||||
copyNativeFiles(pkg.pkgRoot, outputPkgRoot, nativeFiles, progress);
|
||||
progress.finish();
|
||||
}
|
||||
|
||||
function getNativeFiles(pkgRoot: string): string[] {
|
||||
const copyRoot = join(WORKSPACE_ROOT, pkgRoot);
|
||||
return globSync(`**/*.{node,wasm,js,mjs,cjs}`, {
|
||||
ignore: [
|
||||
'**/node_modules/**',
|
||||
'src/command-line/migrate/run-migration-process.js',
|
||||
],
|
||||
cwd: copyRoot,
|
||||
});
|
||||
}
|
||||
|
||||
function copyNativeFiles(
|
||||
pkgRoot: string,
|
||||
outputPkgRoot: string,
|
||||
nativeFiles: string[],
|
||||
progress: ProgressDisplay
|
||||
) {
|
||||
const copyRoot = join(WORKSPACE_ROOT, pkgRoot);
|
||||
nativeFiles.forEach((file) => {
|
||||
const destFile = join(outputPkgRoot, file);
|
||||
const destDir = dirname(destFile);
|
||||
const copySrc = join(copyRoot, file);
|
||||
const relativeFilePath = relative(copyRoot, copySrc);
|
||||
progress.update(relativeFilePath);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
safeCopyFileSync(copySrc, destFile, progress);
|
||||
});
|
||||
}
|
||||
|
||||
function safeCopyFileSync(
|
||||
src: string,
|
||||
dest: string,
|
||||
progress: ProgressDisplay
|
||||
) {
|
||||
const sha = hash('sha256', readFileSync(src));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
try {
|
||||
fs.copyFileSync(src, dest);
|
||||
const destSha = hash('sha256', readFileSync(dest));
|
||||
if (sha === destSha) {
|
||||
return;
|
||||
} else {
|
||||
progress.insertBefore(
|
||||
`Hash mismatch when copying ${src} to ${dest}, retrying...`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
progress.insertBefore(
|
||||
`Error copying file ${src} to ${dest}: ${(e as Error).message}, retrying...`
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed to copy ${src} to ${dest} after 3 attempts.`);
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
import fs, { Dirent, existsSync } from 'node:fs';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { basename, dirname, join, resolve } from 'path';
|
||||
import pc from 'picocolors';
|
||||
import { termSize, truncateEnd } from './ui';
|
||||
|
||||
const SEARCH_DEPTH = 3;
|
||||
|
||||
// Directories to never recurse into. We still detect node_modules
|
||||
// *presence* (marks a workspace root) via a stat, just don't list inside it.
|
||||
const SKIP_RECURSE = new Set([
|
||||
// VCS
|
||||
'.git',
|
||||
'.svn',
|
||||
'.hg',
|
||||
// Package managers (contents, not the marker)
|
||||
'node_modules',
|
||||
'.yarn',
|
||||
'.pnpm',
|
||||
// Build / framework caches
|
||||
'.cache',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.output',
|
||||
'.turbo',
|
||||
'.angular',
|
||||
// Editor / IDE
|
||||
'.vscode',
|
||||
'.idea',
|
||||
// OS
|
||||
'.Trash',
|
||||
]);
|
||||
|
||||
// ── Keypress parsing ────────────────────────────────────────────────
|
||||
|
||||
interface KeyPress {
|
||||
name: string; // e.g. 'a', 'enter', 'backspace', 'up', 'tab'
|
||||
ctrl: boolean;
|
||||
meta: boolean; // Alt / Option
|
||||
char: string; // printable character, or '' for special keys
|
||||
}
|
||||
|
||||
function parseKeypress(data: Buffer): KeyPress {
|
||||
const raw = data.toString();
|
||||
|
||||
// Meta (Alt/Option) + key: ESC followed by a single character
|
||||
if (raw.length === 2 && raw[0] === '\x1b' && raw[1] !== '[') {
|
||||
const ch = raw[1];
|
||||
if (ch === '\x7f')
|
||||
return { name: 'backspace', ctrl: false, meta: true, char: '' };
|
||||
return { name: ch, ctrl: false, meta: true, char: ch };
|
||||
}
|
||||
|
||||
// CSI sequences: ESC [ <letter>
|
||||
if (raw.startsWith('\x1b[')) {
|
||||
const suffix = raw.slice(2);
|
||||
const csiMap: Record<string, string> = {
|
||||
A: 'up',
|
||||
B: 'down',
|
||||
C: 'right',
|
||||
D: 'left',
|
||||
H: 'home',
|
||||
F: 'end',
|
||||
};
|
||||
const name = csiMap[suffix] ?? `unknown(${suffix})`;
|
||||
return { name, ctrl: false, meta: false, char: '' };
|
||||
}
|
||||
|
||||
// Single-byte control characters
|
||||
if (raw.length === 1 && raw.charCodeAt(0) < 0x20) {
|
||||
const code = raw.charCodeAt(0);
|
||||
const ctrlMap: Record<number, string> = {
|
||||
0x03: 'c', // Ctrl+C
|
||||
0x08: 'backspace', // Ctrl+H (legacy backspace)
|
||||
0x09: 'tab',
|
||||
0x0d: 'enter',
|
||||
0x15: 'u', // Ctrl+U
|
||||
0x17: 'w', // Ctrl+W
|
||||
};
|
||||
const name = ctrlMap[code] ?? String.fromCharCode(code + 0x60);
|
||||
const isCtrl = code !== 0x09 && code !== 0x0d; // tab/enter aren't "ctrl" keys
|
||||
return { name, ctrl: isCtrl, meta: false, char: '' };
|
||||
}
|
||||
|
||||
// DEL (0x7f) — Backspace on most terminals
|
||||
if (raw === '\x7f') {
|
||||
return { name: 'backspace', ctrl: false, meta: false, char: '' };
|
||||
}
|
||||
|
||||
// Printable characters (including pasted multi-char text)
|
||||
return { name: raw, ctrl: false, meta: false, char: raw };
|
||||
}
|
||||
|
||||
// ── Input parsing ────────────────────────────────────────────────────
|
||||
|
||||
interface ParsedInput {
|
||||
resolvedSearchDir: string;
|
||||
filter: string;
|
||||
prefix: string;
|
||||
selfDisplay: string | null;
|
||||
}
|
||||
|
||||
function parseInput(input: string): ParsedInput {
|
||||
let searchDir: string;
|
||||
let filter: string;
|
||||
let prefix: string;
|
||||
|
||||
const expanded = input.startsWith('~/')
|
||||
? (process.env.HOME || '') + input.slice(1)
|
||||
: input;
|
||||
|
||||
if (expanded === '' || expanded.endsWith('/')) {
|
||||
searchDir = expanded || '.';
|
||||
filter = '';
|
||||
prefix = input;
|
||||
} else {
|
||||
const d = dirname(expanded);
|
||||
searchDir = d;
|
||||
filter = basename(expanded).toLowerCase();
|
||||
const inputDir = dirname(input);
|
||||
prefix = inputDir === '.' ? '' : inputDir + '/';
|
||||
}
|
||||
|
||||
const resolvedSearchDir = resolve(searchDir);
|
||||
|
||||
// Self-directory: show the search dir itself when there's no child filter
|
||||
// (empty input, trailing slash, or the literal "." / ".." inputs).
|
||||
let selfDisplay: string | null = null;
|
||||
const showSelf = filter === '' || input === '.' || input === '..';
|
||||
if (showSelf && existsSync(join(resolvedSearchDir, 'node_modules'))) {
|
||||
selfDisplay =
|
||||
input === ''
|
||||
? '.'
|
||||
: input.endsWith('/')
|
||||
? input.replace(/\/+$/, '')
|
||||
: input;
|
||||
}
|
||||
|
||||
return { resolvedSearchDir, filter, prefix, selfDisplay };
|
||||
}
|
||||
|
||||
// ── Parallel directory walker ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Walk directories in parallel, invoking `onFound` for each workspace root
|
||||
* (directory containing `node_modules`). All entries within a directory
|
||||
* level are explored concurrently via Promise.all, which dramatically
|
||||
* reduces wall-clock time compared to a sequential async generator.
|
||||
*/
|
||||
async function walkWorkspaceDirs(
|
||||
dir: string,
|
||||
maxDepth: number,
|
||||
relPrefix: string,
|
||||
onFound: (relPath: string) => void,
|
||||
isCancelled: () => boolean
|
||||
): Promise<void> {
|
||||
if (maxDepth < 0 || isCancelled()) return;
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCancelled()) return;
|
||||
|
||||
await Promise.all(
|
||||
entries.map(async (e) => {
|
||||
if (isCancelled()) return;
|
||||
if (!e.isDirectory() || SKIP_RECURSE.has(e.name)) return;
|
||||
|
||||
const fullPath = join(dir, e.name);
|
||||
const relPath = relPrefix ? relPrefix + '/' + e.name : e.name;
|
||||
|
||||
// Check for node_modules with a cheap stat rather than readdir
|
||||
try {
|
||||
await fs.promises.access(join(fullPath, 'node_modules'));
|
||||
if (!isCancelled()) onFound(relPath);
|
||||
} catch {
|
||||
// Not a workspace root — keep looking deeper
|
||||
}
|
||||
|
||||
if (maxDepth > 0 && !isCancelled()) {
|
||||
await walkWorkspaceDirs(
|
||||
fullPath,
|
||||
maxDepth - 1,
|
||||
relPath,
|
||||
onFound,
|
||||
isCancelled
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ── Interactive picker ───────────────────────────────────────────────
|
||||
|
||||
export class DirectoryPicker {
|
||||
private input: string = '';
|
||||
private cursorPos: number = 0;
|
||||
private selectedIndex: number = -1; // -1 = on input line
|
||||
private suggestions: string[] = [];
|
||||
private lastRenderLines = 0;
|
||||
private resolvePromise!: (value: string) => void;
|
||||
|
||||
// ── Scan cache ─────────────────────────────────────────────────────
|
||||
// Key: resolved search directory path
|
||||
// Value: all workspace-root relative paths found under that directory
|
||||
private scanCache = new Map<string, string[]>();
|
||||
private completedScans = new Set<string>();
|
||||
private activeScanKey: string | null = null;
|
||||
private cancelActiveScan: (() => void) | null = null;
|
||||
|
||||
/** Visible suggestion count adapts to the terminal height */
|
||||
private get maxSuggestions(): number {
|
||||
const { rows } = termSize();
|
||||
return Math.max(3, Math.min(Math.floor(rows * 0.3), rows - 4));
|
||||
}
|
||||
|
||||
run(): Promise<string> {
|
||||
return new Promise<string>((res) => {
|
||||
this.resolvePromise = res;
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdin.on('data', this.onKeypress);
|
||||
this.updateSuggestions();
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private onKeypress = (data: Buffer) => {
|
||||
const key = parseKeypress(data);
|
||||
|
||||
// ── Action keys ───────────────────────────────────────────────
|
||||
if (key.ctrl && key.name === 'c') {
|
||||
this.cleanup();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (key.name === 'enter') {
|
||||
const value =
|
||||
this.selectedIndex >= 0
|
||||
? this.suggestions[this.selectedIndex]
|
||||
: this.input;
|
||||
this.cleanup();
|
||||
this.resolvePromise(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'tab') {
|
||||
if (this.suggestions.length > 0) {
|
||||
const idx = Math.max(this.selectedIndex, 0);
|
||||
this.input = this.suggestions[idx];
|
||||
this.cursorPos = this.input.length;
|
||||
this.selectedIndex = -1;
|
||||
this.updateSuggestions();
|
||||
}
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────
|
||||
if (key.name === 'up') {
|
||||
this.selectedIndex = this.selectedIndex > 0 ? this.selectedIndex - 1 : -1;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'down') {
|
||||
if (this.selectedIndex < this.suggestions.length - 1) {
|
||||
this.selectedIndex++;
|
||||
}
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'right') {
|
||||
if (this.cursorPos < this.input.length) this.cursorPos++;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'left') {
|
||||
if (this.cursorPos > 0) this.cursorPos--;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Deletion ──────────────────────────────────────────────────
|
||||
if (key.ctrl && key.name === 'u') {
|
||||
this.input = '';
|
||||
this.cursorPos = 0;
|
||||
this.selectedIndex = -1;
|
||||
this.updateSuggestions();
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+W or Meta+Backspace — delete word backward
|
||||
if (
|
||||
(key.ctrl && key.name === 'w') ||
|
||||
(key.meta && key.name === 'backspace')
|
||||
) {
|
||||
this.deleteWordBackward();
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'backspace') {
|
||||
if (this.cursorPos > 0) {
|
||||
this.input =
|
||||
this.input.slice(0, this.cursorPos - 1) +
|
||||
this.input.slice(this.cursorPos);
|
||||
this.cursorPos--;
|
||||
this.selectedIndex = -1;
|
||||
this.updateSuggestions();
|
||||
}
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Printable text ────────────────────────────────────────────
|
||||
if (key.char) {
|
||||
this.input =
|
||||
this.input.slice(0, this.cursorPos) +
|
||||
key.char +
|
||||
this.input.slice(this.cursorPos);
|
||||
this.cursorPos += key.char.length;
|
||||
this.selectedIndex = -1;
|
||||
this.updateSuggestions();
|
||||
this.render();
|
||||
}
|
||||
};
|
||||
|
||||
private deleteWordBackward() {
|
||||
if (this.cursorPos <= 0) return;
|
||||
const before = this.input.slice(0, this.cursorPos);
|
||||
// Delete trailing slashes, then back to the previous slash or start
|
||||
const trimmed = before.replace(/\/+$/, '');
|
||||
const lastSlash = trimmed.lastIndexOf('/');
|
||||
const newEnd = lastSlash === -1 ? 0 : lastSlash + 1;
|
||||
this.input = this.input.slice(0, newEnd) + this.input.slice(this.cursorPos);
|
||||
this.cursorPos = newEnd;
|
||||
this.selectedIndex = -1;
|
||||
this.updateSuggestions();
|
||||
}
|
||||
|
||||
// ── Suggestion management ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Recompute `this.suggestions` from the cache using the current input.
|
||||
* Pure synchronous filter — no I/O.
|
||||
*/
|
||||
private refreshSuggestions() {
|
||||
const parsed = parseInput(this.input);
|
||||
|
||||
this.suggestions = parsed.selfDisplay ? [parsed.selfDisplay] : [];
|
||||
|
||||
const cached = this.scanCache.get(parsed.resolvedSearchDir);
|
||||
if (!cached) return;
|
||||
|
||||
for (const relPath of cached) {
|
||||
if (parsed.filter) {
|
||||
const firstSegment = relPath.split('/')[0].toLowerCase();
|
||||
if (!firstSegment.startsWith(parsed.filter)) continue;
|
||||
}
|
||||
|
||||
const displayPath = parsed.prefix + relPath;
|
||||
|
||||
// Insert in sorted position
|
||||
const insertIdx = this.suggestions.findIndex((s) => s > displayPath);
|
||||
if (insertIdx === -1) {
|
||||
this.suggestions.push(displayPath);
|
||||
} else {
|
||||
this.suggestions.splice(insertIdx, 0, displayPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on every input change. Refreshes suggestions from cache instantly,
|
||||
* then starts an async scan if the search directory hasn't been fully scanned.
|
||||
*/
|
||||
private updateSuggestions() {
|
||||
const parsed = parseInput(this.input);
|
||||
const cacheKey = parsed.resolvedSearchDir;
|
||||
|
||||
// Always refresh from whatever's in the cache (instant)
|
||||
this.refreshSuggestions();
|
||||
|
||||
if (!existsSync(cacheKey)) return;
|
||||
|
||||
// If this directory is fully scanned, we're done — cache has everything
|
||||
if (this.completedScans.has(cacheKey)) return;
|
||||
|
||||
// If there's already an active scan for this same directory, let it run
|
||||
if (this.activeScanKey === cacheKey) return;
|
||||
|
||||
// Different directory (or first time) — cancel any old scan, start new
|
||||
this.cancelActiveScan?.();
|
||||
this.activeScanKey = cacheKey;
|
||||
|
||||
if (!this.scanCache.has(cacheKey)) {
|
||||
this.scanCache.set(cacheKey, []);
|
||||
}
|
||||
|
||||
this.startScan(cacheKey);
|
||||
}
|
||||
|
||||
private async startScan(cacheKey: string) {
|
||||
let cancelled = false;
|
||||
this.cancelActiveScan = () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
const cache = this.scanCache.get(cacheKey)!;
|
||||
|
||||
// Schedule suggestion refresh + render as a macrotask so that
|
||||
// keypress events (also macrotasks) get interleaved rather than
|
||||
// starved behind a flood of Promise.all microtask continuations.
|
||||
let renderPending = false;
|
||||
const scheduleRender = () => {
|
||||
if (renderPending) return;
|
||||
renderPending = true;
|
||||
setImmediate(() => {
|
||||
renderPending = false;
|
||||
if (!cancelled) {
|
||||
this.refreshSuggestions();
|
||||
this.render();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
await walkWorkspaceDirs(
|
||||
cacheKey,
|
||||
SEARCH_DEPTH,
|
||||
'',
|
||||
(relPath) => {
|
||||
cache.push(relPath);
|
||||
scheduleRender();
|
||||
},
|
||||
() => cancelled
|
||||
);
|
||||
|
||||
// Final render to pick up any results from the last batch
|
||||
if (!cancelled) {
|
||||
this.refreshSuggestions();
|
||||
this.render();
|
||||
this.completedScans.add(cacheKey);
|
||||
this.activeScanKey = null;
|
||||
this.cancelActiveScan = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────
|
||||
|
||||
private render() {
|
||||
const CLR = '\x1b[K'; // clear to end of line (terminal control, not a color)
|
||||
|
||||
// On subsequent renders the cursor is on the input line (from prior
|
||||
// positioning). Just return the cursor to column 0 so we overwrite in
|
||||
// place. On the first render lastRenderLines is 0, so this is a no-op.
|
||||
if (this.lastRenderLines > 0) {
|
||||
process.stdout.write('\r');
|
||||
}
|
||||
|
||||
const { cols } = termSize();
|
||||
|
||||
// ── Input line ──────────────────────────────────────────────────
|
||||
const promptLabel = ` ${pc.cyan('>')} Repo path: `;
|
||||
const promptVisualLen = ' > Repo path: '.length; // without ANSI
|
||||
const maxInputDisplay = cols - promptVisualLen - 1;
|
||||
const displayInput =
|
||||
this.input.length > maxInputDisplay
|
||||
? '…' + this.input.slice(-(maxInputDisplay - 1))
|
||||
: this.input;
|
||||
process.stdout.write(`${promptLabel}${displayInput}${CLR}\n`);
|
||||
|
||||
// Blank separator between input and suggestions
|
||||
process.stdout.write(`${CLR}\n`);
|
||||
|
||||
// ── Suggestion lines ────────────────────────────────────────────
|
||||
const visible = this.suggestions.slice(0, this.maxSuggestions);
|
||||
const shownSuggestions = visible.length;
|
||||
const pointer = ' › ';
|
||||
const noPointer = ' ';
|
||||
const indent = ' ';
|
||||
const maxSuggestionLen = cols - indent.length - pointer.length - 1;
|
||||
|
||||
for (let i = 0; i < shownSuggestions; i++) {
|
||||
const text = truncateEnd(visible[i], maxSuggestionLen);
|
||||
if (i === this.selectedIndex) {
|
||||
process.stdout.write(
|
||||
`${indent}${pc.cyan(pointer)}${pc.inverse(` ${text} `)}${CLR}\n`
|
||||
);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`${indent}${pc.dim(`${noPointer}${text}`)}${CLR}\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hint line ───────────────────────────────────────────────────
|
||||
process.stdout.write(
|
||||
`${pc.dim(' ↑↓ navigate tab complete enter select')}${CLR}\n`
|
||||
);
|
||||
|
||||
// ── Clear leftover lines from previous render ───────────────────
|
||||
const totalLines = 1 + 1 + shownSuggestions + 1; // input + blank + suggestions + hint
|
||||
for (let i = totalLines; i < this.lastRenderLines; i++) {
|
||||
process.stdout.write(`${CLR}\n`);
|
||||
}
|
||||
|
||||
// Track how many physical lines we wrote (including clears)
|
||||
const linesWritten = Math.max(totalLines, this.lastRenderLines);
|
||||
this.lastRenderLines = totalLines;
|
||||
|
||||
// Every line we wrote ended with \n, so the cursor is now
|
||||
// `linesWritten` rows below the input line. Move back up.
|
||||
if (linesWritten > 0) {
|
||||
process.stdout.write(`\x1b[${linesWritten}A`);
|
||||
}
|
||||
|
||||
// Position cursor within the input (account for truncation)
|
||||
const inputOffset =
|
||||
this.input.length > maxInputDisplay
|
||||
? this.cursorPos - (this.input.length - maxInputDisplay) + 1
|
||||
: this.cursorPos;
|
||||
const cursorCol = Math.max(
|
||||
promptVisualLen + 1,
|
||||
promptVisualLen + inputOffset + 1
|
||||
);
|
||||
process.stdout.write(`\x1b[${Math.min(cursorCol, cols)}G`);
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
// Cancel any in-flight scan
|
||||
this.cancelActiveScan?.();
|
||||
|
||||
process.stdin.setRawMode(false);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener('data', this.onKeypress);
|
||||
|
||||
// Cursor is on the input line (from render positioning).
|
||||
// Go to column 0, then overwrite with the final value.
|
||||
const finalValue =
|
||||
this.selectedIndex >= 0
|
||||
? this.suggestions[this.selectedIndex]
|
||||
: this.input;
|
||||
process.stdout.write(`\r Repo path: ${finalValue}\x1b[K\n`);
|
||||
|
||||
// Clear all the suggestion / hint lines below
|
||||
for (let i = 1; i < this.lastRenderLines; i++) {
|
||||
process.stdout.write('\x1b[K\n');
|
||||
}
|
||||
|
||||
// Move back up so subsequent output starts right after the input line
|
||||
if (this.lastRenderLines > 1) {
|
||||
process.stdout.write(`\x1b[${this.lastRenderLines - 1}A`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { readJsonFile } from '@nx/devkit';
|
||||
import { execSync } from 'child_process';
|
||||
import { readJsonSync } from 'fs-extra';
|
||||
import { dirname, join } from 'path';
|
||||
import { globSync } from 'tinyglobby';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import { copyBuiltPackage, WORKSPACE_ROOT } from './copy';
|
||||
import { DirectoryPicker } from './directory-picker';
|
||||
import { asciiBlock, centerStringInWidth, termSize } from './ui';
|
||||
|
||||
const packages: { npmName: string; nxName: string; pkgRoot: string }[] =
|
||||
globSync(`packages/*/package.json`).map((p) => {
|
||||
const pkgJson = readJsonSync(p);
|
||||
const pkgRoot = dirname(p);
|
||||
const projectJson = (() => {
|
||||
try {
|
||||
return readJsonFile(join(WORKSPACE_ROOT, pkgRoot, 'project.json'));
|
||||
} catch {}
|
||||
})();
|
||||
return {
|
||||
npmName: pkgJson.name,
|
||||
nxName: projectJson?.name ?? pkgJson.nx?.name ?? pkgJson.name,
|
||||
pkgRoot,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Score a package name against the user's input for ranking.
|
||||
* Lower score = better match. Returns Infinity for no match.
|
||||
*
|
||||
* Matching is done against the "short name" — the part after the scope
|
||||
* (e.g. "react" for "@nx/react") as well as the full name.
|
||||
*
|
||||
* 0 exact match on short name ("nx" → "nx")
|
||||
* 1 exact match on full name
|
||||
* 2 short name starts with input ("react" matches "@nx/react-native")
|
||||
* 3 full name starts with input
|
||||
* 4 short name contains input
|
||||
* 5 full name contains input
|
||||
*/
|
||||
function packageMatchScore(input: string, name: string): number {
|
||||
const lower = input.toLowerCase();
|
||||
const fullLower = name.toLowerCase();
|
||||
const shortName = fullLower.includes('/')
|
||||
? fullLower.split('/').pop()!
|
||||
: fullLower;
|
||||
|
||||
if (shortName === lower) return 0;
|
||||
if (fullLower === lower) return 1;
|
||||
if (shortName.startsWith(lower)) return 2;
|
||||
if (fullLower.startsWith(lower)) return 3;
|
||||
if (shortName.includes(lower)) return 4;
|
||||
if (fullLower.includes(lower)) return 5;
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
async function promptPackages(): Promise<typeof packages> {
|
||||
const { prompt } = require('enquirer');
|
||||
// Reserve lines for the prompt header, input, footer hint, and breathing room
|
||||
const visibleChoices = Math.max(5, termSize().rows - 4);
|
||||
const result: { packages: string[] } = await prompt({
|
||||
type: 'autocomplete',
|
||||
name: 'packages',
|
||||
message: 'Select packages to copy',
|
||||
choices: packages.map((p) => p.nxName),
|
||||
multiple: true,
|
||||
limit: visibleChoices,
|
||||
suggest(input: string, choices: { name: string; message: string }[]) {
|
||||
if (!input) return choices;
|
||||
return choices
|
||||
.map((ch) => ({ ch, score: packageMatchScore(input, ch.message) }))
|
||||
.filter(({ score }) => score < Infinity)
|
||||
.sort((a, b) => a.score - b.score)
|
||||
.map(({ ch }) => ch);
|
||||
},
|
||||
});
|
||||
if (result.packages.length === 0) {
|
||||
console.error('No packages selected.');
|
||||
process.exit(1);
|
||||
}
|
||||
return result.packages.map(
|
||||
(nxName) => packages.find((p) => p.nxName === nxName)!
|
||||
);
|
||||
}
|
||||
|
||||
yargs(hideBin(process.argv))
|
||||
.command({
|
||||
command: '$0',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option('package', {
|
||||
type: 'string',
|
||||
choices: packages.flatMap((p) => [p.npmName, p.nxName]),
|
||||
description: 'The package to copy the build outputs from',
|
||||
})
|
||||
.option('repo', {
|
||||
type: 'string',
|
||||
description:
|
||||
'The root path of the repo to copy the built packages to',
|
||||
})
|
||||
.option('build', {
|
||||
type: 'boolean',
|
||||
description: 'Set to false to skip prebuild step.',
|
||||
default: true,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
const argvPackage =
|
||||
argv.package &&
|
||||
packages.find(
|
||||
(p) => p.nxName === argv.package || p.npmName === argv.package
|
||||
);
|
||||
const selectedPackages = argvPackage
|
||||
? [argvPackage]
|
||||
: await promptPackages();
|
||||
|
||||
const selectedNxProjects = selectedPackages.map((p) => p.nxName);
|
||||
|
||||
const repo = argv.repo || (await new DirectoryPicker().run());
|
||||
|
||||
if (argv.build) {
|
||||
execSync(
|
||||
'nx run-many --tuiAutoExit=0 -t build -p ' +
|
||||
selectedNxProjects.join(' '),
|
||||
{
|
||||
stdio: 'inherit',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const termWidth = process.stdout.columns || 80;
|
||||
const renderWidth = termWidth - termWidth * 0.2;
|
||||
|
||||
for (const pkg of selectedPackages) {
|
||||
console.log();
|
||||
console.log(
|
||||
asciiBlock(
|
||||
renderWidth,
|
||||
2,
|
||||
1,
|
||||
`Copying ${pkg.npmName} to ${repo}/node_modules/${pkg.npmName}`
|
||||
)
|
||||
.map((line) => centerStringInWidth(line, termWidth))
|
||||
.join('\n')
|
||||
);
|
||||
copyBuiltPackage(pkg, repo);
|
||||
}
|
||||
},
|
||||
})
|
||||
.parseAsync();
|
||||
@@ -0,0 +1,88 @@
|
||||
import { centerStringInWidth } from './ui';
|
||||
|
||||
function getProgressBar(current: number, total: number, width = 40): string {
|
||||
const percentage = total > 0 ? current / total : 0;
|
||||
const filled = Math.round(width * percentage);
|
||||
const empty = width - filled;
|
||||
return '█'.repeat(filled) + '░'.repeat(empty);
|
||||
}
|
||||
|
||||
function truncateStart(str: string, maxLen: number): string {
|
||||
if (str.length <= maxLen) return str;
|
||||
return '...' + str.slice(-(maxLen - 3));
|
||||
}
|
||||
|
||||
export class ProgressDisplay {
|
||||
private current = 0;
|
||||
private total: number;
|
||||
private lastFile = '';
|
||||
private initialized: boolean = false;
|
||||
|
||||
constructor(total: number) {
|
||||
this.total = total;
|
||||
}
|
||||
|
||||
insertBefore(str: string) {
|
||||
process.stdout.write('\x1b[J');
|
||||
console.log(str);
|
||||
}
|
||||
|
||||
row(maxWidth: number, minGap: number, ...parts: string[]): string {
|
||||
const totalPartsLength = parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const calculatedGap = (maxWidth - totalPartsLength) / (parts.length - 1);
|
||||
if (calculatedGap < minGap) {
|
||||
const neededReduction = (minGap - calculatedGap) * (parts.length - 1);
|
||||
parts[0] = truncateStart(parts[0], parts[0].length - neededReduction);
|
||||
}
|
||||
return parts.join(' '.repeat(Math.max(calculatedGap, minGap)));
|
||||
}
|
||||
|
||||
update(file: string, message?: string): void {
|
||||
this.current++;
|
||||
this.lastFile = file;
|
||||
|
||||
// Move cursor up 3 lines and clear each line
|
||||
if (this.initialized) {
|
||||
process.stdout.write('\x1b[2F');
|
||||
} else {
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
if (message) {
|
||||
this.insertBefore(message);
|
||||
}
|
||||
this.renderCurrentStatus();
|
||||
}
|
||||
|
||||
private renderCurrentStatus() {
|
||||
const termWidth = process.stdout.columns || 80;
|
||||
const adjustedTermWidth = termWidth - Math.floor(0.2 * termWidth);
|
||||
const progressBar = getProgressBar(
|
||||
this.current,
|
||||
this.total,
|
||||
adjustedTermWidth
|
||||
);
|
||||
|
||||
// Line 1: Most recently copied file
|
||||
process.stdout.write(
|
||||
centerStringInWidth(
|
||||
this.row(
|
||||
adjustedTermWidth,
|
||||
4,
|
||||
this.lastFile,
|
||||
`[${this.current} / ${this.total}]`
|
||||
),
|
||||
termWidth
|
||||
) + '\x1b[K\n'
|
||||
);
|
||||
// Line 2: Progress bar
|
||||
process.stdout.write(
|
||||
centerStringInWidth(progressBar, termWidth) + '\x1b[K\n'
|
||||
);
|
||||
}
|
||||
|
||||
finish(): void {
|
||||
// Add a newline after completion to separate from next output
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export function termSize(): { cols: number; rows: number } {
|
||||
return {
|
||||
cols: process.stdout.columns || 80,
|
||||
rows: process.stdout.rows || 24,
|
||||
};
|
||||
}
|
||||
|
||||
export function truncateEnd(str: string, maxLen: number): string {
|
||||
if (str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen - 1) + '…';
|
||||
}
|
||||
|
||||
export const centerStringInWidth = (str: string, width: number): string => {
|
||||
const padding = Math.max(0, width - str.length);
|
||||
const padStart = Math.floor(padding / 2);
|
||||
const padEnd = padding - padStart;
|
||||
return ' '.repeat(padStart) + str + ' '.repeat(padEnd);
|
||||
};
|
||||
|
||||
export function asciiBlock(
|
||||
w: number,
|
||||
px: number,
|
||||
py: number,
|
||||
text: string
|
||||
): string[] {
|
||||
const lines = text.split('\n');
|
||||
const contentWidth = w - px * 2 - 2;
|
||||
const paddedLines = lines.map((line) => {
|
||||
const truncatedLine =
|
||||
line.length > contentWidth
|
||||
? line.slice(0, contentWidth - 3) + '...'
|
||||
: line;
|
||||
return centerStringInWidth(truncatedLine, w - 2);
|
||||
});
|
||||
const emptyLine = ' '.repeat(w - 2);
|
||||
const blockLines = [
|
||||
'┌' + '─'.repeat(w - 2) + '┐',
|
||||
...Array(py).fill('│' + emptyLine + '│'),
|
||||
...paddedLines.map((line) => '│' + line + '│'),
|
||||
...Array(py).fill('│' + emptyLine + '│'),
|
||||
'└' + '─'.repeat(w - 2) + '┘',
|
||||
];
|
||||
return blockLines;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//@ts-check
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const glob = require('tinyglobby');
|
||||
|
||||
const p = process.argv[2];
|
||||
|
||||
const nativeFiles = glob.globSync(`packages/${p}/**/*.{node,wasm,js,mjs,cjs}`, {
|
||||
ignore: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'src/command-line/migrate/run-migration-process.js',
|
||||
],
|
||||
});
|
||||
|
||||
console.log({ nativeFiles });
|
||||
|
||||
nativeFiles.forEach((file) => {
|
||||
// Transform: packages/nx/src/native/file.js -> packages/nx/dist/src/native/file.js
|
||||
const parts = file.split('/');
|
||||
// Insert 'dist' after the package name (index 2)
|
||||
parts.splice(2, 0, 'dist');
|
||||
const destFile = parts.join('/');
|
||||
const destDir = path.dirname(destFile);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.copyFileSync(file, destFile);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const p = process.argv[2];
|
||||
const possibleInputPath = process.argv[3];
|
||||
const possibleOutputPath = process.argv[4];
|
||||
|
||||
let sourceReadmePath = `packages/${p}/README.md`;
|
||||
if (possibleInputPath && fs.existsSync(possibleInputPath)) {
|
||||
sourceReadmePath = possibleInputPath;
|
||||
}
|
||||
|
||||
// we need exception for linter
|
||||
if (p === 'linter') {
|
||||
sourceReadmePath = 'packages/eslint/README.md';
|
||||
}
|
||||
let r = fs.readFileSync(sourceReadmePath).toString();
|
||||
r = r.replace(
|
||||
`{{links}}`,
|
||||
fs.readFileSync('scripts/readme-fragments/links.md')
|
||||
);
|
||||
r = r.replace(
|
||||
`{{content}}`,
|
||||
fs.readFileSync('scripts/readme-fragments/content.md')
|
||||
);
|
||||
r = r.replace(
|
||||
`{{resources}}`,
|
||||
fs.readFileSync('scripts/readme-fragments/resources.md')
|
||||
);
|
||||
|
||||
const outputPath = possibleOutputPath ?? `dist/packages/${p}/README.md`;
|
||||
|
||||
console.log('WRITING', outputPath);
|
||||
|
||||
fs.writeFileSync(outputPath, r);
|
||||
|
||||
try {
|
||||
execSync(`npx prettier --write "${outputPath}"`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
// Ignore prettier errors — formatting is best-effort
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//@ts-check
|
||||
const { mkdirSync, copySync } = require('fs-extra');
|
||||
const glob = require('tinyglobby');
|
||||
const { join, basename } = require('path');
|
||||
|
||||
const p = process.argv[2];
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const dest = args[args.length - 1];
|
||||
const from = args.slice(0, args.length - 1);
|
||||
|
||||
try {
|
||||
mkdirSync(dest, {
|
||||
recursive: true,
|
||||
});
|
||||
} catch {}
|
||||
for (const f of from) {
|
||||
const matchingFiles = glob.globSync(f, {
|
||||
cwd: process.cwd(),
|
||||
onlyDirectories: true,
|
||||
});
|
||||
|
||||
for (const file of matchingFiles) {
|
||||
const destFile = join(dest, basename(file));
|
||||
console.log(file, '=>', destFile);
|
||||
copySync(file, destFile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* Creates a versioned docs branch containing only the pre-built static site.
|
||||
*
|
||||
* Usage:
|
||||
* node ./scripts/create-versioned-docs.mts v22
|
||||
* node ./scripts/create-versioned-docs.mts 22
|
||||
* node ./scripts/create-versioned-docs.mts v16 --redirect-to-prod
|
||||
*
|
||||
* --redirect-to-prod: skip the build and produce a branch that 301s every
|
||||
* path to https://nx.dev/docs. Used to retire an old versioned subdomain
|
||||
* (e.g. 16.nx.dev) without maintaining its docs — old paths won't map
|
||||
* cleanly anymore, so everything goes to the /docs root.
|
||||
*
|
||||
* What it does:
|
||||
* 1. Fetches tags from origin, finds the latest stable release for that major
|
||||
* 2. Checks out that tag, builds the docs site
|
||||
* 3. Creates an orphan branch `v{major}` with ONLY the pre-built static site
|
||||
* plus minimal scaffolding so Netlify can "build" (no-op)
|
||||
* 4. Returns to the original branch
|
||||
*
|
||||
* For v21+: builds astro-docs (Astro/Starlight)
|
||||
* For v18-v20: builds nx-dev (Next.js with static export)
|
||||
*
|
||||
* The Netlify nx-dev app UI expects:
|
||||
* - Build command: npx nx run nx-dev:deploy-build:netlify --skip-nx-cache
|
||||
* - Publish dir: ./nx-dev/nx-dev/.next
|
||||
*
|
||||
* The versioned branch keeps that layout (project named "nx-dev" at
|
||||
* nx-dev/nx-dev/, static files at nx-dev/nx-dev/.next/) but uses a root
|
||||
* netlify.toml to override the build command to a no-op and — critically —
|
||||
* NOT include @netlify/plugin-nextjs, so Netlify serves pure static files.
|
||||
*
|
||||
* The resulting branch is deployed via Netlify branch deploys at v{major}.nx.dev.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..');
|
||||
|
||||
// TODO: Remove FIRST_ASTRO_MAJOR and buildAndStageNextjs() once v21+ are the
|
||||
// only versioned websites we maintain. At that point, delete the entire legacy
|
||||
// function and the branching logic that calls it.
|
||||
const FIRST_ASTRO_MAJOR = 21;
|
||||
|
||||
// Disable non-JS plugins that require external toolchains (Java, .NET)
|
||||
process.env.NX_GRADLE_DISABLE = 'true';
|
||||
process.env.NX_MAVEN_DISABLE = 'true';
|
||||
process.env.NX_DOTNET_DISABLE = 'true';
|
||||
|
||||
// Must match the Netlify UI publish directory setting
|
||||
const PUBLISH_DIR = 'nx-dev/nx-dev/.next';
|
||||
|
||||
// When useMise is true, commands are wrapped with `mise exec --` so they
|
||||
// use the Node version from .mise.toml (needed for legacy Next.js builds).
|
||||
let useMise = false;
|
||||
|
||||
function run(cmd: string, opts?: { cwd?: string }) {
|
||||
const fullCmd = useMise ? `mise exec -- ${cmd}` : cmd;
|
||||
console.log(`$ ${fullCmd}`);
|
||||
execSync(fullCmd, { stdio: 'inherit', cwd: opts?.cwd ?? ROOT });
|
||||
}
|
||||
|
||||
function parseVersion(input: string): string {
|
||||
const match = input.replace(/^v/i, '');
|
||||
if (!/^\d+$/.test(match)) {
|
||||
console.error(
|
||||
`Invalid version: "${input}". Expected a major version like "22" or "v22".`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
function findLatestStableTag(major: string): string | null {
|
||||
const tags = execSync(`git tag -l '${major}.*'`, { cwd: ROOT })
|
||||
.toString()
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((t) => t && !t.includes('-'));
|
||||
|
||||
if (tags.length === 0) return null;
|
||||
|
||||
tags.sort((a, b) => {
|
||||
const pa = a.split('.').map(Number);
|
||||
const pb = b.split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return tags[tags.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the shared scaffolding every versioned branch needs.
|
||||
* Writes headers/redirects netlify.toml content to the publish dir.
|
||||
*/
|
||||
function writeSharedScaffolding(
|
||||
tmpDir: string,
|
||||
headersAndRedirects: string
|
||||
): void {
|
||||
const rootPkg = JSON.parse(
|
||||
readFileSync(resolve(ROOT, 'package.json'), 'utf-8')
|
||||
);
|
||||
const nxVersion =
|
||||
rootPkg.devDependencies?.nx ?? rootPkg.dependencies?.nx ?? 'latest';
|
||||
|
||||
// Root package.json with nx so Netlify install succeeds
|
||||
writeFileSync(
|
||||
resolve(tmpDir, 'package.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: '@nx/nx-docs-versioned',
|
||||
version: '0.0.1',
|
||||
private: true,
|
||||
devDependencies: { nx: nxVersion },
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
|
||||
writeFileSync(resolve(tmpDir, 'nx.json'), JSON.stringify({}, null, 2) + '\n');
|
||||
|
||||
// Generate a real pnpm-lock.yaml from the stub package.json.
|
||||
// Must use --no-frozen-lockfile since there's no existing lockfile yet.
|
||||
console.log('Generating pnpm-lock.yaml for versioned branch...');
|
||||
execSync('pnpm install --lockfile-only --no-frozen-lockfile', {
|
||||
cwd: tmpDir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
// Project named "nx-dev" at nx-dev/nx-dev/ to match the Netlify build command:
|
||||
// npx nx run nx-dev:deploy-build:netlify --skip-nx-cache
|
||||
mkdirSync(resolve(tmpDir, 'nx-dev/nx-dev'), { recursive: true });
|
||||
writeFileSync(
|
||||
resolve(tmpDir, 'nx-dev/nx-dev/project.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'nx-dev',
|
||||
targets: {
|
||||
'deploy-build': {
|
||||
command: "echo 'Pre-built static site'",
|
||||
configurations: {
|
||||
netlify: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
|
||||
// Root netlify.toml overrides UI settings for this branch.
|
||||
// NETLIFY_NEXT_PLUGIN_SKIP disables the auto-installed @netlify/plugin-nextjs
|
||||
// so Netlify serves the publish dir as pure static files.
|
||||
writeFileSync(
|
||||
resolve(tmpDir, 'netlify.toml'),
|
||||
`# Auto-generated for versioned docs branch — overrides Netlify UI settings.
|
||||
# NETLIFY_NEXT_PLUGIN_SKIP disables @netlify/plugin-nextjs = pure static serving.
|
||||
|
||||
[build]
|
||||
command = "npx nx run nx-dev:deploy-build:netlify --skip-nx-cache"
|
||||
publish = "${PUBLISH_DIR}"
|
||||
|
||||
[build.environment]
|
||||
NETLIFY_NEXT_PLUGIN_SKIP = "true"
|
||||
|
||||
${headersAndRedirects}`
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage: redirect-to-prod (no build)
|
||||
// Produces a dummy publish dir + netlify.toml that 301s everything to nx.dev/docs.
|
||||
// Used to retire an old versioned subdomain without maintaining its docs.
|
||||
// ---------------------------------------------------------------------------
|
||||
function stageRedirectToProd(tmpDir: string): void {
|
||||
// Publish dir must match the Netlify UI setting (nx-dev/nx-dev/.next) —
|
||||
// the UI value is authoritative; a toml `publish` override isn't honored.
|
||||
const publishDir = resolve(tmpDir, PUBLISH_DIR);
|
||||
mkdirSync(publishDir, { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
resolve(publishDir, 'index.html'),
|
||||
`<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Redirecting to nx.dev/docs</title>
|
||||
<meta http-equiv="refresh" content="0; url=https://nx.dev/docs">
|
||||
<link rel="canonical" href="https://nx.dev/docs">
|
||||
<script>window.location.replace("https://nx.dev/docs");</script>
|
||||
</head>
|
||||
<body>
|
||||
Redirecting to <a href="https://nx.dev/docs">https://nx.dev/docs</a>.
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
);
|
||||
|
||||
// netlify.toml: overrides the UI build command to a no-op and force-301s
|
||||
// every path to nx.dev/docs. Publish dir stays as the UI-configured
|
||||
// ${PUBLISH_DIR}. No package.json / lockfile / nx scaffolding needed.
|
||||
writeFileSync(
|
||||
resolve(tmpDir, 'netlify.toml'),
|
||||
`# Auto-generated for redirect-to-prod versioned branch.
|
||||
# Overrides Netlify UI build command so no install/build runs.
|
||||
# Publish dir stays as the UI-configured ${PUBLISH_DIR}.
|
||||
|
||||
[build]
|
||||
command = "echo 'redirect-only branch — no build'"
|
||||
|
||||
[build.environment]
|
||||
NETLIFY_NEXT_PLUGIN_SKIP = "true"
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "https://nx.dev/docs"
|
||||
status = 301
|
||||
force = true
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build + stage: Astro (v21+)
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildAndStageAstro(tmpDir: string): void {
|
||||
run(`pnpm nx build astro-docs --force`);
|
||||
|
||||
const distDir = resolve(ROOT, 'astro-docs/dist');
|
||||
if (!existsSync(distDir)) {
|
||||
console.error('Build failed: astro-docs/dist not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
cpSync(distDir, resolve(tmpDir, PUBLISH_DIR), { recursive: true });
|
||||
|
||||
// Read the existing astro-docs/netlify.toml for headers/redirects
|
||||
const tomlContent = readFileSync(
|
||||
resolve(ROOT, 'astro-docs/netlify.toml'),
|
||||
'utf-8'
|
||||
);
|
||||
// Strip any [build] section from it — we provide our own
|
||||
const headersAndRedirects = tomlContent
|
||||
.replace(/\[build\.environment\][\s\S]*?(?=\n\[|\n#|\n\[\[|$)/g, '')
|
||||
.replace(/\[build\][\s\S]*?(?=\n\[|\n#|\n\[\[|$)/g, '')
|
||||
.trim();
|
||||
|
||||
writeSharedScaffolding(tmpDir, headersAndRedirects);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build + stage: Next.js (v18-v20)
|
||||
// TODO: Remove this entire function once v21+ are the only maintained versions.
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildAndStageNextjs(tmpDir: string): void {
|
||||
// Patch next.config.js to enable static export
|
||||
const nextConfigPath = resolve(ROOT, 'nx-dev/nx-dev/next.config.js');
|
||||
let nextConfig = readFileSync(nextConfigPath, 'utf-8');
|
||||
|
||||
if (!nextConfig.includes("output: 'export'")) {
|
||||
nextConfig = nextConfig.replace(
|
||||
/module\.exports\s*=\s*withNx\(\{/,
|
||||
"module.exports = withNx({\n output: 'export',\n images: { unoptimized: true },"
|
||||
);
|
||||
writeFileSync(nextConfigPath, nextConfig);
|
||||
}
|
||||
|
||||
// Remove pages that can't be statically exported (dirs or .tsx files)
|
||||
const pagesDir = resolve(ROOT, 'nx-dev/nx-dev/pages');
|
||||
for (const page of ['ai-chat', 'api', 'plugin-registry']) {
|
||||
for (const candidate of [
|
||||
resolve(pagesDir, page),
|
||||
resolve(pagesDir, `${page}.tsx`),
|
||||
]) {
|
||||
if (existsSync(candidate)) {
|
||||
console.log(
|
||||
`Removing non-static page: ${candidate.split('nx-dev/nx-dev/')[1]}`
|
||||
);
|
||||
rmSync(candidate, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Older Nx versions didn't use pnpm workspaces. Force hoisted node_modules
|
||||
// so all deps (e.g. @heroicons/react) are resolvable from nested nx-dev libs.
|
||||
writeFileSync(resolve(ROOT, '.npmrc'), 'node-linker=hoisted\n');
|
||||
run('pnpm install --no-frozen-lockfile');
|
||||
|
||||
// Run build-base (actual Next.js build) directly — skip sitemap and
|
||||
// link-checker which fail with static export.
|
||||
run(`pnpm nx run nx-dev:build-base --force`);
|
||||
|
||||
// @nx/next:build puts the Next.js build cache at dist/nx-dev/nx-dev/
|
||||
// and the static export (from output:'export') at dist/nx-dev/nx-dev/.next/
|
||||
// We want the static export HTML, not the build cache.
|
||||
const exportDir = resolve(ROOT, 'dist/nx-dev/nx-dev/.next');
|
||||
if (!existsSync(exportDir)) {
|
||||
console.error('Build failed: dist/nx-dev/nx-dev/.next not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
cpSync(exportDir, resolve(tmpDir, PUBLISH_DIR), {
|
||||
recursive: true,
|
||||
filter: (src) => !src.includes('/cache/') && !src.endsWith('/project.json'),
|
||||
});
|
||||
|
||||
const headersAndRedirects = `[[headers]]
|
||||
for = "/*"
|
||||
[headers.values]
|
||||
X-Frame-Options = "DENY"
|
||||
Content-Security-Policy = "frame-ancestors 'none'"
|
||||
|
||||
[[redirects]]
|
||||
from = "/docs"
|
||||
to = "/getting-started/intro"
|
||||
|
||||
[[redirects]]
|
||||
from = "/"
|
||||
to = "/getting-started/intro"`;
|
||||
|
||||
writeSharedScaffolding(tmpDir, headersAndRedirects);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Main
|
||||
// ===========================================================================
|
||||
|
||||
const positionalArgs = process.argv.slice(2).filter((a) => !a.startsWith('-'));
|
||||
const flags = new Set(process.argv.slice(2).filter((a) => a.startsWith('-')));
|
||||
const force = flags.has('--force');
|
||||
const redirectToProd = flags.has('--redirect-to-prod');
|
||||
|
||||
if (positionalArgs.length === 0) {
|
||||
console.error(
|
||||
'Usage: node ./scripts/create-versioned-docs.mts <version> [--force] [--redirect-to-prod]'
|
||||
);
|
||||
console.error(' e.g. node ./scripts/create-versioned-docs.mts v22');
|
||||
console.error(
|
||||
' node ./scripts/create-versioned-docs.mts v16 --redirect-to-prod'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Fail fast if working tree is dirty
|
||||
const status = execSync('git status --porcelain', { cwd: ROOT })
|
||||
.toString()
|
||||
.trim();
|
||||
if (status) {
|
||||
console.error(
|
||||
'Working tree is dirty. Commit or stash changes before running this script.\n'
|
||||
);
|
||||
console.error(status);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const majorVersion = parseVersion(positionalArgs[0]);
|
||||
const majorNum = Number(majorVersion);
|
||||
const branchName = majorVersion;
|
||||
const isAstro = majorNum >= FIRST_ASTRO_MAJOR;
|
||||
|
||||
// Check if branch already exists (locally or on origin)
|
||||
const branchExists =
|
||||
execSync(`git branch --list ${branchName}`, { cwd: ROOT })
|
||||
.toString()
|
||||
.trim() !== '' ||
|
||||
execSync(`git ls-remote --heads origin ${branchName}`, { cwd: ROOT })
|
||||
.toString()
|
||||
.trim() !== '';
|
||||
|
||||
if (branchExists && !force) {
|
||||
console.error(
|
||||
`Branch "${branchName}" already exists. Use --force to overwrite.\n` +
|
||||
` node ./scripts/create-versioned-docs.mts ${positionalArgs[0]} --force`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\nCreating versioned docs branch: ${branchName}`);
|
||||
console.log(
|
||||
`Site type: ${
|
||||
redirectToProd
|
||||
? 'redirect-to-prod (no build)'
|
||||
: isAstro
|
||||
? 'Astro (v21+)'
|
||||
: 'Next.js (legacy v18-v20)'
|
||||
}\n`
|
||||
);
|
||||
|
||||
// Save current ref to return to later
|
||||
const currentRef = execSync('git rev-parse --abbrev-ref HEAD', { cwd: ROOT })
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
// --- Step 0: Find and checkout the latest stable tag ---
|
||||
// Skipped in --redirect-to-prod mode: there's nothing to build, so the source
|
||||
// ref doesn't matter — stay on the current branch for commit provenance.
|
||||
let sourceRef: string;
|
||||
if (redirectToProd) {
|
||||
sourceRef = currentRef;
|
||||
console.log(
|
||||
'=== Step 0: Skipped (redirect-to-prod) ===\n' +
|
||||
`Using current ref: ${sourceRef}\n`
|
||||
);
|
||||
} else {
|
||||
console.log('=== Step 0: Finding latest stable release tag ===\n');
|
||||
run('git fetch --tags --force origin');
|
||||
|
||||
const latestTag = findLatestStableTag(majorVersion);
|
||||
if (latestTag) {
|
||||
sourceRef = latestTag;
|
||||
console.log(`Found latest stable tag: ${sourceRef}`);
|
||||
run(`git checkout ${sourceRef}`);
|
||||
} else {
|
||||
sourceRef = currentRef;
|
||||
console.log(
|
||||
`No stable tags found for major version ${majorVersion}. Using current branch: ${currentRef}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Resolve GITHUB_TOKEN (needed for GitHub stars in docs build) ---
|
||||
if (!redirectToProd && !process.env.GITHUB_TOKEN) {
|
||||
try {
|
||||
const token = execSync(
|
||||
"op read --account tuskteam 'op://Employee/API Keys/github_token'",
|
||||
{
|
||||
cwd: ROOT,
|
||||
}
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
if (token) {
|
||||
process.env.GITHUB_TOKEN = token;
|
||||
console.log('GITHUB_TOKEN resolved from 1Password.\n');
|
||||
}
|
||||
} catch {
|
||||
console.warn(
|
||||
'Could not resolve GITHUB_TOKEN from 1Password.\n' +
|
||||
'Re-run with: GITHUB_TOKEN="$(op read --account tuskteam \'op://Employee/API Keys/github_token\')" node ./scripts/create-versioned-docs.mts ...\n'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 1: Install + build ---
|
||||
console.log('\n=== Step 1: Building docs site ===\n');
|
||||
|
||||
if (!redirectToProd) {
|
||||
// Legacy Next.js builds need Node 20 — pin via mise so child processes use it
|
||||
if (!isAstro) {
|
||||
console.log('Pinning Node 20 via mise for legacy build...');
|
||||
run('mise use node@20');
|
||||
useMise = true;
|
||||
}
|
||||
|
||||
// Remove node_modules to avoid stale deps from a different version/branch
|
||||
const nodeModulesDir = resolve(ROOT, 'node_modules');
|
||||
if (existsSync(nodeModulesDir)) {
|
||||
console.log('Removing node_modules for clean install...');
|
||||
rmSync(nodeModulesDir, { recursive: true });
|
||||
}
|
||||
|
||||
run('pnpm install --frozen-lockfile');
|
||||
}
|
||||
|
||||
const tmpDir = resolve(ROOT, 'tmp/versioned-docs');
|
||||
if (existsSync(tmpDir)) {
|
||||
rmSync(tmpDir, { recursive: true });
|
||||
}
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
if (redirectToProd) {
|
||||
stageRedirectToProd(tmpDir);
|
||||
} else if (isAstro) {
|
||||
buildAndStageAstro(tmpDir);
|
||||
} else {
|
||||
buildAndStageNextjs(tmpDir);
|
||||
}
|
||||
|
||||
// --- Step 2: Create orphan branch ---
|
||||
console.log('\n=== Step 2: Creating orphan branch ===\n');
|
||||
|
||||
try {
|
||||
run(`git checkout --orphan ${branchName}-tmp`);
|
||||
run('git rm -rf .');
|
||||
|
||||
// Copy staged files into the working tree
|
||||
if (redirectToProd) {
|
||||
cpSync(resolve(tmpDir, 'netlify.toml'), resolve(ROOT, 'netlify.toml'));
|
||||
// Publish dir tree lives at nx-dev/nx-dev/.next/ — matches Netlify UI.
|
||||
cpSync(resolve(tmpDir, 'nx-dev'), resolve(ROOT, 'nx-dev'), {
|
||||
recursive: true,
|
||||
});
|
||||
run('git add netlify.toml nx-dev/');
|
||||
} else {
|
||||
cpSync(resolve(tmpDir, 'package.json'), resolve(ROOT, 'package.json'));
|
||||
cpSync(resolve(tmpDir, 'nx.json'), resolve(ROOT, 'nx.json'));
|
||||
cpSync(resolve(tmpDir, 'pnpm-lock.yaml'), resolve(ROOT, 'pnpm-lock.yaml'));
|
||||
cpSync(resolve(tmpDir, 'netlify.toml'), resolve(ROOT, 'netlify.toml'));
|
||||
cpSync(resolve(tmpDir, 'nx-dev'), resolve(ROOT, 'nx-dev'), {
|
||||
recursive: true,
|
||||
});
|
||||
run('git add package.json nx.json pnpm-lock.yaml netlify.toml nx-dev/');
|
||||
}
|
||||
run(
|
||||
`git commit -m "docs: versioned docs snapshot for ${branchName} (from ${sourceRef})"`
|
||||
);
|
||||
|
||||
run(`git branch -M ${branchName}`);
|
||||
|
||||
console.log(`\nBranch "${branchName}" created successfully.`);
|
||||
console.log(`\nTo push: git push -f origin ${branchName}`);
|
||||
} finally {
|
||||
useMise = false; // git commands don't need mise wrapping
|
||||
console.log(`\nReturning to: ${currentRef}`);
|
||||
// Clean untracked files left by orphan branch before switching back
|
||||
run('git clean -fd');
|
||||
run(`git checkout ${currentRef}`);
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('\nDone!');
|
||||
@@ -0,0 +1,210 @@
|
||||
import { json } from '@angular-devkit/core';
|
||||
import { SchemaFlattener } from './schema-flattener';
|
||||
|
||||
export enum OptionType {
|
||||
Any = 'any',
|
||||
Array = 'array',
|
||||
Boolean = 'boolean',
|
||||
Number = 'number',
|
||||
String = 'string',
|
||||
}
|
||||
|
||||
function _getEnumFromValue<E, T extends E[keyof E]>(
|
||||
value: json.JsonValue,
|
||||
enumeration: E,
|
||||
defaultValue: T
|
||||
): T {
|
||||
if (typeof value !== 'string') {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (Object.values(enumeration).indexOf(value) !== -1) {
|
||||
return value as unknown as T;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
export async function parseJsonSchemaToOptions(
|
||||
flattener: SchemaFlattener,
|
||||
schema: json.JsonObject
|
||||
): Promise<any[]> {
|
||||
const options: any[] = [];
|
||||
|
||||
function visitor(
|
||||
current: json.JsonObject | json.JsonArray,
|
||||
pointer: json.schema.JsonPointer,
|
||||
parentSchema?: json.JsonObject | json.JsonArray
|
||||
) {
|
||||
if (!parentSchema) {
|
||||
// Ignore root.
|
||||
return;
|
||||
} else if (pointer.split(/\/(?:properties|definitions)\//g).length > 2) {
|
||||
// Ignore subitems (objects or arrays).
|
||||
return;
|
||||
} else if (json.isJsonArray(current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pointer.indexOf('/not/') != -1) {
|
||||
// We don't support anyOf/not.
|
||||
throw new Error('The "not" keyword is not supported in JSON Schema.');
|
||||
}
|
||||
|
||||
const ptr = json.schema.parseJsonPointer(pointer); // eg: /properties/commands => [ 'properties', 'commands' ]
|
||||
const name = ptr[ptr.length - 1]; // eg: 'commands'
|
||||
|
||||
if (ptr[ptr.length - 2] != 'properties') {
|
||||
// Skip any non-property items.
|
||||
return;
|
||||
}
|
||||
|
||||
const typeSet = json.schema.getTypesOfSchema(current); // eg: array
|
||||
|
||||
if (typeSet.size == 0) {
|
||||
throw new Error('Cannot find type of schema.');
|
||||
}
|
||||
|
||||
// We only support number, string or boolean (or array of those), so remove everything else.
|
||||
|
||||
const types = Array.from(typeSet)
|
||||
.filter((x) => {
|
||||
switch (x) {
|
||||
case 'boolean':
|
||||
case 'number':
|
||||
case 'string':
|
||||
case 'array':
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.map((x) => _getEnumFromValue(x, OptionType, OptionType.String));
|
||||
|
||||
if (types.length == 0) {
|
||||
// This means it's not usable on the command line. e.g. an Object.
|
||||
return;
|
||||
}
|
||||
|
||||
// Only keep enum values we support (booleans, numbers and strings).
|
||||
const enumValues = (
|
||||
(json.isJsonArray(current.enum) && current.enum) ||
|
||||
[]
|
||||
).filter((x) => {
|
||||
switch (typeof x) {
|
||||
case 'boolean':
|
||||
case 'number':
|
||||
case 'string':
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}) as any[];
|
||||
|
||||
let defaultValue: string | number | boolean | undefined = undefined;
|
||||
if (current.default !== undefined) {
|
||||
switch (types[0]) {
|
||||
case 'string':
|
||||
if (typeof current.default == 'string') {
|
||||
defaultValue = current.default;
|
||||
}
|
||||
break;
|
||||
case 'number':
|
||||
if (typeof current.default == 'number') {
|
||||
defaultValue = current.default;
|
||||
}
|
||||
break;
|
||||
case 'boolean':
|
||||
if (typeof current.default == 'boolean') {
|
||||
defaultValue = current.default;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const type = types[0];
|
||||
const $default = current.$default;
|
||||
const $defaultIndex =
|
||||
json.isJsonObject($default) && $default['$source'] == 'argv'
|
||||
? $default['index']
|
||||
: undefined;
|
||||
const positional: number | undefined =
|
||||
typeof $defaultIndex == 'number' ? $defaultIndex : undefined;
|
||||
|
||||
const required =
|
||||
json.isJsonObject(parentSchema) && json.isJsonArray(parentSchema.required)
|
||||
? parentSchema.required.indexOf(name) != -1
|
||||
: false;
|
||||
const aliases = json.isJsonArray(current.aliases)
|
||||
? [...current.aliases].map((x) => `${x}`)
|
||||
: current.alias
|
||||
? [`${current.alias}`]
|
||||
: [];
|
||||
const format =
|
||||
typeof current.format == 'string' ? current.format : undefined;
|
||||
const visible = current.visible === undefined || current.visible === true;
|
||||
const hidden = !!current.hidden || !visible;
|
||||
|
||||
// Deprecated is set only if it's true or a string.
|
||||
const xDeprecated = current['x-deprecated'];
|
||||
const deprecated =
|
||||
xDeprecated === true || typeof xDeprecated == 'string'
|
||||
? xDeprecated
|
||||
: undefined;
|
||||
|
||||
const option: any = {
|
||||
name,
|
||||
description:
|
||||
current.description === undefined ? '' : `${current.description}`,
|
||||
...(types.length == 1 ? { type } : { type, types }),
|
||||
...(defaultValue !== undefined ? { default: defaultValue } : {}),
|
||||
...(enumValues && enumValues.length > 0 ? { enum: enumValues } : {}),
|
||||
required,
|
||||
aliases,
|
||||
...(format !== undefined ? { format } : {}),
|
||||
hidden,
|
||||
...(deprecated !== undefined ? { deprecated } : {}),
|
||||
...(positional !== undefined ? { positional } : {}),
|
||||
};
|
||||
|
||||
if (current.type === 'array' && current.items) {
|
||||
const items = current.items as {
|
||||
additionalProperties: boolean;
|
||||
properties: any;
|
||||
required?: string[];
|
||||
type: string;
|
||||
};
|
||||
|
||||
if (items.properties) {
|
||||
option.arrayOfType = items.type;
|
||||
option.arrayOfValues = Object.keys(items.properties).map((key) => ({
|
||||
name: key,
|
||||
...items.properties[key],
|
||||
isRequired: items.required && items.required.includes(key),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
options.push(option);
|
||||
}
|
||||
|
||||
const flattenedSchema = flattener.flatten(schema);
|
||||
json.schema.visitJsonSchema(flattenedSchema, visitor);
|
||||
|
||||
// Sort by positional.
|
||||
return options.sort((a, b) => {
|
||||
if (a.positional) {
|
||||
if (b.positional) {
|
||||
return a.positional - b.positional;
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
} else if (b.positional) {
|
||||
return -1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "documentation-map-schema",
|
||||
"title": "JSON schema for documentation map",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Title of the document"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Description of the document"
|
||||
},
|
||||
"content": {
|
||||
"type": "array",
|
||||
"description": "Dictionary map section",
|
||||
"items": {
|
||||
"$ref": "#/definitions/entry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"entry": {
|
||||
"type": "object",
|
||||
"required": ["name", "id"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name for the current item"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Identifier for the current item"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Description for the item"
|
||||
},
|
||||
"mediaImage": {
|
||||
"type": "string",
|
||||
"description": "Path to an alternate open graph image, relative to /docs"
|
||||
},
|
||||
"file": {
|
||||
"type": "string",
|
||||
"description": "Path to the markdown file"
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"description": "Tags are used on nx.dev to link related piece of content together (e.g: Related Documentation)"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Custom path or URL to find the item on nx.dev"
|
||||
},
|
||||
"isExternal": {
|
||||
"type": "boolean",
|
||||
"description": "Is the path provided is external to nx.dev?"
|
||||
},
|
||||
"itemList": {
|
||||
"type": "array",
|
||||
"description": "Children for the item",
|
||||
"items": {
|
||||
"$ref": "#/definitions/entry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,238 @@
|
||||
import { writeFileSync } from 'fs';
|
||||
import axios from 'axios';
|
||||
|
||||
interface Interval {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
interface PluginRegistry {
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const packagesJson = require('../../nx-dev/nx-dev/public/documentation/generated/manifests/new-nx-api.json');
|
||||
const officialPlugins = Object.keys(packagesJson)
|
||||
.filter(
|
||||
(m: any) =>
|
||||
packagesJson[m].name !== 'add-nx-to-monorepo' &&
|
||||
packagesJson[m].name !== 'cra-to-nx' &&
|
||||
packagesJson[m].name !== 'create-nx-plugin' &&
|
||||
packagesJson[m].name !== 'create-nx-workspace' &&
|
||||
packagesJson[m].name !== 'make-angular-cli-faster' &&
|
||||
packagesJson[m].name !== 'tao'
|
||||
)
|
||||
.map((k) => ({
|
||||
name: packagesJson[k].name === 'nx' ? 'nx' : '@nx/' + packagesJson[k].name,
|
||||
description: packagesJson[k].description,
|
||||
url: packagesJson[k].githubRoot,
|
||||
}));
|
||||
|
||||
const plugins =
|
||||
require('../../astro-docs/src/content/approved-community-plugins.json') as PluginRegistry[];
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const qualityIndicators: any = {};
|
||||
for (let i = 0; i < officialPlugins.length; i++) {
|
||||
const plugin = officialPlugins[i];
|
||||
console.log(`Fetching data for ${plugin.name}`);
|
||||
const npmData = await getNpmData(plugin, true);
|
||||
const npmDownloads = await getNpmDownloads(plugin);
|
||||
qualityIndicators[plugin.name] = {
|
||||
lastPublishedDate: npmData.lastPublishedDate,
|
||||
npmDownloads,
|
||||
githubRepo: `nrwl/nx`,
|
||||
};
|
||||
}
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
const plugin = plugins[i];
|
||||
console.log(`Fetching data for ${plugin.name}`);
|
||||
const npmData = await getNpmData(plugin);
|
||||
const npmDownloads = await getNpmDownloads(plugin);
|
||||
qualityIndicators[plugin.name] = {
|
||||
lastPublishedDate: npmData.lastPublishedDate,
|
||||
npmDownloads,
|
||||
githubRepo: npmData.githubRepo,
|
||||
nxVersion: npmData.nxVersion,
|
||||
};
|
||||
}
|
||||
const repos = Object.keys(qualityIndicators).map((pluginName) => {
|
||||
const [owner, repo] =
|
||||
qualityIndicators[pluginName].githubRepo?.split('/');
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
};
|
||||
});
|
||||
const starData = await getGithubStars(repos);
|
||||
Object.keys(qualityIndicators).forEach((key) => {
|
||||
qualityIndicators[key].githubStars =
|
||||
starData[qualityIndicators[key].githubRepo.replace(/[\-\/#]/g, '')]
|
||||
?.stargazers?.totalCount || -1;
|
||||
delete qualityIndicators[key].githubRepo;
|
||||
});
|
||||
|
||||
writeFileSync(
|
||||
'./nx-dev/nx-dev/pages/quality-indicators.json',
|
||||
JSON.stringify(qualityIndicators, null, 2)
|
||||
);
|
||||
} catch (ex) {
|
||||
console.warn('Failed to load quality indicators!');
|
||||
console.warn(ex);
|
||||
// Don't overwrite quality-indicators.json if the script fails
|
||||
}
|
||||
}
|
||||
main();
|
||||
|
||||
// Publish date (and github directory, readme content)
|
||||
// i.e. https://registry.npmjs.org/@nxkit/playwright
|
||||
async function getNpmData(plugin: PluginRegistry, skipNxVersion = false) {
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
`https://registry.npmjs.org/${plugin.name}`
|
||||
);
|
||||
const lastPublishedDate = data.time[data['dist-tags'].latest];
|
||||
const nxVersion = skipNxVersion || (await getNxVersion(data));
|
||||
if (!data.repository) {
|
||||
console.warn('- No repository defined in package.json!');
|
||||
return { lastPublishedDate, nxVersion, githubRepo: '' };
|
||||
}
|
||||
const url: String = data.repository.url;
|
||||
const indexOfTree = url.indexOf('/tree/');
|
||||
const githubRepo = url
|
||||
.slice(0, indexOfTree === -1 ? undefined : indexOfTree)
|
||||
.slice(0, url.indexOf('#') === -1 ? undefined : url.indexOf('#'))
|
||||
.slice(url.indexOf('github.com/') + 11)
|
||||
.replace('.git', '');
|
||||
return {
|
||||
lastPublishedDate,
|
||||
githubRepo,
|
||||
nxVersion,
|
||||
// readmeContent: plugin.name
|
||||
};
|
||||
} catch (ex) {
|
||||
console.warn('Failed to load npm data for ' + plugin.name, ex);
|
||||
return {
|
||||
lastPublishedData: '',
|
||||
githubRepo: '',
|
||||
nxVersion: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Download count
|
||||
// i.e. https://api.npmjs.org/downloads/point/2023-06-01:2023-07-01/@nxkit/playwright
|
||||
async function getNpmDownloads(plugin: PluginRegistry) {
|
||||
const lastMonth = getLastMonth();
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
`https://api.npmjs.org/downloads/point/${stringifyIntervalForUrl(
|
||||
lastMonth
|
||||
)}/${plugin.name}`
|
||||
);
|
||||
return data.downloads;
|
||||
} catch (ex) {
|
||||
console.warn('Failed to load npm downloads for ' + plugin.name, ex);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLastMonth() {
|
||||
const now = new Date();
|
||||
const oneMonthAgo = new Date();
|
||||
oneMonthAgo.setMonth(now.getMonth() - 1);
|
||||
return {
|
||||
start: oneMonthAgo,
|
||||
end: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function stringifyIntervalForUrl(interval: Interval): string {
|
||||
return `${stringifyDate(interval.start)}:${stringifyDate(interval.end)}`;
|
||||
}
|
||||
|
||||
export function stringifyDate(date: Date) {
|
||||
// yyyy-MM-dd
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// Stars
|
||||
// i.e. https://api.github.com/graphql
|
||||
async function getGithubStars(repos: { owner: string; repo: string }[]) {
|
||||
const query = `
|
||||
fragment repoProperties on Repository {
|
||||
nameWithOwner
|
||||
stargazers {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
${repos
|
||||
.filter(({ owner, repo }) => owner && repo && !owner.includes('.'))
|
||||
.map(
|
||||
({ owner, repo }, index) =>
|
||||
`${owner.replace(/[\-#]/g, '')}${repo.replace(
|
||||
/[\-#]/g,
|
||||
''
|
||||
)}: repository(owner: "${owner}", name: "${repo}") {
|
||||
...repoProperties
|
||||
}`
|
||||
)
|
||||
.join('\n')}
|
||||
}`;
|
||||
|
||||
const result = await axios.post(
|
||||
'https://api.github.com/graphql',
|
||||
{
|
||||
query,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return result.data.data;
|
||||
}
|
||||
|
||||
async function getNxVersion(data: any) {
|
||||
const latest = data['dist-tags'].latest;
|
||||
const nxPackages = ['@nx/devkit', '@nx/workspace'];
|
||||
let devkitVersion = '';
|
||||
for (let i = 0; i < nxPackages.length && !devkitVersion; i++) {
|
||||
const packageName = nxPackages[i];
|
||||
if (data.versions[latest]?.dependencies) {
|
||||
devkitVersion = data.versions[latest]?.dependencies[packageName];
|
||||
if (devkitVersion) {
|
||||
return await findNxRange(devkitVersion);
|
||||
}
|
||||
}
|
||||
if (!devkitVersion && data.versions[latest]?.peerDependencies) {
|
||||
devkitVersion = data.versions[latest]?.peerDependencies[packageName];
|
||||
if (devkitVersion) {
|
||||
return await findNxRange(devkitVersion);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.warn('- No dependency on @nx/devkit!');
|
||||
return devkitVersion;
|
||||
}
|
||||
|
||||
async function findNxRange(devkitVersion: string) {
|
||||
devkitVersion = devkitVersion
|
||||
.replace('^', '')
|
||||
.replace('>=', '')
|
||||
.replace('>', '');
|
||||
const { data: devkitData } = await axios.get(
|
||||
`https://registry.npmjs.org/@nx/devkit`
|
||||
);
|
||||
if (!devkitData.versions[devkitVersion]?.peerDependencies) {
|
||||
const dependencies = devkitData.versions[devkitVersion]?.dependencies;
|
||||
return dependencies?.nx;
|
||||
}
|
||||
return devkitData.versions[devkitVersion]?.peerDependencies.nx;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Prebuild script that fetches banner configuration from a Framer URL
|
||||
* and saves it to a local JSON file for use at build time.
|
||||
*
|
||||
* The Framer page renders JSON inside a <pre> tag which we extract and parse.
|
||||
*/
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
|
||||
// Support configurable env var and output path for use by both nx-dev and astro-docs
|
||||
const BANNER_ENV_VAR = process.env.BANNER_ENV_VAR || 'NEXT_PUBLIC_BANNER_URL';
|
||||
const BANNER_URL = process.env[BANNER_ENV_VAR];
|
||||
const OUTPUT_PATH = process.env.BANNER_OUTPUT_PATH || 'lib/banner.json';
|
||||
|
||||
/**
|
||||
* Extract JSON from a Framer HTML page.
|
||||
* Framer renders the banner config as JSON inside a <pre> tag.
|
||||
*/
|
||||
function extractJsonFromFramerHtml(html) {
|
||||
// Look for JSON in a <pre> tag (Framer renders it this way)
|
||||
const preMatch = html.match(/<pre[^>]*>([\s\S]*?)<\/pre>/i);
|
||||
if (preMatch && preMatch[1]) {
|
||||
try {
|
||||
// Clean up any HTML entities and parse
|
||||
const jsonStr = preMatch[1]
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/'/g, "'")
|
||||
.trim();
|
||||
return JSON.parse(jsonStr);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse <pre> content as JSON:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to parse the entire response as JSON (for direct JSON endpoints)
|
||||
try {
|
||||
return JSON.parse(html);
|
||||
} catch {
|
||||
// Not valid JSON
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the config has all required fields
|
||||
*/
|
||||
function validateBannerConfig(config) {
|
||||
if (!config || typeof config !== 'object') return false;
|
||||
if (typeof config.title !== 'string' || !config.title) return false;
|
||||
if (typeof config.description !== 'string') return false;
|
||||
if (typeof config.primaryCtaUrl !== 'string' || !config.primaryCtaUrl)
|
||||
return false;
|
||||
if (typeof config.primaryCtaText !== 'string' || !config.primaryCtaText)
|
||||
return false;
|
||||
// activeUntil is required for determining when the banner expires
|
||||
if (typeof config.activeUntil !== 'string' || !config.activeUntil)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip optional fields that are present but malformed, so a bad optional
|
||||
* value never drops the whole banner.
|
||||
*/
|
||||
function normalizeBannerConfig(config) {
|
||||
if (config.artwork !== undefined) {
|
||||
if (typeof config.artwork !== 'string' || !config.artwork) {
|
||||
console.warn('Ignoring invalid artwork value:', config.artwork);
|
||||
delete config.artwork;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Empty array for when no banner is configured
|
||||
const emptyCollection = [];
|
||||
|
||||
if (!BANNER_URL) {
|
||||
console.log(`${BANNER_ENV_VAR} not set, writing empty banner collection`);
|
||||
mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
|
||||
writeFileSync(OUTPUT_PATH, JSON.stringify(emptyCollection, null, 2) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Fetching banner config from: ${BANNER_URL}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(BANNER_URL, {
|
||||
headers: {
|
||||
Accept: 'text/html,application/xhtml+xml,application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const config = extractJsonFromFramerHtml(text);
|
||||
|
||||
if (!config) {
|
||||
throw new Error('No valid JSON found in banner page');
|
||||
}
|
||||
|
||||
if (!validateBannerConfig(config)) {
|
||||
throw new Error('Invalid banner configuration format');
|
||||
}
|
||||
|
||||
// Wrap in array for collection format, add id for Astro file loader
|
||||
const collection = [{ id: 'banner', ...normalizeBannerConfig(config) }];
|
||||
|
||||
console.log('Banner config fetched successfully:', config.title);
|
||||
mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
|
||||
writeFileSync(OUTPUT_PATH, JSON.stringify(collection, null, 2) + '\n');
|
||||
console.log(`Written to ${OUTPUT_PATH}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch banner config:', error.message);
|
||||
console.log('Writing empty banner collection as fallback');
|
||||
mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
|
||||
writeFileSync(OUTPUT_PATH, JSON.stringify(emptyCollection, null, 2) + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,5 @@
|
||||
import { json } from '@angular-devkit/core';
|
||||
|
||||
export interface SchemaFlattener {
|
||||
flatten: (schema) => json.JsonObject;
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { MenuItem } from '@nx/nx-dev-models-menu';
|
||||
import { outputFileSync } from 'fs-extra';
|
||||
import {
|
||||
bold,
|
||||
code,
|
||||
h2,
|
||||
lines as mdLines,
|
||||
strikethrough,
|
||||
table,
|
||||
} from 'markdown-factory';
|
||||
import { join } from 'path';
|
||||
import { format, resolveConfig } from 'prettier';
|
||||
import { CommandModule } from 'yargs';
|
||||
import { stripVTControlCharacters } from 'node:util';
|
||||
|
||||
const importFresh = require('import-fresh');
|
||||
|
||||
export function sortAlphabeticallyFunction(a: string, b: string): number {
|
||||
const nameA = a.toUpperCase(); // ignore upper and lowercase
|
||||
const nameB = b.toUpperCase(); // ignore upper and lowercase
|
||||
if (nameA < nameB) {
|
||||
return -1;
|
||||
}
|
||||
if (nameA > nameB) {
|
||||
return 1;
|
||||
}
|
||||
// names must be equal
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function sortByBooleanFunction(a: boolean, b: boolean): number {
|
||||
if (a && !b) {
|
||||
return -1;
|
||||
}
|
||||
if (!a && b) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function generateMarkdownFile(
|
||||
outputDirectory: string,
|
||||
templateObject: { name: string; template: string }
|
||||
): Promise<void> {
|
||||
const filePath = join(outputDirectory, `${templateObject.name}.md`);
|
||||
outputFileSync(
|
||||
filePath,
|
||||
await formatWithPrettier(
|
||||
filePath,
|
||||
stripVTControlCharacters(templateObject.template)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateJsonFile(
|
||||
filePath: string,
|
||||
json: unknown
|
||||
): Promise<void> {
|
||||
outputFileSync(
|
||||
filePath,
|
||||
await formatWithPrettier(filePath, JSON.stringify(json)),
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
}
|
||||
|
||||
function menuItemToStrings(item: MenuItem, pathPrefix = '/'): string[] {
|
||||
if (item.isExternal) {
|
||||
return [];
|
||||
}
|
||||
const line = item.path ? `- [${item.name}](${item.path})` : `- ${item.name}`;
|
||||
const padding = item.path
|
||||
.replace(pathPrefix, '')
|
||||
.split('/')
|
||||
.map(() => ' ')
|
||||
.join('');
|
||||
const childLines = item.children.flatMap((child) =>
|
||||
menuItemToStrings(child, pathPrefix)
|
||||
);
|
||||
return [padding + line, ...childLines];
|
||||
}
|
||||
|
||||
function deduplicate<T>(array: T[]): T[] {
|
||||
return Array.from(new Set(array));
|
||||
}
|
||||
|
||||
export async function generateIndexMarkdownFile(
|
||||
filePath: string,
|
||||
json: { id: string; menu: MenuItem[] }[]
|
||||
): Promise<void> {
|
||||
function capitalize(word: string) {
|
||||
const [firstLetter, ...rest] = word;
|
||||
return firstLetter.toLocaleUpperCase() + rest.join('');
|
||||
}
|
||||
const idToPathPrefix = {
|
||||
nx: undefined,
|
||||
recipes: `/recipes/`,
|
||||
plugins: `/plugins/`,
|
||||
packages: `/packages/`,
|
||||
ci: `/ci/`,
|
||||
};
|
||||
const content = json
|
||||
.map(
|
||||
({ id, menu }) =>
|
||||
deduplicate(
|
||||
[
|
||||
`- ${capitalize(id)}`,
|
||||
...menu.flatMap((item) =>
|
||||
menuItemToStrings(item, idToPathPrefix[id])
|
||||
),
|
||||
].filter((line) => line.length > 0)
|
||||
).join('\n') + '\n'
|
||||
)
|
||||
.join(`\n`);
|
||||
outputFileSync(filePath, await formatWithPrettier(filePath, content), {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
export async function formatWithPrettier(filePath: string, content: string) {
|
||||
let options: any = {
|
||||
filepath: filePath,
|
||||
};
|
||||
const resolvedOptions = await resolveConfig(filePath);
|
||||
if (resolvedOptions) {
|
||||
options = {
|
||||
...options,
|
||||
...resolvedOptions,
|
||||
};
|
||||
}
|
||||
|
||||
return format(content, options);
|
||||
}
|
||||
|
||||
export function wrapLinks(content: string): string {
|
||||
const urlRegex = /(https?:\/\/)[^\s]+[a-zA-Z][a-zA-Z]/g;
|
||||
const links = content.match(urlRegex) || [];
|
||||
for (const link of links) {
|
||||
const wrappedLink = `[${link}](${link.replace('https://nx.dev', '')})`;
|
||||
content = content.replace(link, wrappedLink);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
export function formatDescription(
|
||||
description: string,
|
||||
deprecated: boolean | string
|
||||
) {
|
||||
const updatedDescription = wrapLinks(description);
|
||||
if (!deprecated) {
|
||||
return updatedDescription;
|
||||
}
|
||||
if (!description) {
|
||||
return `${bold('Deprecated:')} ${deprecated}`;
|
||||
}
|
||||
return deprecated === true
|
||||
? `${bold('Deprecated:')} ${updatedDescription}`
|
||||
: mdLines(`${bold('Deprecated:')} ${deprecated}`, updatedDescription);
|
||||
}
|
||||
|
||||
export function getCommands(command: any) {
|
||||
return command.getInternalMethods().getCommandInstance().getCommandHandlers();
|
||||
}
|
||||
|
||||
export interface ParsedCommandOption {
|
||||
name: string[];
|
||||
type: string;
|
||||
description: string;
|
||||
default: string;
|
||||
deprecated: boolean | string;
|
||||
hidden: boolean;
|
||||
choices?: string[];
|
||||
}
|
||||
|
||||
export interface ParsedCommand {
|
||||
name: string;
|
||||
commandString: string;
|
||||
description: string;
|
||||
deprecated: string;
|
||||
options?: Array<ParsedCommandOption>;
|
||||
subcommands?: Array<ParsedCommand>;
|
||||
}
|
||||
|
||||
const YargsTypes = ['array', 'count', 'string', 'boolean', 'number'];
|
||||
|
||||
export async function parseCommand(
|
||||
name: string,
|
||||
command: any
|
||||
): Promise<ParsedCommand> {
|
||||
// It is not a function return a strip down version of the command
|
||||
if (
|
||||
!(
|
||||
command.builder &&
|
||||
command.builder.constructor &&
|
||||
command.builder.call &&
|
||||
command.builder.apply
|
||||
)
|
||||
) {
|
||||
return {
|
||||
name,
|
||||
commandString: command.original,
|
||||
deprecated: command.deprecated,
|
||||
description: command.description,
|
||||
};
|
||||
}
|
||||
|
||||
// Show all the options we can get from yargs
|
||||
const builder = await command.builder(
|
||||
importFresh('yargs')().getInternalMethods().reset()
|
||||
);
|
||||
const builderDescriptions = builder
|
||||
.getInternalMethods()
|
||||
.getUsageInstance()
|
||||
.getDescriptions();
|
||||
const builderOptions = builder.getOptions();
|
||||
const builderDefaultOptions = builderOptions.default;
|
||||
const builderAutomatedOptions = builderOptions.defaultDescription;
|
||||
const builderDeprecatedOptions = builder.getDeprecatedOptions();
|
||||
const builderOptionsChoices = builderOptions.choices;
|
||||
const builderOptionTypes = YargsTypes.reduce((acc, type) => {
|
||||
builderOptions[type].forEach(
|
||||
(option: any) => (acc = { ...acc, [option]: type })
|
||||
);
|
||||
return acc;
|
||||
}, {});
|
||||
const subcommands = await Promise.all(
|
||||
Object.entries(getCommands(builder))
|
||||
.filter(([, subCommandConfig]) => {
|
||||
const c = subCommandConfig as CommandModule;
|
||||
// These are all supported yargs fields for description, even though the types don't reflect that
|
||||
// @ts-ignore
|
||||
return c.description || c.describe || c.desc;
|
||||
})
|
||||
.map(([subCommandName, subCommandConfig]) =>
|
||||
parseCommand(subCommandName, subCommandConfig)
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
name,
|
||||
description: command.description,
|
||||
commandString: command.original.replace('$0', name),
|
||||
deprecated: command.deprecated,
|
||||
options:
|
||||
Object.keys(builderDescriptions).map((key) => ({
|
||||
name: [key, ...(builderOptions.alias[key] || [])],
|
||||
description: builderDescriptions[key]
|
||||
? builderDescriptions[key].replace('__yargsString__:', '')
|
||||
: '',
|
||||
default: builderDefaultOptions[key] ?? builderAutomatedOptions[key],
|
||||
type: (<any>builderOptionTypes)[key],
|
||||
choices: builderOptionsChoices[key],
|
||||
deprecated: builderDeprecatedOptions[key],
|
||||
hidden: builderOptions.hiddenOptions.includes(key),
|
||||
})) || null,
|
||||
subcommands,
|
||||
};
|
||||
}
|
||||
|
||||
export function generateOptionsMarkdown(
|
||||
command: ParsedCommand,
|
||||
extraHeadingLevels = 0
|
||||
): string {
|
||||
type FieldName = 'name' | 'type' | 'description';
|
||||
const items: Record<FieldName, string>[] = [];
|
||||
const optionsField = command.subcommands?.length ? 'Shared Option' : 'Option';
|
||||
const fields: { field: FieldName; label: string }[] = [
|
||||
{ field: 'name', label: optionsField },
|
||||
{ field: 'type', label: 'Type' },
|
||||
{ field: 'description', label: 'Description' },
|
||||
];
|
||||
if (Array.isArray(command.options) && !!command.options.length) {
|
||||
command.options
|
||||
.sort((a, b) => sortAlphabeticallyFunction(a.name[0], b.name[0]))
|
||||
.filter(({ hidden }) => !hidden)
|
||||
.forEach((option) => {
|
||||
function nameAliases(aliases) {
|
||||
return aliases.map((alias) => code('--' + alias)).join(', ');
|
||||
}
|
||||
const name = option.deprecated
|
||||
? strikethrough(nameAliases(option.name))
|
||||
: nameAliases(option.name);
|
||||
let description = formatDescription(
|
||||
option.description,
|
||||
option.deprecated
|
||||
);
|
||||
let type = option.type;
|
||||
if (option.choices !== undefined) {
|
||||
type = option.choices
|
||||
.map((c: any) => '`' + JSON.stringify(c).replace(/"/g, '') + '`')
|
||||
.join(', ');
|
||||
}
|
||||
if (option.default !== undefined) {
|
||||
description += ` (Default: \`${JSON.stringify(option.default).replace(
|
||||
/"/g,
|
||||
''
|
||||
)}\`)`;
|
||||
}
|
||||
if (
|
||||
(option.name[0] === 'version' &&
|
||||
option.description === 'Show version number') ||
|
||||
(option.name[0] === 'help' && option.description === 'Show help')
|
||||
) {
|
||||
// Add . to the end of the built-in description for consistency with our other descriptions
|
||||
description = `${description}.`;
|
||||
}
|
||||
items.push({ name, type, description });
|
||||
});
|
||||
}
|
||||
return h2('Options', table(items, fields));
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Supply chain hardening: expands transitive dependencies of a package into
|
||||
* explicit, pinned direct dependencies in package.json.
|
||||
*
|
||||
* This ensures that `pnpm install nx@latest` produces a fully deterministic
|
||||
* install with zero resolver freedom — every package version is predetermined.
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node scripts/expand-deps.ts --project nx
|
||||
* npx ts-node scripts/expand-deps.ts --project nx --dry-run
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import * as yaml from 'yaml';
|
||||
|
||||
const workspaceRoot = join(__dirname, '..');
|
||||
|
||||
interface LockfileImporterDep {
|
||||
specifier: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
interface LockfileSnapshot {
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface Lockfile {
|
||||
importers: Record<
|
||||
string,
|
||||
{
|
||||
dependencies?: Record<string, LockfileImporterDep>;
|
||||
optionalDependencies?: Record<string, LockfileImporterDep>;
|
||||
}
|
||||
>;
|
||||
snapshots: Record<string, LockfileSnapshot>;
|
||||
}
|
||||
|
||||
function parseArgs(): { project: string; dryRun: boolean; verbose: boolean } {
|
||||
const args = process.argv.slice(2);
|
||||
let project = '';
|
||||
let dryRun = false;
|
||||
let verbose = false;
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg === '--dry-run') {
|
||||
dryRun = true;
|
||||
} else if (arg === '--verbose') {
|
||||
verbose = true;
|
||||
} else if (!arg.startsWith('-')) {
|
||||
project = arg;
|
||||
}
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
console.error('Usage: expand-deps <project> [--dry-run] [--verbose]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { project, dryRun, verbose };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip peer dep qualifiers from a version string.
|
||||
* e.g. "1.15.11(debug@4.4.1)" -> "1.15.11"
|
||||
*/
|
||||
function stripPeerQualifier(version: string): string {
|
||||
const parenIndex = version.indexOf('(');
|
||||
return parenIndex === -1 ? version : version.substring(0, parenIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the snapshot lookup key for a package.
|
||||
* In the snapshots section, entries are keyed as "name@version" or
|
||||
* "name@version(peer-qualifiers)". We need to find the right entry.
|
||||
*/
|
||||
function findSnapshotEntry(
|
||||
snapshots: Record<string, LockfileSnapshot>,
|
||||
name: string,
|
||||
version: string
|
||||
): LockfileSnapshot | null {
|
||||
// Try exact match first (name@version)
|
||||
const exactKey = `${name}@${version}`;
|
||||
if (snapshots[exactKey]) {
|
||||
return snapshots[exactKey];
|
||||
}
|
||||
|
||||
// Look for entries with peer qualifiers (name@version(...))
|
||||
for (const key of Object.keys(snapshots)) {
|
||||
if (key === exactKey || key.startsWith(`${exactKey}(`)) {
|
||||
return snapshots[key];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ConflictInfo {
|
||||
name: string;
|
||||
version1: string;
|
||||
path1: string[];
|
||||
version2: string;
|
||||
path2: string[];
|
||||
}
|
||||
|
||||
function walkDeps(
|
||||
snapshots: Record<string, LockfileSnapshot>,
|
||||
name: string,
|
||||
version: string,
|
||||
resolved: Map<string, { version: string; path: string[] }>,
|
||||
conflicts: ConflictInfo[],
|
||||
currentPath: string[]
|
||||
): void {
|
||||
const path = [...currentPath, `${name}@${version}`];
|
||||
|
||||
// Check for conflicts
|
||||
const existing = resolved.get(name);
|
||||
if (existing) {
|
||||
if (existing.version === version) {
|
||||
return; // Already visited with same version
|
||||
}
|
||||
conflicts.push({
|
||||
name,
|
||||
version1: existing.version,
|
||||
path1: existing.path,
|
||||
version2: version,
|
||||
path2: path,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
resolved.set(name, { version, path });
|
||||
|
||||
const snapshot = findSnapshotEntry(snapshots, name, version);
|
||||
if (!snapshot) {
|
||||
// Leaf dependency with no sub-deps (e.g. "cli-spinners@2.6.1: {}")
|
||||
return;
|
||||
}
|
||||
|
||||
const deps = snapshot.dependencies || {};
|
||||
for (const [depName, depVersionRaw] of Object.entries(deps)) {
|
||||
const depVersion = stripPeerQualifier(depVersionRaw);
|
||||
walkDeps(snapshots, depName, depVersion, resolved, conflicts, path);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { project, dryRun, verbose } = parseArgs();
|
||||
|
||||
const packageJsonPath = join(
|
||||
workspaceRoot,
|
||||
'packages',
|
||||
project,
|
||||
'package.json'
|
||||
);
|
||||
const lockfilePath = join(workspaceRoot, 'pnpm-lock.yaml');
|
||||
|
||||
// Read package.json
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
const originalDeps: Record<string, string> = packageJson.dependencies || {};
|
||||
const directDepNames = new Set(Object.keys(originalDeps));
|
||||
|
||||
// Parse lockfile
|
||||
console.log('Parsing pnpm-lock.yaml...');
|
||||
const lockfileContent = readFileSync(lockfilePath, 'utf-8');
|
||||
const lockfile: Lockfile = yaml.parse(lockfileContent);
|
||||
|
||||
// Find the importer entry for this package
|
||||
const importerKey = `packages/${project}`;
|
||||
const importer = lockfile.importers[importerKey];
|
||||
if (!importer) {
|
||||
console.error(
|
||||
`ERROR: No importer entry found for "${importerKey}" in pnpm-lock.yaml`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const importerDeps = importer.dependencies || {};
|
||||
|
||||
// Resolve direct deps to their pinned versions from the lockfile
|
||||
const directDepsResolved = new Map<
|
||||
string,
|
||||
{ specifier: string; resolvedVersion: string }
|
||||
>();
|
||||
const skippedDeps: string[] = [];
|
||||
for (const depName of directDepNames) {
|
||||
const importerDep = importerDeps[depName];
|
||||
if (!importerDep) {
|
||||
// This dep is in package.json but not in the lockfile importer section.
|
||||
// This happens when package.json is dirty from a previous expand-deps run
|
||||
// that wasn't cleaned up, or when someone forgot to run `pnpm install`.
|
||||
// In CI we fail hard; locally we skip and warn.
|
||||
if (process.env.CI) {
|
||||
console.error(
|
||||
`ERROR: Direct dependency "${depName}" not found in lockfile importers section. Run \`pnpm install\` first.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
skippedDeps.push(depName);
|
||||
continue;
|
||||
}
|
||||
directDepsResolved.set(depName, {
|
||||
specifier: originalDeps[depName],
|
||||
resolvedVersion: stripPeerQualifier(importerDep.version),
|
||||
});
|
||||
}
|
||||
|
||||
if (skippedDeps.length > 0) {
|
||||
console.warn(
|
||||
`\nWARNING: ${skippedDeps.length} dependencies were not expanded since they were missing in the lockfile. Run with --verbose to see the list. Make sure you run \`pnpm install\` first.`
|
||||
);
|
||||
if (verbose) {
|
||||
for (const dep of skippedDeps) {
|
||||
console.warn(` - ${dep}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Walk the full transitive dependency tree
|
||||
const resolved = new Map<string, { version: string; path: string[] }>();
|
||||
const conflicts: ConflictInfo[] = [];
|
||||
|
||||
for (const [depName, { resolvedVersion }] of directDepsResolved) {
|
||||
walkDeps(
|
||||
lockfile.snapshots,
|
||||
depName,
|
||||
resolvedVersion,
|
||||
resolved,
|
||||
conflicts,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
if (conflicts.length > 0) {
|
||||
console.error('\nERROR: Version conflicts detected:\n');
|
||||
for (const conflict of conflicts) {
|
||||
console.error(` "${conflict.name}":`);
|
||||
console.error(` Version ${conflict.version1}`);
|
||||
console.error(` Path: ${conflict.path1.join(' -> ')}`);
|
||||
console.error(` Version ${conflict.version2}`);
|
||||
console.error(` Path: ${conflict.path2.join(' -> ')}`);
|
||||
console.error('');
|
||||
}
|
||||
console.error('Resolve manually before publishing.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Build the expanded dependencies object
|
||||
const expandedDeps: Record<string, string> = {};
|
||||
const sortedNames = [...resolved.keys()].sort();
|
||||
for (const name of sortedNames) {
|
||||
expandedDeps[name] = resolved.get(name)!.version;
|
||||
}
|
||||
|
||||
// Report
|
||||
console.log(`\nexpand-deps: ${project}\n`);
|
||||
|
||||
// Direct deps changes
|
||||
console.log('Direct deps (resolved from lockfile):');
|
||||
for (const [depName, { specifier, resolvedVersion }] of directDepsResolved) {
|
||||
if (specifier === resolvedVersion) {
|
||||
console.log(` ${depName}: ${resolvedVersion} (unchanged)`);
|
||||
} else {
|
||||
console.log(
|
||||
` ${depName}: ${resolvedVersion} (was "${specifier}" — ${specifier.startsWith('catalog:') ? 'resolved' : 'pinned'})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// New transitive deps
|
||||
const transitiveDeps = sortedNames.filter(
|
||||
(name) => !directDepNames.has(name)
|
||||
);
|
||||
console.log(`\nNew transitive deps to add (${transitiveDeps.length}):`);
|
||||
for (const name of transitiveDeps) {
|
||||
console.log(` ${name}: ${resolved.get(name)!.version}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\nTotal: ${directDepNames.size} direct -> ${resolved.size} total deps (${transitiveDeps.length} transitive added)`
|
||||
);
|
||||
|
||||
if (dryRun) {
|
||||
console.log('\n--dry-run: no changes written.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Write back
|
||||
packageJson.dependencies = expandedDeps;
|
||||
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
|
||||
console.log(`\nWrote expanded dependencies to ${packageJsonPath}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,69 @@
|
||||
const cp = require('child_process');
|
||||
|
||||
async function calculate() {
|
||||
const aa = await Promise.all(
|
||||
[1, 2, 3, 4, 5].map((q) => {
|
||||
const v = cp
|
||||
.execSync(
|
||||
`curl -i "https://api.github.com/repos/nrwl/nx/issues?state=open&page=${q}&per_page=100"`
|
||||
)
|
||||
.toString();
|
||||
const substr = v.substring(v.indexOf('['));
|
||||
return JSON.parse(substr);
|
||||
})
|
||||
);
|
||||
let all = [];
|
||||
aa.forEach((a) => all.push(...a));
|
||||
all = all.filter((a) => a.html_url.indexOf('/pull') === -1);
|
||||
console.log('total number', all.length);
|
||||
|
||||
const grouped = {};
|
||||
all.forEach((i) => {
|
||||
const ll = i.labels;
|
||||
let scope = (
|
||||
ll.find((lll) => lll.name.indexOf('scope:') > -1) || {
|
||||
name: 'no-scope',
|
||||
}
|
||||
).name;
|
||||
|
||||
if (
|
||||
scope === 'scope: react' ||
|
||||
scope === 'scope: nextjs' ||
|
||||
scope === 'scope: gatsby'
|
||||
) {
|
||||
scope = 'scope: react+next+gatsby';
|
||||
}
|
||||
|
||||
if (!grouped[scope]) grouped[scope] = [];
|
||||
grouped[scope].push(i);
|
||||
});
|
||||
|
||||
let totalBugs = 0;
|
||||
Object.keys(grouped).forEach((k) => {
|
||||
const bugs = grouped[k].filter((i) => {
|
||||
const ll = i.labels;
|
||||
return !!ll.find((lll) => lll.name.indexOf('type: bug') > -1);
|
||||
});
|
||||
totalBugs += bugs.length;
|
||||
console.log(`${k}, issues: ${grouped[k].length}, bugs: ${bugs.length}`);
|
||||
});
|
||||
|
||||
console.log('without scope');
|
||||
grouped['no-scope'].forEach((issue) => {
|
||||
if (
|
||||
issue.labels.every((lll) => lll.name.indexOf('type: question') === -1)
|
||||
) {
|
||||
console.log(issue.html_url);
|
||||
} else {
|
||||
// console.log(`question: ${issue.html_url}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Total bugs: ${totalBugs}`);
|
||||
}
|
||||
|
||||
calculate()
|
||||
.then(() => {
|
||||
console.log('done');
|
||||
})
|
||||
.catch((e) => console.log(e));
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ensureDirSync } from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { readdirSync, writeFileSync } from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
function generateFileContent(
|
||||
workspaces: { id: string; label: string; url: string }[]
|
||||
) {
|
||||
return `
|
||||
window.exclude = [];
|
||||
window.watch = false;
|
||||
window.environment = 'dev';
|
||||
window.useXstateInspect = false;
|
||||
|
||||
window.appConfig = {
|
||||
showDebugger: true,
|
||||
showExperimentalFeatures: true,
|
||||
workspaces: ${JSON.stringify(workspaces)},
|
||||
defaultWorkspaceId: '${workspaces[0].id}',
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
function writeFile() {
|
||||
let generatedGraphs;
|
||||
try {
|
||||
generatedGraphs = readdirSync(
|
||||
join(__dirname, '../graph/client/src/assets/generated-project-graphs')
|
||||
).map((filename) => {
|
||||
const id = filename.substring(0, filename.length - 5);
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
projectGraphUrl: join('assets/generated-project-graphs/', filename),
|
||||
taskGraphUrl: join('assets/generated-task-graphs/', filename),
|
||||
taskInputsUrl: join('assets/generated-task-inputs/', filename),
|
||||
sourceMapsUrl: join('assets/generated-source-maps/', filename),
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
generatedGraphs = [];
|
||||
}
|
||||
|
||||
let pregeneratedGraphs;
|
||||
try {
|
||||
pregeneratedGraphs = readdirSync(
|
||||
join(__dirname, '../graph/client/src/assets/project-graphs')
|
||||
).map((filename) => {
|
||||
const id = filename.substring(0, filename.length - 5);
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
projectGraphUrl: join('assets/project-graphs/', filename),
|
||||
taskGraphUrl: join('assets/task-graphs/', filename),
|
||||
taskInputsUrl: join('assets/task-inputs/', filename),
|
||||
sourceMapsUrl: join('assets/source-maps/', filename),
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
pregeneratedGraphs = [];
|
||||
}
|
||||
|
||||
// if no generated projects are found, generate one for nx and try this again
|
||||
if (generatedGraphs.length === 0) {
|
||||
execSync('nx run graph-client:generate-graph --directory ./ --name nx', {
|
||||
windowsHide: false,
|
||||
});
|
||||
writeFile();
|
||||
return;
|
||||
}
|
||||
|
||||
const projects = generatedGraphs.concat(pregeneratedGraphs);
|
||||
|
||||
ensureDirSync(join(__dirname, '../graph/client/src/assets/dev/'));
|
||||
|
||||
writeFileSync(
|
||||
join(__dirname, '../graph/client/src/assets/dev/', `environment.js`),
|
||||
generateFileContent(projects)
|
||||
);
|
||||
}
|
||||
|
||||
writeFile();
|
||||
@@ -0,0 +1,110 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import * as yargs from 'yargs';
|
||||
import { ensureDirSync } from 'fs-extra';
|
||||
|
||||
async function generateGraph(directory: string, name: string) {
|
||||
if (!existsSync(directory)) {
|
||||
console.error(`Could not find directory ${directory}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(
|
||||
'npx nx graph --file ./node_modules/.cache/nx-graph-gen/graph.html',
|
||||
{ cwd: directory, stdio: 'ignore', windowsHide: false }
|
||||
);
|
||||
} catch {
|
||||
console.error(`Could not run graph command in directory ${directory}`);
|
||||
return;
|
||||
}
|
||||
const environmentJs = readFileSync(
|
||||
join(directory, 'node_modules/.cache/nx-graph-gen/static/environment.js'),
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
const projectGraphResponse = environmentJs.match(
|
||||
/window.projectGraphResponse = (.*?);/
|
||||
);
|
||||
|
||||
const taskGraphResponse = environmentJs.match(
|
||||
/window.taskGraphResponse = (.*?);/
|
||||
);
|
||||
|
||||
const expandedTaskInputsReponse = environmentJs.match(
|
||||
/window.expandedTaskInputsResponse = (.*?);/
|
||||
);
|
||||
|
||||
const sourceMapsResponse = environmentJs.match(
|
||||
/window.sourceMapsResponse = (.*?);/
|
||||
);
|
||||
|
||||
ensureDirSync(
|
||||
join(__dirname, '../graph/client/src/assets/generated-project-graphs/')
|
||||
);
|
||||
ensureDirSync(
|
||||
join(__dirname, '../graph/client/src/assets/generated-task-graphs/')
|
||||
);
|
||||
ensureDirSync(
|
||||
join(__dirname, '../graph/client/src/assets/generated-task-inputs/')
|
||||
);
|
||||
ensureDirSync(
|
||||
join(__dirname, '../graph/client/src/assets/generated-source-maps/')
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(
|
||||
__dirname,
|
||||
'../graph/client/src/assets/generated-project-graphs/',
|
||||
`${name}.json`
|
||||
),
|
||||
projectGraphResponse[1]
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(
|
||||
__dirname,
|
||||
'../graph/client/src/assets/generated-task-graphs/',
|
||||
`${name}.json`
|
||||
),
|
||||
taskGraphResponse[1]
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(
|
||||
__dirname,
|
||||
'../graph/client/src/assets/generated-task-inputs/',
|
||||
`${name}.json`
|
||||
),
|
||||
expandedTaskInputsReponse[1]
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(
|
||||
__dirname,
|
||||
'../graph/client/src/assets/generated-source-maps/',
|
||||
`${name}.json`
|
||||
),
|
||||
sourceMapsResponse[1]
|
||||
);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const parsedArgs = yargs
|
||||
.scriptName('pnpm generate-graph')
|
||||
.strictOptions()
|
||||
.option('name', {
|
||||
type: 'string',
|
||||
requiresArg: true,
|
||||
description: 'The snake-case name of the file created',
|
||||
})
|
||||
.option('directory', {
|
||||
type: 'string',
|
||||
requiresArg: true,
|
||||
description: 'Directory of workspace',
|
||||
})
|
||||
.parseSync();
|
||||
|
||||
await generateGraph(parsedArgs.directory, parsedArgs.name);
|
||||
})();
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ReportData, TrendData } from './model';
|
||||
import { getSinceDate } from './scrape-issues';
|
||||
import { table } from 'markdown-factory';
|
||||
|
||||
export function getSlackMessageJson(body: string) {
|
||||
return {
|
||||
blocks: [
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
text: body,
|
||||
type: 'mrkdwn',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function formatGhReport(
|
||||
currentData: ReportData,
|
||||
trendData: TrendData,
|
||||
prevData: ReportData,
|
||||
unlabeledIssuesUrl: string
|
||||
): string {
|
||||
const formattedIssueDelta = formatDelta(trendData.totalIssueCount);
|
||||
const formattedBugDelta = formatDelta(trendData.totalBugCount);
|
||||
|
||||
const header = `Issue Report for ${currentData.collectedDate} <${unlabeledIssuesUrl}|[view unlabeled]>
|
||||
\`\`\`
|
||||
Totals, Issues: ${currentData.totalIssueCount} ${formattedIssueDelta} Bugs: ${currentData.totalBugCount} ${formattedBugDelta}\n\n`;
|
||||
|
||||
const prevDate = prevData.collectedDate
|
||||
? new Date(prevData.collectedDate)
|
||||
: undefined;
|
||||
const closedSinceDate = getSinceDate(prevDate)
|
||||
.toDateString()
|
||||
.split(' ')
|
||||
.slice(1)
|
||||
.join(' ');
|
||||
|
||||
const bodyLines: string[] = [
|
||||
...(prevData.collectedDate
|
||||
? [`Previous Report: ${prevData.collectedDate}`]
|
||||
: []),
|
||||
`Untriaged: ${currentData.untriagedIssueCount} ${formatDelta(
|
||||
trendData.untriagedIssueCount
|
||||
)}`,
|
||||
`Closed since ${closedSinceDate}: ${currentData.totalClosed} ${formatDelta(
|
||||
trendData.totalClosed
|
||||
)}`,
|
||||
];
|
||||
|
||||
const sorted = Object.entries(currentData.scopes)
|
||||
.sort(([, a], [, b]) => b.count - a.count)
|
||||
.map(([scope, x]) => ({
|
||||
...x,
|
||||
scope,
|
||||
}));
|
||||
|
||||
bodyLines.push(
|
||||
table(sorted, [
|
||||
{
|
||||
field: 'scope',
|
||||
label: 'Scope',
|
||||
},
|
||||
{
|
||||
label: 'Issues',
|
||||
mapFn: (el) =>
|
||||
`${el.count} ${formatDelta(trendData.scopes[el.scope].count)}`,
|
||||
},
|
||||
{
|
||||
label: 'Bugs',
|
||||
mapFn: (el) =>
|
||||
`${el.bugCount} ${formatDelta(trendData.scopes[el.scope].bugCount)}`,
|
||||
},
|
||||
{
|
||||
label: 'Closed',
|
||||
mapFn: (el) =>
|
||||
`${el.closed} ${formatDelta(trendData.scopes[el.scope].closed)}`,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const footer = '```';
|
||||
return header + bodyLines.join('\n') + footer;
|
||||
}
|
||||
|
||||
function formatDelta(delta: number | null): string {
|
||||
if (delta === null || delta === 0) {
|
||||
return '';
|
||||
}
|
||||
return delta < 0 ? `(${delta})` : `(+${delta})`;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { setOutput } from '@actions/core';
|
||||
import { ensureDirSync, readJsonSync, writeJsonSync } from 'fs-extra';
|
||||
import isCI from 'is-ci';
|
||||
import { dirname, join } from 'path';
|
||||
import { formatGhReport, getSlackMessageJson } from './format-slack-message';
|
||||
import { ReportData, ScopeData, TrendData } from './model';
|
||||
import { getScopeLabels, scrapeIssues } from './scrape-issues';
|
||||
|
||||
const CACHE_FILE = join(__dirname, 'cached', 'data.json');
|
||||
|
||||
async function main() {
|
||||
const oldData = getOldData();
|
||||
const currentData = await scrapeIssues(
|
||||
oldData.collectedDate ? new Date(oldData.collectedDate) : undefined
|
||||
);
|
||||
const trendData = getTrendData(currentData, oldData);
|
||||
const formatted = formatGhReport(
|
||||
currentData,
|
||||
trendData,
|
||||
oldData,
|
||||
getUnlabeledIssuesUrl(await getScopeLabels())
|
||||
);
|
||||
if (process.env.GITHUB_ACTIONS) {
|
||||
setOutput('SLACK_MESSAGE', getSlackMessageJson(formatted));
|
||||
}
|
||||
console.log(formatted.replace(/\<(.*)\|(.*)\>/g, '[$2]($1)'));
|
||||
saveCacheData(currentData);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
function getUnlabeledIssuesUrl(scopeLabels: string[]) {
|
||||
const labelFilters = scopeLabels.map((s) => `-label:"${s}"`);
|
||||
return `https://github.com/nrwl/nx/issues/?q=is%3Aopen+is%3Aissue+sort%3Aupdated-desc+${encodeURIComponent(
|
||||
labelFilters.join(' ')
|
||||
)}`;
|
||||
}
|
||||
|
||||
function getTrendData(newData: ReportData, oldData: ReportData): TrendData {
|
||||
const scopeTrends: Record<string, Partial<ScopeData>> = {};
|
||||
for (const [scope, data] of Object.entries(newData.scopes)) {
|
||||
scopeTrends[scope] ??= {};
|
||||
scopeTrends[scope].count = data.count - (oldData.scopes[scope]?.count ?? 0);
|
||||
scopeTrends[scope].bugCount =
|
||||
data.bugCount - (oldData.scopes[scope]?.bugCount ?? 0);
|
||||
scopeTrends[scope].closed =
|
||||
data.closed - (oldData.scopes[scope]?.closed ?? 0);
|
||||
}
|
||||
return {
|
||||
scopes: scopeTrends as Record<string, ScopeData>,
|
||||
totalBugCount: newData.totalBugCount - oldData.totalBugCount,
|
||||
totalIssueCount: newData.totalIssueCount - oldData.totalIssueCount,
|
||||
totalClosed: newData.totalClosed - oldData.totalClosed,
|
||||
untriagedIssueCount:
|
||||
newData.untriagedIssueCount - oldData.untriagedIssueCount,
|
||||
};
|
||||
}
|
||||
|
||||
function saveCacheData(report: ReportData) {
|
||||
if (isCI) {
|
||||
ensureDirSync(dirname(CACHE_FILE));
|
||||
writeJsonSync(CACHE_FILE, report);
|
||||
}
|
||||
}
|
||||
|
||||
function getOldData(): ReportData {
|
||||
try {
|
||||
return readJsonSync(CACHE_FILE);
|
||||
} catch (e) {
|
||||
return {
|
||||
scopes: {},
|
||||
totalBugCount: 0,
|
||||
totalIssueCount: 0,
|
||||
untriagedIssueCount: 0,
|
||||
totalClosed: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface ScopeData {
|
||||
bugCount: number;
|
||||
count: number;
|
||||
closed: number;
|
||||
}
|
||||
|
||||
export interface ReportData {
|
||||
scopes: Record<string, ScopeData>;
|
||||
totalBugCount: number;
|
||||
totalIssueCount: number;
|
||||
totalClosed: number;
|
||||
untriagedIssueCount: number;
|
||||
collectedDate?: string;
|
||||
}
|
||||
|
||||
export type TrendData = Omit<ReportData, 'collectedDate'>;
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Octokit } from 'octokit';
|
||||
import { ReportData, ScopeData } from './model';
|
||||
|
||||
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
||||
const now = new Date();
|
||||
|
||||
export async function scrapeIssues(prevDate?: Date): Promise<ReportData> {
|
||||
let total = 0;
|
||||
let totalBugs = 0;
|
||||
let untriagedIssueCount = 0;
|
||||
let totalClosed = 0;
|
||||
const scopeLabels = await getScopeLabels();
|
||||
const scopes: Record<string, ScopeData> = {};
|
||||
|
||||
for await (const { data: slice } of getOpenIssueIterator()) {
|
||||
for (const issue of slice.filter(isNotPullRequest)) {
|
||||
const bug = hasLabel(issue, 'type: bug');
|
||||
|
||||
if (bug) {
|
||||
totalBugs += 1;
|
||||
}
|
||||
total += 1;
|
||||
|
||||
let triaged = false;
|
||||
for (const scope of scopeLabels) {
|
||||
if (hasLabel(issue, scope)) {
|
||||
scopes[scope] ??= { bugCount: 0, count: 0, closed: 0 };
|
||||
if (bug) {
|
||||
scopes[scope].bugCount += 1;
|
||||
}
|
||||
scopes[scope].count += 1;
|
||||
triaged = true;
|
||||
}
|
||||
}
|
||||
if (!triaged) {
|
||||
untriagedIssueCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sinceDate = getSinceDate(prevDate);
|
||||
for await (const { data: slice } of getClosedIssueIterator(sinceDate)) {
|
||||
for (const issue of slice.filter(isNotPullRequest)) {
|
||||
totalClosed += 1;
|
||||
|
||||
for (const scope of scopeLabels) {
|
||||
if (hasLabel(issue, scope)) {
|
||||
scopes[scope] ??= { bugCount: 0, count: 0, closed: 0 };
|
||||
scopes[scope].closed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scopes: scopes,
|
||||
totalBugCount: totalBugs,
|
||||
totalIssueCount: total,
|
||||
totalClosed,
|
||||
untriagedIssueCount,
|
||||
// Format is like: Mar 03 2023
|
||||
collectedDate: new Date().toDateString().split(' ').slice(1).join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSinceDate(prevDate?: Date, referenceDate = now): Date {
|
||||
const firstOfPrevMonth = new Date(
|
||||
referenceDate.getFullYear(),
|
||||
referenceDate.getMonth() - 1,
|
||||
1
|
||||
);
|
||||
if (prevDate && prevDate > firstOfPrevMonth) {
|
||||
return prevDate;
|
||||
}
|
||||
return firstOfPrevMonth;
|
||||
}
|
||||
|
||||
const getOpenIssueIterator = () =>
|
||||
octokit.paginate.iterator('GET /repos/{owner}/{repo}/issues', {
|
||||
owner: 'nrwl',
|
||||
repo: 'nx',
|
||||
per_page: 100,
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
const getClosedIssueIterator = (since: Date) =>
|
||||
octokit.paginate.iterator('GET /repos/{owner}/{repo}/issues', {
|
||||
owner: 'nrwl',
|
||||
repo: 'nx',
|
||||
per_page: 100,
|
||||
state: 'closed',
|
||||
sort: 'updated',
|
||||
direction: 'desc',
|
||||
since: since.toISOString(),
|
||||
});
|
||||
|
||||
let labelCache: string[];
|
||||
export async function getScopeLabels(): Promise<string[]> {
|
||||
labelCache ??= await getAllLabels().then((labels) =>
|
||||
labels.filter((l) => l.startsWith('scope:'))
|
||||
);
|
||||
return labelCache;
|
||||
}
|
||||
|
||||
async function getAllLabels(): Promise<string[]> {
|
||||
const labels: string[] = [];
|
||||
|
||||
for await (const { data: slice } of octokit.paginate.iterator(
|
||||
'GET /repos/{owner}/{repo}/labels',
|
||||
{ owner: 'nrwl', repo: 'nx' }
|
||||
)) {
|
||||
labels.push(...slice.map((l) => l.name));
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
type IssueItem = Awaited<
|
||||
ReturnType<typeof octokit.rest.issues.listForRepo>
|
||||
>['data'][number];
|
||||
|
||||
function isNotPullRequest(issue: IssueItem): boolean {
|
||||
return !('pull_request' in issue) || issue.pull_request == null;
|
||||
}
|
||||
|
||||
function hasLabel(issue: IssueItem, labelName: string): boolean {
|
||||
return issue.labels.some(
|
||||
(l) => (typeof l === 'string' ? l : l.name) === labelName
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Require the real chalk module by resolving its actual path
|
||||
// to avoid circular reference from moduleNameMapper
|
||||
const path = require('path');
|
||||
const realChalkPath = require.resolve('chalk', {
|
||||
paths: [path.join(__dirname, '../../node_modules')],
|
||||
});
|
||||
const chalk = require(realChalkPath);
|
||||
|
||||
// chalk v4 is a Chalk instance where color methods (green, blue, etc.)
|
||||
// are getters on the prototype, not enumerable own properties.
|
||||
//
|
||||
// For `import * as chalk from 'chalk'` to work, we need a Proxy that
|
||||
// delegates all property access to the actual chalk instance.
|
||||
|
||||
const handler = {
|
||||
get(target, prop) {
|
||||
if (prop === '__esModule') return true;
|
||||
if (prop === 'default') return chalk;
|
||||
return chalk[prop];
|
||||
},
|
||||
set(target, prop, value) {
|
||||
chalk[prop] = value;
|
||||
return true;
|
||||
},
|
||||
has(target, prop) {
|
||||
return prop === '__esModule' || prop === 'default' || prop in chalk;
|
||||
},
|
||||
ownKeys() {
|
||||
return [...Object.keys(chalk), '__esModule', 'default'];
|
||||
},
|
||||
getOwnPropertyDescriptor(target, prop) {
|
||||
if (prop === '__esModule') {
|
||||
return { configurable: true, enumerable: true, value: true };
|
||||
}
|
||||
if (prop === 'default') {
|
||||
return { configurable: true, enumerable: true, value: chalk };
|
||||
}
|
||||
const desc = Object.getOwnPropertyDescriptor(chalk, prop);
|
||||
if (desc) return desc;
|
||||
// For prototype properties like 'green', create a descriptor
|
||||
if (prop in chalk) {
|
||||
return { configurable: true, enumerable: true, value: chalk[prop] };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = new Proxy({}, handler);
|
||||
@@ -0,0 +1,15 @@
|
||||
// Mock for ora - ora@9+ is ESM-only and breaks Jest in CommonJS mode
|
||||
const spinner = {};
|
||||
spinner.start =
|
||||
spinner.stop =
|
||||
spinner.succeed =
|
||||
spinner.fail =
|
||||
spinner.warn =
|
||||
spinner.info =
|
||||
spinner.clear =
|
||||
spinner.render =
|
||||
() => spinner;
|
||||
|
||||
module.exports = () => spinner;
|
||||
module.exports.default = module.exports;
|
||||
module.exports.promise = (action) => action;
|
||||
@@ -0,0 +1,65 @@
|
||||
// CJS wrapper for Prettier to avoid dynamic import issues in Jest's VM.
|
||||
// Prettier v3 has a CJS entry point but `await import('prettier')` in
|
||||
// format-files.ts fails without --experimental-vm-modules.
|
||||
// This wrapper loads the real module via require() so formatting actually runs.
|
||||
//
|
||||
// Prettier v3's index.cjs fires a top-level `import("./index.mjs")` which
|
||||
// always fails in Jest's VM context. On Node v24+ the resulting unhandled
|
||||
// rejection crashes the process. We collect any rejection promises that appear
|
||||
// while loading prettier and mark them handled via .catch().
|
||||
const path = require('path');
|
||||
const realPrettierPath = require.resolve('prettier', {
|
||||
paths: [path.join(__dirname, '../../node_modules')],
|
||||
});
|
||||
|
||||
// Install a short-lived listener that captures rejection promises created
|
||||
// by prettier's top-level import(). We .catch() them after loading to
|
||||
// prevent Node from treating them as unhandled.
|
||||
const rejections = new Set();
|
||||
const captureRejection = (_reason, promise) => {
|
||||
rejections.add(promise);
|
||||
};
|
||||
process.on('unhandledRejection', captureRejection);
|
||||
|
||||
const prettier = require(realPrettierPath);
|
||||
|
||||
// The import() rejection surfaces asynchronously. Use setTimeout(0) which
|
||||
// runs after both microtasks and the nextTick queue where Node fires
|
||||
// unhandledRejection events, then clean up.
|
||||
setTimeout(() => {
|
||||
process.removeListener('unhandledRejection', captureRejection);
|
||||
for (const p of rejections) {
|
||||
p.catch(() => {});
|
||||
}
|
||||
rejections.clear();
|
||||
}, 0);
|
||||
|
||||
const handler = {
|
||||
get(target, prop) {
|
||||
if (prop === '__esModule') return true;
|
||||
if (prop === 'default') return prettier;
|
||||
return prettier[prop];
|
||||
},
|
||||
has(target, prop) {
|
||||
return prop === '__esModule' || prop === 'default' || prop in prettier;
|
||||
},
|
||||
ownKeys() {
|
||||
return [...Object.keys(prettier), '__esModule', 'default'];
|
||||
},
|
||||
getOwnPropertyDescriptor(target, prop) {
|
||||
if (prop === '__esModule') {
|
||||
return { configurable: true, enumerable: true, value: true };
|
||||
}
|
||||
if (prop === 'default') {
|
||||
return { configurable: true, enumerable: true, value: prettier };
|
||||
}
|
||||
const desc = Object.getOwnPropertyDescriptor(prettier, prop);
|
||||
if (desc) return desc;
|
||||
if (prop in prettier) {
|
||||
return { configurable: true, enumerable: true, value: prettier[prop] };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = new Proxy({}, handler);
|
||||
@@ -0,0 +1,48 @@
|
||||
// Require the real yargs-parser module by resolving its actual path
|
||||
// to avoid circular reference from moduleNameMapper
|
||||
const path = require('path');
|
||||
const realYargsParserPath = require.resolve('yargs-parser', {
|
||||
paths: [path.join(__dirname, '../../node_modules')],
|
||||
});
|
||||
const yargsParser = require(realYargsParserPath);
|
||||
|
||||
// yargs-parser exports a function directly.
|
||||
// We need to handle both import styles:
|
||||
// - import * as yargs from 'yargs-parser' -> yargs(command, options)
|
||||
// - import yargs from 'yargs-parser' -> yargs(command, options)
|
||||
//
|
||||
// Use a Proxy to delegate all property access to the real yargsParser function.
|
||||
|
||||
const handler = {
|
||||
get(target, prop) {
|
||||
if (prop === '__esModule') return true;
|
||||
if (prop === 'default') return yargsParser;
|
||||
return yargsParser[prop];
|
||||
},
|
||||
apply(target, thisArg, args) {
|
||||
return yargsParser.apply(thisArg, args);
|
||||
},
|
||||
has(target, prop) {
|
||||
return prop === '__esModule' || prop === 'default' || prop in yargsParser;
|
||||
},
|
||||
ownKeys() {
|
||||
return [...Object.keys(yargsParser), '__esModule', 'default'];
|
||||
},
|
||||
getOwnPropertyDescriptor(target, prop) {
|
||||
if (prop === '__esModule') {
|
||||
return { configurable: true, enumerable: true, value: true };
|
||||
}
|
||||
if (prop === 'default') {
|
||||
return { configurable: true, enumerable: true, value: yargsParser };
|
||||
}
|
||||
const desc = Object.getOwnPropertyDescriptor(yargsParser, prop);
|
||||
if (desc) return desc;
|
||||
if (prop in yargsParser) {
|
||||
return { configurable: true, enumerable: true, value: yargsParser[prop] };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
// Use a function as the proxy target so 'apply' trap works
|
||||
module.exports = new Proxy(function () {}, handler);
|
||||
@@ -0,0 +1,58 @@
|
||||
import { join } from 'path';
|
||||
import { lines, h1, h2, unorderedList } from 'markdown-factory';
|
||||
import { writeFileSync } from 'fs';
|
||||
|
||||
const checker: typeof import('license-checker') = require('license-checker');
|
||||
|
||||
const map: Record<string, string> = {
|
||||
'The Apache License, Version 2.0': 'Apache-2.0',
|
||||
'Apache License, Version 2.0': 'Apache-2.0',
|
||||
'The Apache Software License, Version 2.0': 'Apache-2.0',
|
||||
'Apache Software License - Version 2.0': 'Apache-2.0',
|
||||
'Eclipse Public License - Version 1.0': 'Eclipse Public License-1.0',
|
||||
'Eclipse Public License': 'Eclipse Public License-2.0',
|
||||
'Eclipse Public License v. 2.0': 'Eclipse Public License-2.0',
|
||||
'Eclipse Public License - v 1.0': 'Eclipse Public License-1.0',
|
||||
'MIT License': 'MIT',
|
||||
'The MIT License (MIT)': 'MIT',
|
||||
'New BSD License': 'New BSD License',
|
||||
'CDDL/GPLv2+CE': 'CDDL-1.0',
|
||||
'COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0': 'CDDL-1.0',
|
||||
'CDDL 1.1': 'CDDL-1.1',
|
||||
'MIT*': 'MIT',
|
||||
CC0: 'CC0',
|
||||
};
|
||||
|
||||
checker.init(
|
||||
{
|
||||
start: join(__dirname, '..'),
|
||||
},
|
||||
(err, modules) => {
|
||||
const packagesByLicense: Record<string, string[]> = {};
|
||||
Object.entries(modules).forEach(([name, info]) => {
|
||||
const licenseKey =
|
||||
(Array.isArray(info.licenses) ? info.licenses[0] : info.licenses) ??
|
||||
'unknown';
|
||||
const license = map[licenseKey] ?? licenseKey;
|
||||
packagesByLicense[license] ??= [];
|
||||
packagesByLicense[license].push(name);
|
||||
});
|
||||
const md = Object.entries(packagesByLicense).reduce(
|
||||
(txt, [license, packages]) =>
|
||||
lines(
|
||||
txt,
|
||||
h2(
|
||||
license,
|
||||
unorderedList(
|
||||
packages.map((p) => `[${p}](https://www.npmjs.com/${p})`)
|
||||
)
|
||||
)
|
||||
),
|
||||
h1(
|
||||
'License report',
|
||||
'This report contains the licenses for all dependencies used by any project for the Nx monorepo.'
|
||||
)
|
||||
);
|
||||
writeFileSync(join(__dirname, '../dist/license-report.md'), md);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,79 @@
|
||||
// @ts-check
|
||||
const { exec, execSync } = require('node:child_process');
|
||||
const {
|
||||
LARGE_BUFFER,
|
||||
} = require('nx/src/executors/run-commands/run-commands.impl');
|
||||
|
||||
async function populateLocalRegistryStorage() {
|
||||
const listenAddress = 'localhost';
|
||||
const port = process.env.NX_LOCAL_REGISTRY_PORT ?? '4873';
|
||||
const registry = `http://${listenAddress}:${port}`;
|
||||
const authToken = 'secretVerdaccioToken';
|
||||
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
try {
|
||||
await assertLocalRegistryIsRunning(registry);
|
||||
break;
|
||||
} catch {
|
||||
console.log(`Waiting for Local registry to start on ${registry}...`);
|
||||
}
|
||||
}
|
||||
|
||||
process.env.npm_config_registry = registry;
|
||||
|
||||
// bun
|
||||
process.env.BUN_CONFIG_REGISTRY = registry;
|
||||
process.env.BUN_CONFIG_TOKEN = authToken;
|
||||
// yarnv1
|
||||
process.env.YARN_REGISTRY = registry;
|
||||
// yarnv2
|
||||
process.env.YARN_NPM_REGISTRY_SERVER = registry;
|
||||
process.env.YARN_UNSAFE_HTTP_WHITELIST = listenAddress;
|
||||
|
||||
try {
|
||||
const publishVersion = process.env.PUBLISHED_VERSION ?? 'major';
|
||||
const isVerbose = process.env.NX_VERBOSE_LOGGING === 'true';
|
||||
|
||||
console.log('Publishing packages to local registry to populate storage');
|
||||
await runLocalRelease(publishVersion, isVerbose);
|
||||
} catch (err) {
|
||||
console.error('Error:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
exports.populateLocalRegistryStorage = populateLocalRegistryStorage;
|
||||
|
||||
function runLocalRelease(publishVersion, isVerbose) {
|
||||
return new Promise((res, rej) => {
|
||||
const publishProcess = exec(`pnpm nx-release --local ${publishVersion}`, {
|
||||
env: process.env,
|
||||
maxBuffer: LARGE_BUFFER,
|
||||
});
|
||||
let logs = Buffer.from('');
|
||||
if (isVerbose) {
|
||||
publishProcess?.stdout?.pipe(process.stdout);
|
||||
publishProcess?.stderr?.pipe(process.stderr);
|
||||
} else {
|
||||
publishProcess?.stdout?.on('data', (data) => (logs += data));
|
||||
publishProcess?.stderr?.on('data', (data) => (logs += data));
|
||||
}
|
||||
publishProcess.on('exit', (code) => {
|
||||
if (code && code > 0) {
|
||||
if (!isVerbose) {
|
||||
console.log(logs.toString());
|
||||
}
|
||||
rej(code);
|
||||
}
|
||||
res(undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
exports.runLocalRelease = runLocalRelease;
|
||||
|
||||
async function assertLocalRegistryIsRunning(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// @ts-check
|
||||
|
||||
import { populateLocalRegistryStorage } from './populate-storage.js';
|
||||
|
||||
/**
|
||||
* This script is primarily intended to run as part of e2e-ci,
|
||||
* so we want to capture the full logs of the local release.
|
||||
*/
|
||||
process.env.NX_VERBOSE_LOGGING = 'true';
|
||||
|
||||
await populateLocalRegistryStorage();
|
||||
@@ -0,0 +1,127 @@
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
||||
let desiredMajorVersion = process.argv[2];
|
||||
|
||||
// Fallback to package.json version if no argument provided
|
||||
if (!desiredMajorVersion) {
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||
const packageManager = packageJson.packageManager;
|
||||
|
||||
if (packageManager && packageManager.startsWith('pnpm@')) {
|
||||
const version = packageManager.replace('pnpm@', '');
|
||||
desiredMajorVersion = version.split('.')[0];
|
||||
console.log(
|
||||
`📖 Using version from package.json: pnpm ${desiredMajorVersion}`
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
'❌ No version provided and no pnpm version found in package.json'
|
||||
);
|
||||
console.error('❌ Usage: pnpm migrate-to-pnpm-version <major-version>');
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to read package.json:', error.message);
|
||||
console.error('❌ Usage: pnpm migrate-to-pnpm-version <major-version>');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🚀 Starting migration to pnpm ${desiredMajorVersion}...`);
|
||||
|
||||
let currentVersion;
|
||||
let needsPnpmInstall = true;
|
||||
|
||||
try {
|
||||
currentVersion = execSync('pnpm --version', { encoding: 'utf8' }).trim();
|
||||
const currentMajorVersion = currentVersion.split('.')[0];
|
||||
|
||||
if (currentMajorVersion === desiredMajorVersion) {
|
||||
console.log(
|
||||
`✅ Already using pnpm ${desiredMajorVersion}.${currentVersion
|
||||
.split('.')
|
||||
.slice(1)
|
||||
.join('.')}`
|
||||
);
|
||||
needsPnpmInstall = false;
|
||||
|
||||
if (fs.existsSync('node_modules') && fs.existsSync('pnpm-lock.yaml')) {
|
||||
try {
|
||||
execSync('pnpm list --depth=0', { stdio: 'pipe' });
|
||||
console.log('📦 Packages already installed!');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.log('🔄 Dependencies need reinstalling...');
|
||||
}
|
||||
} else {
|
||||
console.log('📥 No dependencies installed. Installing...');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('⚠️ pnpm not found, installing...');
|
||||
}
|
||||
|
||||
// Install pnpm if needed
|
||||
if (needsPnpmInstall) {
|
||||
try {
|
||||
execSync(`npm install -g pnpm@${desiredMajorVersion}`, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
const installedVersion = execSync('pnpm --version', {
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
console.log(`✅ Installed pnpm@${installedVersion}`);
|
||||
|
||||
// Update packageManager in package.json
|
||||
const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||
packageJson.packageManager = `pnpm@${installedVersion}`;
|
||||
fs.writeFileSync(
|
||||
'package.json',
|
||||
JSON.stringify(packageJson, null, 2) + '\n'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to install pnpm:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Backup and install packages
|
||||
if (fs.existsSync('pnpm-lock.yaml')) {
|
||||
fs.copyFileSync('pnpm-lock.yaml', 'pnpm-lock.yaml.backup');
|
||||
console.log('💾 Backup created');
|
||||
}
|
||||
|
||||
if (needsPnpmInstall) {
|
||||
try {
|
||||
if (fs.existsSync('node_modules')) {
|
||||
execSync('rm -rf node_modules', { stdio: 'inherit' });
|
||||
}
|
||||
execSync('pnpm store prune', { stdio: 'pipe' });
|
||||
} catch (error) {
|
||||
// Store prune can fail, but we can continue
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📦 Installing dependencies...');
|
||||
try {
|
||||
execSync('pnpm install', { stdio: 'inherit' });
|
||||
|
||||
// Verify
|
||||
execSync('pnpm build --help', { stdio: 'pipe' });
|
||||
execSync('pnpm nx --version', { stdio: 'pipe' });
|
||||
|
||||
console.log('🎉 Migration completed successfully!');
|
||||
if (fs.existsSync('pnpm-lock.yaml.backup')) {
|
||||
console.log('🗑️ Removing backup file...');
|
||||
fs.unlinkSync('pnpm-lock.yaml.backup');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Installation failed:', error.message);
|
||||
if (fs.existsSync('pnpm-lock.yaml.backup')) {
|
||||
fs.copyFileSync('pnpm-lock.yaml.backup', 'pnpm-lock.yaml');
|
||||
console.log('🔄 Backup restored');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
if (process.argv.slice(2).some((arg) => arg.includes('pnpm-lock.yaml'))) {
|
||||
console.warn(
|
||||
[
|
||||
'⚠️ ----------------------------------------------------------------------------------------- ⚠️',
|
||||
'⚠️ pnpm-lock.yaml changed, please run `pnpm install` to ensure your packages are up to date. ⚠️',
|
||||
'⚠️ ----------------------------------------------------------------------------------------- ⚠️',
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
#!/usr/bin/env node
|
||||
import { createProjectGraphAsync, workspaceRoot } from '@nx/devkit';
|
||||
import { styleText } from 'node:util';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { rmSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { URL } from 'node:url';
|
||||
import { isRelativeVersionKeyword } from 'nx/src/command-line/release/utils/semver';
|
||||
import { ReleaseType, major, parse } from 'semver';
|
||||
import yargs from 'yargs';
|
||||
|
||||
const LARGE_BUFFER = 1024 * 1000000;
|
||||
|
||||
// DO NOT MODIFY, even for testing. This only gates releases to latest.
|
||||
const VALID_AUTHORS_FOR_LATEST = [
|
||||
'jaysoo',
|
||||
'JamesHenry',
|
||||
'FrozenPandaz',
|
||||
'vsavkin',
|
||||
];
|
||||
|
||||
(async () => {
|
||||
const options = parseArgs();
|
||||
// Perform minimal logging by default
|
||||
let isVerboseLogging = process.env.NX_VERBOSE_LOGGING === 'true';
|
||||
|
||||
if (options.clearLocalRegistry) {
|
||||
rmSync(join(__dirname, '../dist/local-registry/storage'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure all the native-packages directories are available at the top level of the build directory, enabling consistent packageRoot structure
|
||||
execSync(`pnpm nx copy-native-package-directories nx`, {
|
||||
stdio: isVerboseLogging ? [0, 1, 2] : 'ignore',
|
||||
maxBuffer: LARGE_BUFFER,
|
||||
windowsHide: false,
|
||||
});
|
||||
|
||||
// Expected to run as part of the Github `publish` workflow
|
||||
if (!options.local && process.env.GITHUB_ACTIONS) {
|
||||
// Delete all .node files that were built during the previous steps
|
||||
// Always run before the artifacts step because we still need the .node files for native-packages
|
||||
execSync('find ./dist ./packages/nx/dist -name "*.node" -delete', {
|
||||
stdio: [0, 1, 2],
|
||||
maxBuffer: LARGE_BUFFER,
|
||||
windowsHide: false,
|
||||
});
|
||||
|
||||
execSync('pnpm nx run-many --target=artifacts', {
|
||||
stdio: [0, 1, 2],
|
||||
maxBuffer: LARGE_BUFFER,
|
||||
windowsHide: false,
|
||||
});
|
||||
}
|
||||
|
||||
const runNxReleaseVersion = () => {
|
||||
let versionCommand = `pnpm nx release version${
|
||||
options.version ? ` --specifier ${options.version}` : ''
|
||||
}`;
|
||||
if (options.dryRun) {
|
||||
versionCommand += ' --dry-run';
|
||||
}
|
||||
if (isVerboseLogging) {
|
||||
versionCommand += ' --verbose';
|
||||
}
|
||||
console.log(`> ${versionCommand}`);
|
||||
execSync(versionCommand, {
|
||||
stdio: isVerboseLogging ? [0, 1, 2] : 'ignore',
|
||||
maxBuffer: LARGE_BUFFER,
|
||||
windowsHide: false,
|
||||
});
|
||||
};
|
||||
|
||||
const packagesToReset = [
|
||||
'packages/angular',
|
||||
'packages/angular-rspack',
|
||||
'packages/angular-rspack-compiler',
|
||||
'packages/create-nx-plugin',
|
||||
'packages/create-nx-workspace',
|
||||
'packages/devkit',
|
||||
'packages/detox',
|
||||
'packages/dotnet',
|
||||
'packages/cypress',
|
||||
'packages/docker',
|
||||
'packages/esbuild',
|
||||
'packages/eslint',
|
||||
'packages/eslint-plugin',
|
||||
'packages/expo',
|
||||
'packages/express',
|
||||
'packages/gradle',
|
||||
'packages/jest',
|
||||
'packages/js',
|
||||
'packages/maven',
|
||||
'packages/module-federation',
|
||||
'packages/nest',
|
||||
'packages/next',
|
||||
'packages/node',
|
||||
'packages/nuxt',
|
||||
'packages/nx',
|
||||
'packages/playwright',
|
||||
'packages/plugin',
|
||||
'packages/react',
|
||||
'packages/react-native',
|
||||
'packages/remix',
|
||||
'packages/rollup',
|
||||
'packages/rsbuild',
|
||||
'packages/rspack',
|
||||
'packages/storybook',
|
||||
'packages/vite',
|
||||
'packages/vitest',
|
||||
'packages/vue',
|
||||
'packages/web',
|
||||
'packages/webpack',
|
||||
'packages/workspace',
|
||||
];
|
||||
|
||||
const packageSnapshots: { [key: string]: string } = {};
|
||||
for (const packagePath of packagesToReset) {
|
||||
const packageJsonPath = join(workspaceRoot, packagePath, 'package.json');
|
||||
const packageJson = readFileSync(packageJsonPath, 'utf-8');
|
||||
packageSnapshots[packagePath] = packageJson;
|
||||
}
|
||||
|
||||
const resetPackageJsons = () => {
|
||||
for (const packagePath of packagesToReset) {
|
||||
const packageJsonPath = join(workspaceRoot, packagePath, 'package.json');
|
||||
writeFileSync(packageJsonPath, packageSnapshots[packagePath]);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset package.json files even on ctrl+C
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\nReceived SIGINT, restoring package.json files...');
|
||||
resetPackageJsons();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Intended for creating a github release which triggers the publishing workflow
|
||||
// This runs locally (not in CI) to create the GitHub release that triggers the actual publish workflow
|
||||
if (!options.local && !process.env.GITHUB_ACTIONS) {
|
||||
// For this important use-case it makes sense to always have full logs
|
||||
isVerboseLogging = true;
|
||||
|
||||
execSync('git status --ahead-behind', {
|
||||
windowsHide: false,
|
||||
});
|
||||
|
||||
if (isRelativeVersionKeyword(options.version)) {
|
||||
resetPackageJsons();
|
||||
throw new Error(
|
||||
'When creating actual releases, you must use an exact semver version'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
runNxReleaseVersion();
|
||||
|
||||
execSync(`pnpm nx run-many -t add-extra-dependencies --parallel 8`, {
|
||||
stdio: isVerboseLogging ? [0, 1, 2] : 'ignore',
|
||||
maxBuffer: LARGE_BUFFER,
|
||||
windowsHide: false,
|
||||
});
|
||||
|
||||
execSync(`pnpm nx run nx:expand-deps`, {
|
||||
stdio: isVerboseLogging ? [0, 1, 2] : 'ignore',
|
||||
maxBuffer: LARGE_BUFFER,
|
||||
windowsHide: false,
|
||||
});
|
||||
|
||||
let changelogCommand = `pnpm nx release changelog ${options.version} --interactive workspace`;
|
||||
if (options.from) {
|
||||
changelogCommand += ` --from ${options.from}`;
|
||||
}
|
||||
if (options.gitRemote) {
|
||||
changelogCommand += ` --git-remote ${options.gitRemote}`;
|
||||
}
|
||||
if (options.dryRun) {
|
||||
changelogCommand += ' --dry-run';
|
||||
}
|
||||
if (isVerboseLogging) {
|
||||
changelogCommand += ' --verbose';
|
||||
}
|
||||
console.log(`> ${changelogCommand}`);
|
||||
execSync(changelogCommand, {
|
||||
stdio: isVerboseLogging ? [0, 1, 2] : 'ignore',
|
||||
maxBuffer: LARGE_BUFFER,
|
||||
windowsHide: false,
|
||||
});
|
||||
|
||||
console.log(
|
||||
'Check github: https://github.com/nrwl/nx/actions/workflows/publish.yml'
|
||||
);
|
||||
} finally {
|
||||
resetPackageJsons();
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
runNxReleaseVersion();
|
||||
|
||||
execSync(`pnpm nx run-many -t add-extra-dependencies --parallel 8`, {
|
||||
stdio: isVerboseLogging ? [0, 1, 2] : 'ignore',
|
||||
maxBuffer: LARGE_BUFFER,
|
||||
windowsHide: false,
|
||||
});
|
||||
|
||||
execSync(`pnpm nx run nx:expand-deps`, {
|
||||
stdio: isVerboseLogging ? [0, 1, 2] : 'ignore',
|
||||
maxBuffer: LARGE_BUFFER,
|
||||
windowsHide: false,
|
||||
});
|
||||
|
||||
const distTag = determineDistTag(options.version);
|
||||
|
||||
// If publishing locally, force all projects to not be private first
|
||||
if (options.local) {
|
||||
console.log(
|
||||
styleText(
|
||||
'dim',
|
||||
`\n Publishing locally, so setting all packages with existing nx-release-publish targets to not be private. If you have created a new private package and you want it to be published, you will need to manually configure the "nx-release-publish" target using executor "@nx/js:release-publish"`
|
||||
)
|
||||
);
|
||||
const projectGraph = await createProjectGraphAsync();
|
||||
for (const proj of Object.values(projectGraph.nodes)) {
|
||||
if (proj.data.targets?.['nx-release-publish']) {
|
||||
const packageJsonPath = join(
|
||||
workspaceRoot,
|
||||
// Mirror the @nx/js:release-publish executor default: when packageRoot
|
||||
// is not set explicitly, it falls back to the project root.
|
||||
proj.data.targets?.['nx-release-publish']?.options?.packageRoot ??
|
||||
proj.data.root,
|
||||
'package.json'
|
||||
);
|
||||
try {
|
||||
const packageJson = require(packageJsonPath);
|
||||
if (packageJson.private) {
|
||||
console.log(
|
||||
'- Publishing private package locally:',
|
||||
packageJson.name
|
||||
);
|
||||
writeFileSync(
|
||||
packageJsonPath,
|
||||
JSON.stringify({ ...packageJson, private: false })
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.local && (!distTag || distTag === 'latest')) {
|
||||
// We are only expecting non-local latest releases to be performed within publish.yml on GitHub
|
||||
const author = process.env.GITHUB_ACTOR ?? '';
|
||||
if (!VALID_AUTHORS_FOR_LATEST.includes(author)) {
|
||||
throw new Error(
|
||||
`The GitHub user "${author}" is not allowed to publish to "latest". Please request one of the following users to carry out the release: ${VALID_AUTHORS_FOR_LATEST.join(
|
||||
', '
|
||||
)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up tsconfig files before publishing
|
||||
console.log('Cleaning up tsconfig files...');
|
||||
execSync('node ./scripts/cleanup-tsconfig-files.js', {
|
||||
stdio: isVerboseLogging ? [0, 1, 2] : 'ignore',
|
||||
maxBuffer: LARGE_BUFFER,
|
||||
windowsHide: false,
|
||||
});
|
||||
|
||||
hackFixForDevkitPeerDependencies();
|
||||
|
||||
// Run with dynamic output-style so that we have more minimal logs by default but still always see errors
|
||||
let publishCommand = `pnpm nx release publish --registry=${getRegistry()} --tag=${distTag} --output-style=dynamic --parallel=8`;
|
||||
if (options.dryRun) {
|
||||
publishCommand += ' --dry-run';
|
||||
}
|
||||
console.log(`\n> ${publishCommand}`);
|
||||
execSync(publishCommand, {
|
||||
stdio: [0, 1, 2],
|
||||
maxBuffer: LARGE_BUFFER,
|
||||
windowsHide: false,
|
||||
});
|
||||
|
||||
if (!options.dryRun) {
|
||||
let version;
|
||||
if (['minor', 'major', 'patch'].includes(options.version)) {
|
||||
version = execSync(`npm view nx@${distTag} version`, {
|
||||
windowsHide: false,
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
} else {
|
||||
version = options.version;
|
||||
}
|
||||
|
||||
console.log(styleText('green', ` > Published version: ` + version));
|
||||
console.log(
|
||||
styleText('dim', ` Use: npx create-nx-workspace@${version}\n`)
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
resetPackageJsons();
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
})();
|
||||
|
||||
function parseArgs() {
|
||||
const registry = getRegistry();
|
||||
const registryIsLocalhost = registry.hostname === 'localhost';
|
||||
|
||||
const parsedArgs = yargs
|
||||
.scriptName('pnpm nx-release')
|
||||
.wrap(144)
|
||||
.strictOptions()
|
||||
.version(false)
|
||||
.command(
|
||||
'$0 [version]',
|
||||
'This script is for publishing Nx both locally and publically'
|
||||
)
|
||||
.option('dryRun', {
|
||||
type: 'boolean',
|
||||
description: 'Dry-run flag to be passed to all `nx release` commands',
|
||||
})
|
||||
.option('clearLocalRegistry', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Clear existing versions in the local registry so that you can republish the same version',
|
||||
default: true,
|
||||
})
|
||||
.option('local', {
|
||||
type: 'boolean',
|
||||
description: 'Publish Nx locally, not to actual NPM',
|
||||
alias: 'l',
|
||||
default: true,
|
||||
})
|
||||
.option('force', {
|
||||
type: 'boolean',
|
||||
description: "Don't use this unless you really know what it does",
|
||||
hidden: true,
|
||||
})
|
||||
.option('from', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Git ref to pass to `nx release changelog`. Not applicable for local publishing or e2e tests.',
|
||||
})
|
||||
.positional('version', {
|
||||
type: 'string',
|
||||
description:
|
||||
'The version to publish. This does not need to be passed and can be inferred.',
|
||||
default: 'minor',
|
||||
coerce: (version: string) => {
|
||||
const isGithubActions = !!process.env.GITHUB_ACTIONS;
|
||||
if (
|
||||
isGithubActions &&
|
||||
!registryIsLocalhost &&
|
||||
isRelativeVersionKeyword(version)
|
||||
) {
|
||||
// Print error rather than throw to avoid yargs noise in this specifically handled case
|
||||
console.error(
|
||||
'Error: The release script was triggered in a GitHub Actions workflow, to a non-local registry, but a relative version keyword was provided. This is an unexpected combination.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (version !== 'canary') {
|
||||
return version;
|
||||
}
|
||||
/**
|
||||
* Handle the special case of `canary`
|
||||
*
|
||||
* Use the base version from nx@next so that canary versions
|
||||
* are always aligned with the current next/beta release line.
|
||||
*/
|
||||
|
||||
const currentNextVersion = execSync('npm view nx@next version', {
|
||||
windowsHide: false,
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
const parsedNext = parse(currentNextVersion);
|
||||
if (!parsedNext) {
|
||||
throw new Error(
|
||||
`Unable to parse the current next version from the npm registry: "${currentNextVersion}"`
|
||||
);
|
||||
}
|
||||
|
||||
const canaryBaseVersion = `${parsedNext.major}.${parsedNext.minor}.${parsedNext.patch}`;
|
||||
|
||||
// Create YYYYMMDD string
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0'); // Months are 0-indexed
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const YYYYMMDD = `${year}${month}${day}`;
|
||||
|
||||
// Get the current short git sha
|
||||
const gitSha = execSync('git rev-parse --short HEAD', {
|
||||
windowsHide: false,
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
const canaryVersion = `${canaryBaseVersion}-canary.${YYYYMMDD}-${gitSha}`;
|
||||
|
||||
console.log(`\nDerived canary version dynamically`, {
|
||||
currentNextVersion,
|
||||
canaryVersion,
|
||||
});
|
||||
|
||||
return canaryVersion;
|
||||
},
|
||||
})
|
||||
.option('gitRemote', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Alternate git remote name to publish tags to (useful for testing changelog)',
|
||||
default: 'origin',
|
||||
})
|
||||
.example(
|
||||
'$0',
|
||||
`By default, this will locally publish a minor version bump as latest. Great for local development. Most developers should only need this.`
|
||||
)
|
||||
.example(
|
||||
'$0 --local false 2.3.4-beta.0',
|
||||
`This will really publish a new version to npm as next.`
|
||||
)
|
||||
.example(
|
||||
'$0 --local false 2.3.4',
|
||||
`Given the current latest major version on npm is 2, this will really publish a new version to npm as latest.`
|
||||
)
|
||||
.example(
|
||||
'$0 --local false 1.3.4-beta.0',
|
||||
`Given the current latest major version on npm is 2, this will really publish a new version to npm as previous.`
|
||||
)
|
||||
.group(
|
||||
['local', 'clearLocalRegistry'],
|
||||
'Local Publishing Options for most developers'
|
||||
)
|
||||
.group(
|
||||
['gitRemote', 'force'],
|
||||
'Real Publishing Options for actually publishing to NPM'
|
||||
)
|
||||
.demandOption('version')
|
||||
.check((args) => {
|
||||
if (!args.local) {
|
||||
if (!process.env.GH_TOKEN) {
|
||||
throw new Error('process.env.GH_TOKEN is not set');
|
||||
}
|
||||
if (!args.force && registryIsLocalhost) {
|
||||
throw new Error(
|
||||
'Registry is still set to localhost! Run "pnpm local-registry disable" or pass --force'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!args.force && !registryIsLocalhost) {
|
||||
throw new Error('--local was passed and registry is not localhost');
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.parseSync();
|
||||
|
||||
return parsedArgs;
|
||||
}
|
||||
|
||||
function getRegistry() {
|
||||
return new URL(
|
||||
execSync('npm config get registry', {
|
||||
windowsHide: false,
|
||||
})
|
||||
.toString()
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
function determineDistTag(
|
||||
newVersion: string
|
||||
): 'latest' | 'next' | 'previous' | 'canary' | 'pull-request' {
|
||||
// Special case of canary
|
||||
if (newVersion.includes('canary')) {
|
||||
return 'canary';
|
||||
}
|
||||
|
||||
// Special case of PR release (e.g. 22.5.0-pr.1234.abc1234)
|
||||
if (/^\d+\.\d+\.\d+-pr\./.test(newVersion)) {
|
||||
return 'pull-request';
|
||||
}
|
||||
|
||||
// For a relative version keyword, it cannot be previous
|
||||
if (isRelativeVersionKeyword(newVersion)) {
|
||||
const prereleaseKeywords: ReleaseType[] = [
|
||||
'premajor',
|
||||
'preminor',
|
||||
'prepatch',
|
||||
'prerelease',
|
||||
];
|
||||
return prereleaseKeywords.includes(newVersion) ? 'next' : 'latest';
|
||||
}
|
||||
|
||||
const parsedGivenVersion = parse(newVersion);
|
||||
if (!parsedGivenVersion) {
|
||||
throw new Error(
|
||||
`Unable to parse the given version: "${newVersion}". Is it valid semver?`
|
||||
);
|
||||
}
|
||||
|
||||
const currentLatestVersion = execSync('npm view nx version', {
|
||||
windowsHide: false,
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
const parsedCurrentLatestVersion = parse(currentLatestVersion);
|
||||
if (!parsedCurrentLatestVersion) {
|
||||
throw new Error(
|
||||
`The current version resolved from the npm registry could not be parsed (resolved "${currentLatestVersion}")`
|
||||
);
|
||||
}
|
||||
|
||||
const distTag =
|
||||
parsedGivenVersion.prerelease.length > 0
|
||||
? 'next'
|
||||
: parsedGivenVersion.major < parsedCurrentLatestVersion.major
|
||||
? 'previous'
|
||||
: 'latest';
|
||||
|
||||
return distTag;
|
||||
}
|
||||
|
||||
//TODO(@Coly010): Remove this after fixing up the release peer dep handling
|
||||
function hackFixForDevkitPeerDependencies() {
|
||||
const { readFileSync, writeFileSync } = require('fs');
|
||||
const devkitPackageJson = JSON.parse(
|
||||
readFileSync('./packages/devkit/package.json', 'utf-8')
|
||||
);
|
||||
|
||||
const beforeVersion = devkitPackageJson.peerDependencies['nx'];
|
||||
const majorVersion = major(beforeVersion);
|
||||
if (!beforeVersion.includes('<') && majorVersion !== 0) {
|
||||
console.log(
|
||||
'@nx/devkit peer dependencies range is broken - needs release fix. Patching it to avoid broken publishes.'
|
||||
);
|
||||
devkitPackageJson.peerDependencies['nx'] = `>= ${majorVersion - 1} <= ${
|
||||
majorVersion + 1
|
||||
} || ^${majorVersion}.0.0-0`;
|
||||
writeFileSync(
|
||||
'./packages/devkit/package.json',
|
||||
JSON.stringify(devkitPackageJson, null, 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
'use strict';
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Memoize results of `existsSync(...) && lstatSync(...).isFile()` for
|
||||
// resolution probes inside `packages/*`. Each spec file with
|
||||
// `jest.mock('@nx/devkit', ...)` (or any cross-package require) makes Jest
|
||||
// re-run resolution from scratch, which calls `existsSync` and `lstatSync`
|
||||
// on the same workspace files repeatedly. Those probes show up in the
|
||||
// sandbox report as "unexpected reads" even though they don't read any
|
||||
// file content. The map is process-wide and bounded by the set of
|
||||
// workspace package entry points, so memory is trivial.
|
||||
const isFileCache = new Map();
|
||||
const isWorkspaceFile = (p) => {
|
||||
if (isFileCache.has(p)) return isFileCache.get(p);
|
||||
let result = false;
|
||||
try {
|
||||
result = fs.existsSync(p) && fs.lstatSync(p).isFile();
|
||||
} catch (_) {}
|
||||
isFileCache.set(p, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom resolver which will respect package exports (until Jest supports it natively
|
||||
* by resolving https://github.com/facebook/jest/issues/9771)
|
||||
*
|
||||
* Also needed for TypeScript project references support:
|
||||
* - ts-jest doesn't support TypeScript project references (https://github.com/kulshekhar/ts-jest/issues/1648)
|
||||
* - When using TS project references, Jest needs to resolve imports like '@nx/devkit' to TypeScript source files
|
||||
* - Without this resolver, Jest will fail to resolve these imports correctly
|
||||
*/
|
||||
const enhancedResolver = require('enhanced-resolve').create.sync({
|
||||
conditionNames: ['@nx/nx-source', 'require', 'node', 'default'],
|
||||
extensions: ['.js', '.json', '.node', '.ts', '.tsx'],
|
||||
});
|
||||
|
||||
if (
|
||||
process.argv[1].indexOf('jest-worker') > -1 ||
|
||||
(process.argv.length >= 4 && process.argv[3].split(':')[1] === 'test')
|
||||
) {
|
||||
const root = path.join(__dirname, '..', 'tmp', 'unit');
|
||||
try {
|
||||
if (!fs.existsSync(root)) {
|
||||
fs.mkdirSync(root);
|
||||
}
|
||||
} catch (_err) {}
|
||||
process.env.NX_WORKSPACE_ROOT_PATH = root;
|
||||
}
|
||||
|
||||
const excludedPackages = [
|
||||
'@nx/conformance',
|
||||
'@nx/owners',
|
||||
'@nx/key',
|
||||
'@nx/s3-cache',
|
||||
'@nx/azure-cache',
|
||||
'@nx/gcs-cache',
|
||||
'@nx/shared-fs-cache',
|
||||
];
|
||||
|
||||
module.exports = function (modulePath, options) {
|
||||
// Skip sequencer
|
||||
if (modulePath === 'jest-sequencer-@jest/test-sequencer') return;
|
||||
|
||||
// Only use custom resolution for workspace packages
|
||||
// Let default resolver handle published packages and everything else
|
||||
const workspacePackages = [
|
||||
'@nx/rollup',
|
||||
'@nx/eslint',
|
||||
'@nx/vite',
|
||||
'@nx/vitest',
|
||||
'@nx/jest',
|
||||
'@nx/docker',
|
||||
'@nx/dotnet',
|
||||
'@nx/js',
|
||||
'@nx/maven',
|
||||
'@nx/next',
|
||||
'@nx/storybook',
|
||||
'@nx/rsbuild',
|
||||
'@nx/react-native',
|
||||
'@nx/express',
|
||||
'@nx/web',
|
||||
'@nx/vue',
|
||||
'@nx/workspace',
|
||||
'@nx/module-federation',
|
||||
'@nx/rspack',
|
||||
'@nx/eslint-plugin',
|
||||
'@nx/angular',
|
||||
'@nx/create-nx-plugin',
|
||||
'@nx/create-nx-workspace',
|
||||
'@nx/cypress',
|
||||
'@nx/detox',
|
||||
'@nx/devkit',
|
||||
'@nx/esbuild',
|
||||
'@nx/expo',
|
||||
'@nx/gradle',
|
||||
'@nx/nest',
|
||||
'@nx/node',
|
||||
'@nx/nuxt',
|
||||
'@nx/playwright',
|
||||
'@nx/plugin',
|
||||
'@nx/react',
|
||||
'@nx/remix',
|
||||
'@nx/webpack',
|
||||
];
|
||||
|
||||
const isWorkspacePackage =
|
||||
workspacePackages.some((pkg) => modulePath.startsWith(pkg)) ||
|
||||
modulePath.startsWith('nx/');
|
||||
|
||||
if (!isWorkspacePackage) {
|
||||
// Global modules which must be resolved by defaultResolver
|
||||
if (['child_process', 'fs', 'http', 'path'].includes(modulePath)) {
|
||||
return options.defaultResolver(modulePath, options);
|
||||
}
|
||||
|
||||
return enhancedResolver(path.resolve(options.basedir), modulePath);
|
||||
}
|
||||
|
||||
const ext = path.extname(modulePath);
|
||||
|
||||
// Handle CSS imports
|
||||
if (ext === '.css' || ext === '.scss' || ext === '.sass' || ext === '.less') {
|
||||
return require.resolve('identity-obj-proxy');
|
||||
}
|
||||
|
||||
try {
|
||||
// For nx/* imports, use @nx/nx-source condition to resolve to .ts
|
||||
// source files instead of dist/*.js files.
|
||||
if (modulePath.startsWith('nx/') || modulePath === 'nx') {
|
||||
return options.defaultResolver(modulePath, {
|
||||
...options,
|
||||
conditions: ['@nx/nx-source', 'require', 'node', 'default'],
|
||||
});
|
||||
}
|
||||
|
||||
// Find workspace root - avoid filesystem lookups inside node_modules
|
||||
// For PNPM workspaces, we know the structure: workspace/packages/packageName
|
||||
let workspaceRoot = options.rootDir;
|
||||
|
||||
// If we're in a packages subdirectory, go up two levels to workspace root
|
||||
if (workspaceRoot.includes('/packages/')) {
|
||||
const packagesIndex = workspaceRoot.lastIndexOf('/packages/');
|
||||
workspaceRoot = workspaceRoot.substring(0, packagesIndex);
|
||||
} else {
|
||||
// Fallback: go up directories until we find packages/ (but check pnpm-lock.yaml for validation)
|
||||
while (workspaceRoot && workspaceRoot !== path.dirname(workspaceRoot)) {
|
||||
const pnpmLock = path.join(workspaceRoot, 'pnpm-lock.yaml');
|
||||
const packagesDir = path.join(workspaceRoot, 'packages');
|
||||
if (fs.existsSync(pnpmLock) && fs.existsSync(packagesDir)) {
|
||||
break;
|
||||
}
|
||||
workspaceRoot = path.dirname(workspaceRoot);
|
||||
}
|
||||
}
|
||||
|
||||
const packagesPath = path.join(workspaceRoot, 'packages');
|
||||
|
||||
// Handle main @nx/* package imports (e.g., '@nx/devkit', '@nx/js')
|
||||
const nxPackageMatch = modulePath.match(/^@nx\/([^/]+)$/);
|
||||
if (nxPackageMatch) {
|
||||
const packageName = nxPackageMatch[1];
|
||||
|
||||
// Check if this package exists in workspace
|
||||
const packageDir = path.join(packagesPath, packageName);
|
||||
|
||||
// Try different entry points based on package structure
|
||||
const possibleEntries = [
|
||||
path.join(packageDir, 'index.ts'),
|
||||
path.join(packageDir, 'src', 'index.ts'),
|
||||
];
|
||||
|
||||
for (const entry of possibleEntries) {
|
||||
if (isWorkspaceFile(entry)) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle @nx/*/src/* subpath imports (e.g., '@nx/devkit/src/utils/something')
|
||||
const nxSubpathMatch = modulePath.match(/^@nx\/([^/]+)\/src\/(.+)$/);
|
||||
if (nxSubpathMatch) {
|
||||
const packageName = nxSubpathMatch[1];
|
||||
const subpath = nxSubpathMatch[2];
|
||||
|
||||
// Try different patterns for subpath resolution
|
||||
const possiblePaths = [
|
||||
path.join(packagesPath, packageName, 'src', subpath + '.ts'), // Direct file
|
||||
path.join(packagesPath, packageName, 'src', subpath, 'index.ts'), // Directory with index.ts
|
||||
];
|
||||
|
||||
for (const possiblePath of possiblePaths) {
|
||||
if (isWorkspaceFile(possiblePath)) {
|
||||
return possiblePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle @nx/* other subpaths (e.g., '@nx/devkit/testing', '@nx/devkit/package.json')
|
||||
const nxOtherMatch = modulePath.match(/^@nx\/([^/]+)\/(.+)$/);
|
||||
if (nxOtherMatch) {
|
||||
const packageName = nxOtherMatch[1];
|
||||
const subpath = nxOtherMatch[2];
|
||||
|
||||
const packageDir = path.join(packagesPath, packageName);
|
||||
|
||||
// Try different patterns for subpath resolution
|
||||
const possiblePaths = [
|
||||
path.join(packageDir, subpath), // For files like package.json
|
||||
path.join(packageDir, subpath + '.ts'),
|
||||
path.join(packageDir, 'src', subpath + '.ts'),
|
||||
];
|
||||
|
||||
for (const possiblePath of possiblePaths) {
|
||||
if (isWorkspaceFile(possiblePath)) {
|
||||
return possiblePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Block excluded Nx packages from auto-resolution
|
||||
if (
|
||||
modulePath.startsWith('@nx/') &&
|
||||
!modulePath.startsWith('@nx/powerpack-') &&
|
||||
!excludedPackages.some((pkg) => modulePath.startsWith(pkg))
|
||||
) {
|
||||
// If we get here, it means the workspace package couldn't be resolved above
|
||||
// This might indicate a missing file or incorrect import
|
||||
console.warn(
|
||||
`[resolver] Could not resolve workspace package: ${modulePath}`
|
||||
);
|
||||
}
|
||||
|
||||
return enhancedResolver(path.resolve(options.basedir), modulePath);
|
||||
} catch (e) {
|
||||
// Final fallback: use default resolver for packages we can't handle
|
||||
// This preserves the old behavior where packages that couldn't be resolved
|
||||
// by the custom resolver would fall back to default resolution
|
||||
try {
|
||||
return options.defaultResolver(modulePath, options);
|
||||
} catch (defaultResolverError) {
|
||||
throw new Error(`[resolver] Could not resolve module: ${modulePath}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
This pre-install script will check that the necessary dependencies are installed
|
||||
Checks for:
|
||||
* Node 20+
|
||||
* pnpm (major derived from package.json packageManager field)
|
||||
* Rust 1.70+
|
||||
* mise trust status (if applicable)
|
||||
*/
|
||||
|
||||
if (process.env.CI) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const lt = require('semver/functions/lt');
|
||||
const { packageManager } = require('../package.json');
|
||||
|
||||
// packageManager is "pnpm@<version>"; the major is the floor we accept.
|
||||
const requiredPnpmVersion = packageManager.split('@')[1];
|
||||
const requiredPnpmMajor = requiredPnpmVersion.split('.')[0];
|
||||
|
||||
function execOrNull(command) {
|
||||
try {
|
||||
return execSync(command, { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getToolData() {
|
||||
const pnpm = execOrNull('pnpm --version');
|
||||
const rustRaw = execOrNull('rustc --version');
|
||||
const miseRaw = execOrNull('mise trust --show');
|
||||
|
||||
return {
|
||||
node: process.version,
|
||||
pnpm,
|
||||
rust: rustRaw ? rustRaw.split(' ')[1] : null,
|
||||
miseInstalled: miseRaw !== null,
|
||||
miseUntrusted: miseRaw !== null && miseRaw.includes('untrusted'),
|
||||
};
|
||||
}
|
||||
|
||||
function checkNode({ node }) {
|
||||
if (lt(node, '20.19.0')) {
|
||||
console.warn(
|
||||
`Please make sure that your installed Node version (${node}) is greater than v20.19.0`
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkPnpm({ pnpm }) {
|
||||
if (pnpm === null) {
|
||||
console.error(
|
||||
`Could not find pnpm on this system. Please make sure it is installed with: npm install -g pnpm@${requiredPnpmMajor}`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (lt(pnpm, `${requiredPnpmMajor}.0.0`)) {
|
||||
console.error(
|
||||
`Found pnpm ${pnpm}. Please make sure that your installed pnpm version is ${requiredPnpmMajor}.0.0 or greater. You can update with: npm install -g pnpm@${requiredPnpmMajor}`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkRust({ rust, miseInstalled, miseUntrusted }) {
|
||||
const missing = rust === null;
|
||||
const outdated = !missing && lt(rust, '1.70.0');
|
||||
|
||||
if (!missing && !outdated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If mise is installed but untrusted, a simple `mise trust` likely fixes Rust
|
||||
if (miseInstalled && miseUntrusted) {
|
||||
console.error(
|
||||
missing
|
||||
? 'Could not find the Rust compiler on this system.'
|
||||
: `Found Rust ${rust}, but version 1.70.0 or greater is required.`,
|
||||
'\nmise is installed but this directory is not trusted. Run `mise trust` in this directory to install the correct toolchain.'
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (outdated) {
|
||||
console.log(`Found rustc ${rust}`);
|
||||
console.error(
|
||||
'Please make sure that your installed Rust version is greater than v1.70. You can update with `rustup update`, or install mise (https://mise.jdx.dev/) and run `mise trust` in this directory.'
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
'Could not find the Rust compiler on this system. Please install it with https://rustup.rs, or install mise (https://mise.jdx.dev/) and run `mise trust` in this directory.'
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const toolData = getToolData();
|
||||
const hasErrors = [checkNode, checkPnpm, checkRust].some((check) =>
|
||||
check(toolData)
|
||||
);
|
||||
|
||||
if (hasErrors) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// @ts-check
|
||||
|
||||
const { execSync } = require('node:child_process');
|
||||
|
||||
/**
|
||||
* This function is invoked by the publish.yml GitHub Action workflow and contains all of the dynamic logic needed
|
||||
* for the various workflow trigger types. This avoids the need for the logic to be stored in fragile inline
|
||||
* shell commands.
|
||||
*
|
||||
* @typedef {'--dry-run' | ''} DryRunFlag
|
||||
*
|
||||
* @typedef {{
|
||||
* version: string;
|
||||
* dry_run_flag: DryRunFlag;
|
||||
* success_comment: string;
|
||||
* publish_branch: string;
|
||||
* ref: string;
|
||||
* repo: string;
|
||||
* pr_number: string;
|
||||
* pr_author: string;
|
||||
* }} PublishResolveData
|
||||
*
|
||||
* Partial from https://github.com/actions/toolkit/blob/c6b487124a61d7dc6c7bd6ea0208368af3513a6e/packages/github/src/context.ts
|
||||
* @typedef {{
|
||||
* actor: string;
|
||||
* runId: number;
|
||||
* repo: { owner: string; repo: string };
|
||||
* }} GitHubContext
|
||||
*
|
||||
* @param {{
|
||||
* github: import('octokit/dist-types').Octokit;
|
||||
* context: GitHubContext;
|
||||
* core: import('@actions/core');
|
||||
* }} param
|
||||
*/
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const data = await getPublishResolveData({ github, context });
|
||||
|
||||
// Ensure that certain outputs are always set
|
||||
if (!data.version) {
|
||||
throw new Error('The "version" to release could not be determined');
|
||||
}
|
||||
if (!data.publish_branch) {
|
||||
throw new Error('The "publish_branch" could not be determined');
|
||||
}
|
||||
|
||||
// Set the outputs to be consumed in later steps
|
||||
core.setOutput('version', data.version);
|
||||
core.setOutput('dry_run_flag', data.dry_run_flag);
|
||||
core.setOutput('success_comment', JSON.stringify(data.success_comment)); // Escape the multi-line string
|
||||
core.setOutput('publish_branch', data.publish_branch);
|
||||
core.setOutput('ref', data.ref);
|
||||
core.setOutput('repo', data.repo);
|
||||
core.setOutput('pr_number', data.pr_number);
|
||||
core.setOutput('pr_author', data.pr_author);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* github: import('octokit/dist-types').Octokit;
|
||||
* context: GitHubContext;
|
||||
* }} param
|
||||
*
|
||||
* @returns {Promise<PublishResolveData>}
|
||||
*/
|
||||
async function getPublishResolveData({ github, context }) {
|
||||
// We use empty strings as default values so that we can let the `actions/checkout` action apply its default resolution
|
||||
/**
|
||||
* "The short ref name of the branch or tag that triggered the workflow run. This value matches the branch or tag name shown
|
||||
* on GitHub. For example, feature-branch-1."
|
||||
*
|
||||
* Source: https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables
|
||||
*/
|
||||
const refName = process.env.GITHUB_REF_NAME;
|
||||
if (!refName) {
|
||||
throw new Error('The github ref name could not be determined');
|
||||
}
|
||||
|
||||
const DEFAULT_PUBLISH_BRANCH = `publish/${refName}`;
|
||||
const DEFAULT_REPO = '';
|
||||
const DEFAULT_REF = '';
|
||||
|
||||
/** @type {DryRunFlag} */
|
||||
const DRY_RUN_DISABLED = '';
|
||||
/** @type {DryRunFlag} */
|
||||
const DRY_RUN_ENABLED = '--dry-run';
|
||||
|
||||
switch (process.env.GITHUB_EVENT_NAME) {
|
||||
case 'schedule': {
|
||||
const data = {
|
||||
version: 'canary',
|
||||
dry_run_flag: DRY_RUN_DISABLED,
|
||||
success_comment: '',
|
||||
publish_branch: DEFAULT_PUBLISH_BRANCH,
|
||||
repo: DEFAULT_REPO,
|
||||
ref: DEFAULT_REF,
|
||||
pr_number: '',
|
||||
pr_author: '',
|
||||
};
|
||||
console.log('"schedule" trigger detected', { data });
|
||||
return data;
|
||||
}
|
||||
|
||||
case 'release': {
|
||||
const data = {
|
||||
version: refName,
|
||||
dry_run_flag: DRY_RUN_DISABLED,
|
||||
success_comment: '',
|
||||
publish_branch: DEFAULT_PUBLISH_BRANCH,
|
||||
repo: DEFAULT_REPO,
|
||||
ref: DEFAULT_REF,
|
||||
pr_number: '',
|
||||
pr_author: '',
|
||||
};
|
||||
console.log('"release" trigger detected', { data });
|
||||
return data;
|
||||
}
|
||||
|
||||
case 'workflow_dispatch': {
|
||||
const prNumber = context.payload.inputs?.pr;
|
||||
|
||||
if (!prNumber) {
|
||||
const data = {
|
||||
version: '0.0.0-dry-run.0',
|
||||
dry_run_flag: DRY_RUN_ENABLED,
|
||||
success_comment: '',
|
||||
publish_branch: DEFAULT_PUBLISH_BRANCH,
|
||||
// In this case the default checkout logic should use the branch/tag selected when triggering the workflow
|
||||
repo: DEFAULT_REPO,
|
||||
ref: DEFAULT_REF,
|
||||
pr_number: '',
|
||||
pr_author: '',
|
||||
};
|
||||
console.log(
|
||||
'"workflow_dispatch" trigger detected, no PR number provided',
|
||||
{ data }
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: Number(prNumber),
|
||||
});
|
||||
if (!pr?.data?.head?.repo) {
|
||||
throw new Error(
|
||||
`The PR data for PR number ${prNumber} is missing the head branch information`
|
||||
);
|
||||
}
|
||||
|
||||
const fullSHA = pr.data.head.sha;
|
||||
const shortSHA = fullSHA.slice(0, 7);
|
||||
|
||||
// Use the base version from nx@next so PR releases align with the current next/beta release line
|
||||
const currentNextVersion = execSync('npm view nx@next version', {
|
||||
windowsHide: false,
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
const nextVersionMatch = currentNextVersion.match(/^(\d+\.\d+\.\d+)/);
|
||||
if (!nextVersionMatch) {
|
||||
throw new Error(
|
||||
`Unable to parse the current next version from the npm registry: "${currentNextVersion}"`
|
||||
);
|
||||
}
|
||||
const prBaseVersion = nextVersionMatch[1];
|
||||
const version = `${prBaseVersion}-pr.${prNumber}.${shortSHA}`;
|
||||
const repo = pr.data.head.repo.full_name;
|
||||
const ref = pr.data.head.ref;
|
||||
const pr_author = pr.data.user?.login || 'unknown';
|
||||
|
||||
const data = {
|
||||
version,
|
||||
dry_run_flag: DRY_RUN_DISABLED,
|
||||
success_comment: getSuccessCommentForPR({
|
||||
context,
|
||||
version,
|
||||
repo,
|
||||
ref,
|
||||
pr_short_sha: shortSHA,
|
||||
pr_full_sha: fullSHA,
|
||||
}),
|
||||
publish_branch: `publish-pr-${prNumber}`,
|
||||
// Override the default git checkout for the build job
|
||||
repo,
|
||||
ref,
|
||||
pr_number: prNumber,
|
||||
pr_author,
|
||||
};
|
||||
console.log('"workflow_dispatch" trigger detected, PR number provided', {
|
||||
data,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`The publish.yml workflow was triggered by an unexpected event: "${process.env.GITHUB_EVENT_NAME}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getSuccessCommentForPR({
|
||||
context,
|
||||
version,
|
||||
repo,
|
||||
ref,
|
||||
pr_short_sha,
|
||||
pr_full_sha,
|
||||
}) {
|
||||
return `## 🐳 We have a release for that!
|
||||
|
||||
This PR has a release associated with it. You can try it out using this command:
|
||||
|
||||
\`\`\`bash
|
||||
npx create-nx-workspace@${version} my-workspace
|
||||
\`\`\`
|
||||
|
||||
Or just copy this version and use it in your own command:
|
||||
\`\`\`bash
|
||||
${version}
|
||||
\`\`\`
|
||||
|
||||
| Release details | 📑 |
|
||||
| ------------- | ------------- |
|
||||
| **Published version** | [${version}](https://www.npmjs.com/package/nx/v/${version}) |
|
||||
| **Triggered by** | @${context.actor} |
|
||||
| **Branch** | [${ref}](https://github.com/${repo}/tree/${ref}) |
|
||||
| **Commit** | [${pr_short_sha}](https://github.com/${repo}/commit/${pr_full_sha}) |
|
||||
| **Workflow run** | [${context.runId}](https://github.com/nrwl/nx/actions/runs/${context.runId}) |
|
||||
|
||||
To request a new release for this pull request, mention someone from the Nx team or the \`@nrwl/nx-pipelines-reviewers\`.
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
## Getting Started
|
||||
|
||||
### Creating an Nx Workspace
|
||||
|
||||
**Using `npx`**
|
||||
|
||||
```bash
|
||||
npx create-nx-workspace
|
||||
```
|
||||
|
||||
**Using `npm init`**
|
||||
|
||||
```bash
|
||||
npm init nx-workspace
|
||||
```
|
||||
|
||||
**Using `yarn create`**
|
||||
|
||||
```bash
|
||||
yarn create nx-workspace
|
||||
```
|
||||
|
||||
### Adding Nx to an Existing Repository
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx nx@latest init
|
||||
```
|
||||
|
||||
## Documentation & Resources
|
||||
|
||||
- [Nx.Dev: Documentation, Guides, Tutorials](https://nx.dev)
|
||||
- [Intro to Nx](https://nx.dev/getting-started/intro)
|
||||
- [Official Nx YouTube Channel](https://www.youtube.com/@NxDevtools)
|
||||
- [Blog Posts About Nx](https://nx.dev/blog)
|
||||
|
||||
<p style="text-align: center;"><a href="https://nx.dev/#learning-materials" target="_blank" rel="noreferrer"><img src="https://raw.githubusercontent.com/nrwl/nx/master/images/nx-courses-and-videos.svg"
|
||||
width="100%" alt="Nx - Smart Monorepos · Fast Builds"></a></p>
|
||||
@@ -0,0 +1,10 @@
|
||||
<div style="text-align: center;">
|
||||
|
||||
[]()
|
||||
[](https://www.npmjs.com/package/nx)
|
||||
[]()
|
||||
[](http://commitizen.github.io/cz-cli/)
|
||||
[](https://go.nx.dev/community)
|
||||
[](https://nx.dev/docs/features/ci-features/sandboxing)
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
## Documentation & Resources
|
||||
|
||||
- [Nx.Dev: Documentation, Guides, Tutorials](https://nx.dev)
|
||||
- [Intro to Nx](https://nx.dev/getting-started/intro)
|
||||
- [Official Nx YouTube Channel](https://www.youtube.com/@NxDevtools)
|
||||
- [Blog Posts About Nx](https://nx.dev/blog)
|
||||
|
||||
<p style="text-align: center;"><a href="https://nx.dev/#learning-materials" target="_blank" rel="noreferrer"><img src="https://raw.githubusercontent.com/nrwl/nx/master/images/nx-courses-and-videos.svg"
|
||||
width="100%" alt="Nx - Smart Monorepos · Fast Builds"></a></p>
|
||||
@@ -0,0 +1,48 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { gte, major, maxSatisfying } from 'semver';
|
||||
|
||||
// The GITHUB_REF_NAME is a full version (i.e. 17.3.2). The branchName will strip the patch version number.
|
||||
// We will publish docs to the website branch based on the current tag (i.e. website-17)
|
||||
const dryRun = process.argv.includes('--dryRun');
|
||||
if (dryRun) {
|
||||
console.log('Dry run mode enabled');
|
||||
}
|
||||
const currentVersion = process.env.GITHUB_REF_NAME || '';
|
||||
console.log(`Comparing ${currentVersion} to npm versions`);
|
||||
|
||||
const majorVersion = major(currentVersion);
|
||||
let releasedVersions: string[] = JSON.parse(
|
||||
execSync(`npm show nx@^${majorVersion} version --json`, {
|
||||
windowsHide: false,
|
||||
}).toString()
|
||||
);
|
||||
if (typeof releasedVersions === 'string') {
|
||||
releasedVersions = [releasedVersions];
|
||||
}
|
||||
|
||||
const latestVersion = maxSatisfying(releasedVersions, `^${majorVersion}`);
|
||||
|
||||
console.log(`Found npm versions:\n${releasedVersions.join('\n')}`);
|
||||
|
||||
// Publish if the current version is greater than or equal to the latest released version
|
||||
|
||||
const branchName = `website-${majorVersion}`;
|
||||
if (
|
||||
!dryRun &&
|
||||
currentVersion &&
|
||||
latestVersion &&
|
||||
gte(currentVersion, latestVersion)
|
||||
) {
|
||||
console.log(
|
||||
`Publishing docs site for ${process.env.GITHUB_REF_NAME} to ${branchName}`
|
||||
);
|
||||
// We force recreate the branch in order to always be up to date and avoid merge conflicts within the automated workflow
|
||||
execSync(`git branch -f ${branchName}`, {
|
||||
windowsHide: false,
|
||||
});
|
||||
execSync(`git push -f origin ${branchName}`, {
|
||||
windowsHide: false,
|
||||
});
|
||||
} else {
|
||||
console.log(`Not publishing docs to ${branchName}`);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
const { readFileSync, writeFileSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
|
||||
const [file] = process.argv.slice(2);
|
||||
|
||||
const originalContents = readFileSync(file).toString();
|
||||
|
||||
const packageJson = require(join(__dirname, '../package.json'));
|
||||
|
||||
const typescriptVersion = packageJson.devDependencies.typescript;
|
||||
const prettierVersion = packageJson.devDependencies.prettier;
|
||||
|
||||
writeFileSync(
|
||||
file,
|
||||
originalContents
|
||||
.replace(/TYPESCRIPT_VERSION/g, typescriptVersion)
|
||||
.replace(/PRETTIER_VERSION/g, prettierVersion)
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
// @ts-check
|
||||
|
||||
const { execSync } = require('node:child_process');
|
||||
|
||||
if (
|
||||
process.env.SKIP_ANALYZER_BUILD !== 'true' &&
|
||||
process.env.SKIP_NATIVE_TARGET !== 'true'
|
||||
) {
|
||||
const [target, project] = process.argv.slice(2);
|
||||
execSync(`nx run ${project}:${target}`, { stdio: 'inherit' });
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
const open = require('open');
|
||||
const childProcess = require('child_process');
|
||||
|
||||
function createPullRequest() {
|
||||
const remoteUrl = childProcess
|
||||
.execSync(`git ls-remote --get-url origin`)
|
||||
.toString('utf-8')
|
||||
.trim();
|
||||
const remoteName = remoteUrl.match(/[\/|:](\w+?)\//)[1];
|
||||
const branchName = childProcess
|
||||
.execSync('git rev-parse --abbrev-ref HEAD')
|
||||
.toString('utf-8')
|
||||
.trim();
|
||||
const prUrl = `https://github.com/nrwl/nx/compare/master...${remoteName}:${branchName}?expand=1&template=COMMUNITY_PLUGIN.md`;
|
||||
open(prUrl);
|
||||
}
|
||||
|
||||
createPullRequest();
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
./scripts/link.sh
|
||||
PUBLISHED_VERSION=$1 jest --maxWorkers=1 ./build/e2e/commands/create-nx-workspace.test.js
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../scripts/tools-out",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../scripts/tools-out",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../packages/devkit/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../packages/nx/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../nx-dev/models-package/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../nx-dev/models-document/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../nx-dev/data-access-documents/tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
const path = require('path');
|
||||
|
||||
// Absolute paths to the physical source files inside `packages/nx`. Mocking
|
||||
// by the `nx/src/...` specifier instead routes through the pnpm
|
||||
// `node_modules/nx` symlink, which jest keys as a *different* module id
|
||||
// from the relative imports inside `packages/nx` — so the mock is never
|
||||
// applied on CI. Using the absolute physical path here guarantees both
|
||||
// resolution chains hit the same registry entry.
|
||||
const nxSrcPath = (relative) => {
|
||||
const base = path.resolve(__dirname, '..', 'packages/nx/src', relative);
|
||||
// Resolve to the actual source file. `jest.doMock` keys mocks by the path
|
||||
// that callers' module resolution produces — `.ts` for our source, or
|
||||
// `index.js` for the napi binding entry — so passing a bare directory or
|
||||
// extension-less path leaves callers' imports unmocked.
|
||||
for (const candidate of [base, `${base}.ts`, path.join(base, 'index.js')]) {
|
||||
try {
|
||||
const stat = require('fs').statSync(candidate);
|
||||
if (stat.isFile()) return candidate;
|
||||
} catch (_) {}
|
||||
}
|
||||
return base;
|
||||
};
|
||||
const realWorkspaceRoot = path.resolve(__dirname, '..');
|
||||
|
||||
module.exports = () => {
|
||||
/**
|
||||
* When the daemon is enabled during unit tests,
|
||||
* and the daemon is already running, the daemon-client.ts
|
||||
* code will be used, but it will hit the already running
|
||||
* daemon which is from the installed version of Nx.
|
||||
*
|
||||
* In the vast majority of cases, this is fine. However,
|
||||
* if a new message type has been added to the daemon in
|
||||
* the source code, and isn't yet in the installed version,
|
||||
* any test that hits that codepath will fail. This is because
|
||||
* the installed version of the daemon doesn't know how to
|
||||
* handle the new message type.
|
||||
*
|
||||
* To prevent this, we disable the daemon during unit tests.
|
||||
*/
|
||||
process.env.NX_DAEMON = 'false';
|
||||
|
||||
const emptyProjectGraph = { nodes: {}, dependencies: {} };
|
||||
const emptyProjectGraphAndMaps = {
|
||||
projectGraph: emptyProjectGraph,
|
||||
sourceMaps: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* When `createProjectGraphAsync` is called during tests,
|
||||
* if its not mocked, it will return the Nx repo's project
|
||||
* graph. We don't want any unit tests to depend on the structure
|
||||
* of the Nx repo, so we mock it to return an empty project graph.
|
||||
*
|
||||
* Skipped for `packages/nx` itself — `nx`'s own source code never imports
|
||||
* from `@nx/devkit` (devkit re-exports from nx, not vice versa), and the
|
||||
* relative-path mock for `nx/src/project-graph/project-graph` below
|
||||
* already covers nx's internal `createProjectGraphAsync` callers. Loading
|
||||
* devkit here just to spread `requireActual('@nx/devkit')` would pull
|
||||
* its entire source tree into the sandbox for no callers.
|
||||
*/
|
||||
const isNxProject = process.env.NX_TASK_TARGET_PROJECT === 'nx';
|
||||
if (!isNxProject) {
|
||||
jest.doMock('@nx/devkit', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('@nx/devkit'),
|
||||
createProjectGraphAsync: jest.fn(async () => emptyProjectGraph),
|
||||
/**
|
||||
* `ensurePackage` calls `require(pkg)` which resolves from node_modules
|
||||
* (the installed version) instead of the local source code. Using
|
||||
* `jest.requireActual` routes through Jest's module resolver which
|
||||
* respects tsconfig paths, so it picks up the source code instead.
|
||||
*/
|
||||
ensurePackage: jest.fn((pkg) => jest.requireActual(pkg)),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Code inside `packages/nx` imports graph builders via relative paths
|
||||
* (`../../project-graph/project-graph`), which skip the `@nx/devkit`
|
||||
* mock above. Mock the source file at its absolute physical path so
|
||||
* those callers also get an empty graph.
|
||||
*/
|
||||
const projectGraphPath = nxSrcPath('project-graph/project-graph');
|
||||
jest.doMock(projectGraphPath, () => {
|
||||
const actual = jest.requireActual(projectGraphPath);
|
||||
return {
|
||||
__esModule: true,
|
||||
...actual,
|
||||
createProjectGraphAsync: jest.fn(async () => emptyProjectGraph),
|
||||
createProjectGraphAndSourceMapsAsync: jest.fn(
|
||||
async () => emptyProjectGraphAndMaps
|
||||
),
|
||||
buildProjectGraphAndSourceMapsWithoutDaemon: jest.fn(
|
||||
async () => emptyProjectGraphAndMaps
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Guard: if a unit test reaches plugin isolation pointed at the real
|
||||
* workspace, it spawns a `plugin-worker.ts` subprocess that scans the
|
||||
* whole monorepo and produces ~thousands of sandbox violations. Tests
|
||||
* that legitimately exercise plugin isolation against a `TempFs` root
|
||||
* (e.g. `getOnlyDefaultPlugins(tempFs.tempDir)`) pass through unchanged.
|
||||
*/
|
||||
const loadIsolatedPath = nxSrcPath(
|
||||
'project-graph/plugins/isolation/load-isolated-plugin'
|
||||
);
|
||||
jest.doMock(loadIsolatedPath, () => {
|
||||
const actual = jest.requireActual(loadIsolatedPath);
|
||||
return {
|
||||
__esModule: true,
|
||||
...actual,
|
||||
loadIsolatedNxPlugin: jest.fn((plugin, root, index) => {
|
||||
if (root === realWorkspaceRoot) {
|
||||
throw new Error(
|
||||
'[unit-test-setup] loadIsolatedNxPlugin was called with the real ' +
|
||||
'workspace root during a unit test. This spawns a real plugin ' +
|
||||
'worker that scans the entire monorepo and causes sandbox ' +
|
||||
'violations. Something reached real project-graph computation ' +
|
||||
'without hitting the @nx/devkit or project-graph mocks. Check ' +
|
||||
'the stack trace for the unmocked caller and either mock it in ' +
|
||||
'the test, point the call at a TempFs root, or extend ' +
|
||||
'scripts/unit-test-setup.js.'
|
||||
);
|
||||
}
|
||||
return actual.loadIsolatedNxPlugin(plugin, root, index);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Guard: short-circuit `workspace-context` helpers when they're handed the
|
||||
* real workspace root. The native rust `WorkspaceContext` recursively walks
|
||||
* the workspace on construction, so any test that reaches these with the
|
||||
* real root scans the full monorepo and produces thousands of sandbox
|
||||
* violations. Tests that pass a `TempFs` root continue to hit the real
|
||||
* implementation.
|
||||
*
|
||||
* The actual culprit observed: `createFileMapUsingProjectGraph` reads the
|
||||
* imported `workspaceRoot` constant and calls `getAllFileDataInContext` on
|
||||
* it. Returning empty results for the real root gives every caller a safe
|
||||
* no-op without breaking tests that have synthetic file maps.
|
||||
*/
|
||||
// Use plain functions (not `jest.fn`) so `jest.resetAllMocks()` in test
|
||||
// suites can't wipe these implementations and turn them into `() =>
|
||||
// undefined`, which would surface as "is not iterable" downstream.
|
||||
const workspaceContextPath = nxSrcPath('utils/workspace-context');
|
||||
jest.doMock(workspaceContextPath, () => {
|
||||
// Lazily resolve the real module on each call. Capturing it in the
|
||||
// factory closure produces an empty object on the first invocation
|
||||
// (jest's internal loader returns the in-progress `module.exports`
|
||||
// when `requireActual` re-enters the same module from inside the
|
||||
// mock factory).
|
||||
const realFn =
|
||||
(name) =>
|
||||
(...args) =>
|
||||
jest.requireActual(workspaceContextPath)[name](...args);
|
||||
const guarded =
|
||||
(name, fallback) =>
|
||||
(root, ...rest) => {
|
||||
if (root === realWorkspaceRoot) return fallback();
|
||||
return jest.requireActual(workspaceContextPath)[name](root, ...rest);
|
||||
};
|
||||
return {
|
||||
__esModule: true,
|
||||
setupWorkspaceContext: (root) => {
|
||||
if (root === realWorkspaceRoot) return;
|
||||
return jest
|
||||
.requireActual(workspaceContextPath)
|
||||
.setupWorkspaceContext(root);
|
||||
},
|
||||
getNxWorkspaceFilesFromContext: guarded(
|
||||
'getNxWorkspaceFilesFromContext',
|
||||
() =>
|
||||
Promise.resolve({
|
||||
projectFileMap: {},
|
||||
globalFiles: [],
|
||||
externalReferences: {},
|
||||
})
|
||||
),
|
||||
globWithWorkspaceContext: guarded('globWithWorkspaceContext', () =>
|
||||
Promise.resolve([])
|
||||
),
|
||||
globWithWorkspaceContextSync: guarded(
|
||||
'globWithWorkspaceContextSync',
|
||||
() => []
|
||||
),
|
||||
multiGlobWithWorkspaceContext: guarded(
|
||||
'multiGlobWithWorkspaceContext',
|
||||
() => Promise.resolve([])
|
||||
),
|
||||
hashWithWorkspaceContext: guarded('hashWithWorkspaceContext', () =>
|
||||
Promise.resolve('0')
|
||||
),
|
||||
hashMultiGlobWithWorkspaceContext: guarded(
|
||||
'hashMultiGlobWithWorkspaceContext',
|
||||
() => Promise.resolve([])
|
||||
),
|
||||
getAllFileDataInContext: guarded('getAllFileDataInContext', () =>
|
||||
Promise.resolve([])
|
||||
),
|
||||
getFilesInDirectoryUsingContext: guarded(
|
||||
'getFilesInDirectoryUsingContext',
|
||||
() => Promise.resolve([])
|
||||
),
|
||||
// Pass-through helpers that don't take a workspace root.
|
||||
updateContextWithChangedFiles: realFn('updateContextWithChangedFiles'),
|
||||
updateFilesInContext: realFn('updateFilesInContext'),
|
||||
updateProjectFiles: realFn('updateProjectFiles'),
|
||||
resetWorkspaceContext: realFn('resetWorkspaceContext'),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Backstop: short-circuit native rust functions that recursively walk a
|
||||
* directory when they're handed the real workspace root. The
|
||||
* `workspace-context` mock above catches the high-level callers, but
|
||||
* `expandOutputs` / `getFilesForOutputsBatch` are called directly from
|
||||
* `tasks-runner/cache.ts` (`_expandOutputs(outputs, workspaceRoot)`) and
|
||||
* miss that net — they construct nothing, but `expand_outputs` drives
|
||||
* `nx_walker(realWorkspaceRoot)` and surfaces as the same cross-project
|
||||
* violation set.
|
||||
*/
|
||||
const nativePath = nxSrcPath('native');
|
||||
jest.doMock(nativePath, () => {
|
||||
const actual = jest.requireActual(nativePath);
|
||||
const RealWorkspaceContext = actual.WorkspaceContext;
|
||||
function GuardedWorkspaceContext(root, cacheDir) {
|
||||
if (root === realWorkspaceRoot) {
|
||||
throw new Error(
|
||||
'[unit-test-setup] WorkspaceContext was constructed with the real ' +
|
||||
'workspace root during a unit test. This triggers a recursive ' +
|
||||
'walk of the entire monorepo and causes sandbox violations. ' +
|
||||
'Check the stack trace for the caller and either mock it in the ' +
|
||||
'test, point the call at a TempFs root, or extend ' +
|
||||
'scripts/unit-test-setup.js.'
|
||||
);
|
||||
}
|
||||
return new RealWorkspaceContext(root, cacheDir);
|
||||
}
|
||||
GuardedWorkspaceContext.prototype = RealWorkspaceContext.prototype;
|
||||
const guardDirArg = (fn, fallback) =>
|
||||
function (directory, ...rest) {
|
||||
if (directory === realWorkspaceRoot) return fallback;
|
||||
return fn(directory, ...rest);
|
||||
};
|
||||
return {
|
||||
__esModule: true,
|
||||
...actual,
|
||||
WorkspaceContext: GuardedWorkspaceContext,
|
||||
expandOutputs: guardDirArg(actual.expandOutputs, []),
|
||||
getFilesForOutputsBatch: guardDirArg(actual.getFilesForOutputsBatch, []),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* `isUsingTsSolutionSetup()` falls back to `new FsTree(workspaceRoot, false)`
|
||||
* when called without a tree, which reads the real repo's `tsconfig.json` /
|
||||
* `tsconfig.base.json`. That surfaces as a sandbox violation for tests that
|
||||
* indirectly invoke it (cypress-preset, playwright-preset, plugin
|
||||
* `createNodesV2`, executor `normalize`, etc.).
|
||||
*
|
||||
* Unit tests should never touch the real workspace FS, so when the function
|
||||
* is called without a tree, short-circuit to `true`. `true` matches the
|
||||
* de-facto behavior of hitting the real FS (the Nx repo is a TS solution
|
||||
* workspace), preserving every test's existing expectations without
|
||||
* reading from disk. Calls that pass an explicit (virtual) tree still run
|
||||
* the real implementation.
|
||||
*
|
||||
* There are two copies of the function — one in `@nx/js` and one in
|
||||
* `@nx/workspace` — both need to be mocked.
|
||||
*/
|
||||
const mockIsUsingTsSolutionSetup = (specifier) => {
|
||||
// Some test configs (e.g. tools/workspace-plugin) use the default jest
|
||||
// resolver, which does not read package `exports` maps. If a workspace
|
||||
// package locks down its `exports` map, `@nx/<pkg>/src/...` subpath
|
||||
// imports become unresolvable in those contexts. Skip the mock there —
|
||||
// those tests don't import the function anyway.
|
||||
try {
|
||||
require.resolve(specifier);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
jest.doMock(specifier, () => {
|
||||
const actual = jest.requireActual(specifier);
|
||||
return {
|
||||
__esModule: true,
|
||||
...actual,
|
||||
isUsingTsSolutionSetup: jest.fn((tree) =>
|
||||
tree ? actual.isUsingTsSolutionSetup(tree) : true
|
||||
),
|
||||
};
|
||||
});
|
||||
};
|
||||
mockIsUsingTsSolutionSetup('@nx/js/internal');
|
||||
mockIsUsingTsSolutionSetup(
|
||||
'@nx/workspace/src/utilities/typescript/ts-solution-setup'
|
||||
);
|
||||
|
||||
/**
|
||||
* Two helpers in `packages/nx/src/utils/` probe the filesystem via
|
||||
* `require.resolve` to find sibling Nx packages:
|
||||
*
|
||||
* - `hasNxJsPlugin(projectRoot, workspaceRoot)` (in `has-nx-js-plugin.ts`)
|
||||
* — checks whether `@nx/js` is installed so it can decide whether to
|
||||
* inject the implicit `nx-release-publish` target on a
|
||||
* `package.json`-based project.
|
||||
* - `readModulePackageJsonWithoutFallbacks(specifier, paths)` (in
|
||||
* `package-json.ts`) — reads a plugin's `package.json`. Used by
|
||||
* `readPluginPackageJson`, `readExecutorJson`, and target normalization.
|
||||
*
|
||||
* In unit tests `__dirname` falls back to the real `packages/nx/src/utils`,
|
||||
* so even when callers pass a synthetic `workspaceRoot` like `/tmp/test`,
|
||||
* Node's resolver walks up to the real repo's pnpm-symlinked
|
||||
* `node_modules` and lands on `packages/<plugin>/package.json`. Each one
|
||||
* shows up as a sandbox-violating foreign read.
|
||||
*
|
||||
* Pin both behaviors:
|
||||
* - `hasNxJsPlugin` → always `true`, matching the de-facto answer in
|
||||
* this repo (and what tests expect — they assert the implicit
|
||||
* target gets added).
|
||||
* - `readModulePackageJsonWithoutFallbacks` → throw MODULE_NOT_FOUND for
|
||||
* `@nx/*` lookups. Production callers (`readPluginPackageJson`,
|
||||
* `readExecutorJson`, target normalization) all catch MODULE_NOT_FOUND
|
||||
* and degrade gracefully.
|
||||
*
|
||||
* Scoped to `nx:test` only — these mocks target `packages/nx/src/utils/`
|
||||
* source files by absolute physical path and exist to neutralize nx's
|
||||
* own plugin-resolution probing. Applying them to other projects'
|
||||
* tests (rspack, webpack, jest, …) can interfere with legitimate
|
||||
* `@nx/*` lookups those test paths might exercise.
|
||||
*/
|
||||
if (isNxProject) {
|
||||
const hasNxJsPluginPath = nxSrcPath('utils/has-nx-js-plugin');
|
||||
jest.doMock(hasNxJsPluginPath, () => ({
|
||||
__esModule: true,
|
||||
hasNxJsPlugin: () => true,
|
||||
}));
|
||||
|
||||
const packageJsonPath = nxSrcPath('utils/package-json');
|
||||
jest.doMock(packageJsonPath, () => {
|
||||
const actual = jest.requireActual(packageJsonPath);
|
||||
return {
|
||||
__esModule: true,
|
||||
...actual,
|
||||
readModulePackageJsonWithoutFallbacks: (
|
||||
moduleSpecifier,
|
||||
requirePaths
|
||||
) => {
|
||||
if (moduleSpecifier && moduleSpecifier.startsWith('@nx/')) {
|
||||
const err = new Error(`Cannot find module '${moduleSpecifier}'`);
|
||||
err.code = 'MODULE_NOT_FOUND';
|
||||
throw err;
|
||||
}
|
||||
return actual.readModulePackageJsonWithoutFallbacks(
|
||||
moduleSpecifier,
|
||||
requirePaths
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
const { readFileSync, writeFileSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
|
||||
console.log('Updating @nrwl/workspace package group');
|
||||
const file = join(__dirname, '../dist/packages/workspace/package.json');
|
||||
const originalContents = readFileSync(file).toString();
|
||||
|
||||
const { version } = require(file);
|
||||
|
||||
writeFileSync(file, originalContents.replace(/\*/g, version));
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
./scripts/build.sh
|
||||
|
||||
echo 'Updating all playground projects...'
|
||||
|
||||
# Update NX playground
|
||||
rm -rf tmp/nx/proj/node_modules/@nrwl
|
||||
cp -r build/packages tmp/nx/proj/node_modules/@nrwl
|
||||
|
||||
# Update Angular playground
|
||||
rm -rf tmp/angular/proj/node_modules/@nrwl
|
||||
cp -r build/packages tmp/angular/proj/node_modules/@nrwl
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { validateCommitMessage } = require('./commit-lint.js');
|
||||
|
||||
// Get PR title and body from environment variables or arguments
|
||||
const prTitle = process.env.PR_TITLE || process.argv[2] || '';
|
||||
const prBody = process.env.PR_BODY || process.argv[3] || '';
|
||||
|
||||
if (!prTitle) {
|
||||
console.error('Error: PR title is required');
|
||||
console.error('Usage: node validate-pr-title.js "<title>" "<body>"');
|
||||
console.error('Or set PR_TITLE and PR_BODY environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('🐟🐟🐟 Validating PR title 🐟🐟🐟');
|
||||
console.log(`PR title: ${prTitle}`);
|
||||
|
||||
// Validate the PR title using the shared validation function
|
||||
const result = validateCommitMessage(prTitle);
|
||||
|
||||
if (result.isValid) {
|
||||
console.log('✅ PR title ACCEPTED 👍');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(
|
||||
'[Error]: Oh no! 😦 Your PR title: \n' +
|
||||
'-------------------------------------------------------------------\n' +
|
||||
prTitle +
|
||||
'\n-------------------------------------------------------------------' +
|
||||
'\n\n 👉️ Does not follow the commit message convention specified in the CONTRIBUTING.MD file.'
|
||||
);
|
||||
console.error('\ntype(scope): subject');
|
||||
console.error('\n');
|
||||
console.error(`possible types: ${result.allowedTypes}`);
|
||||
console.error(
|
||||
`possible scopes: ${result.allowedScopes} (if unsure use "core")`
|
||||
);
|
||||
console.error(
|
||||
'\nEXAMPLE: \n' +
|
||||
'feat(nx): add an option to generate lazy-loadable modules\n' +
|
||||
'fix(core)!: breaking change should have exclamation mark'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user