chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:36 +08:00
commit 8e2a6eb840
10194 changed files with 1593658 additions and 0 deletions
+11
View File
@@ -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');
}
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
rm -rf build
nx run-many --target=build --parallel
+126
View File
@@ -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;
}
}
+99
View File
@@ -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);
}
+51
View File
@@ -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);
}
+6
View File
@@ -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);
+3
View File
@@ -0,0 +1,3 @@
const fs = require('fs');
fs.chmodSync(process.argv[2], 0o777);
+52
View File
@@ -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();
+109
View File
@@ -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);
}
+81
View File
@@ -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",
},
],
};
+109
View File
@@ -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`);
}
}
}
+150
View File
@@ -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();
+88
View File
@@ -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');
}
}
+44
View File
@@ -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;
}
+27
View File
@@ -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);
});
+41
View File
@@ -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
}
+28
View File
@@ -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);
}
}
+536
View File
@@ -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!');
+210
View File
@@ -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;
}
});
}
+71
View File
@@ -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;
}
+130
View File
@@ -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(/&quot;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&#39;/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;
}
+310
View File
@@ -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));
}
+298
View File
@@ -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();
+69
View File
@@ -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));
+82
View File
@@ -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();
+110
View File
@@ -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})`;
}
+83
View File
@@ -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,
};
}
}
+16
View File
@@ -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'>;
+129
View File
@@ -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
);
}
+48
View File
@@ -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);
+15
View File
@@ -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;
+65
View File
@@ -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);
+48
View File
@@ -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);
+58
View File
@@ -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();
+127
View File
@@ -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);
}
+9
View File
@@ -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')
);
}
+557
View File
@@ -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)
);
}
}
+245
View File
@@ -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}`);
}
}
};
+108
View File
@@ -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);
}
+235
View File
@@ -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\`.
`;
}
+39
View File
@@ -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>
+10
View File
@@ -0,0 +1,10 @@
<div style="text-align: center;">
[![License](https://img.shields.io/npm/l/@nx/workspace.svg?style=flat-square)]()
[![NPM Version](https://badge.fury.io/js/nx.svg)](https://www.npmjs.com/package/nx)
[![Semantic Release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg?style=flat-square)]()
[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/)
[![Join us on the Official Nx Discord Server](https://img.shields.io/discord/1143497901675401286?label=discord)](https://go.nx.dev/community)
[![Nx Sandboxing](https://staging.nx.app/workspaces/62d013ea0852fe0a2df74438/sandbox-badge.svg)](https://nx.dev/docs/features/ci-features/sandboxing)
</div>
+9
View File
@@ -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>
+48
View File
@@ -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}`);
}
+18
View File
@@ -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)
);
+11
View File
@@ -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' });
}
+18
View File
@@ -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();
+4
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "../scripts/tools-out",
"types": ["node"]
},
"include": ["**/*.ts"]
}
+25
View File
@@ -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"
}
]
}
+365
View File
@@ -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
);
},
};
});
}
};
+10
View File
@@ -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));
+13
View File
@@ -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
+45
View File
@@ -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);
}