chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
type ExecutorContext,
|
||||
logger,
|
||||
parseTargetString,
|
||||
type ProjectGraph,
|
||||
readJsonFile,
|
||||
workspaceRoot,
|
||||
} from '@nx/devkit';
|
||||
import { interpolate } from 'nx/src/tasks-runner/utils';
|
||||
import { type CopyWorkspaceModulesOptions } from './schema';
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, join, relative, sep } from 'path';
|
||||
import { lstatSync } from 'fs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
import { getWorkspacePackagesFromGraph } from 'nx/src/plugins/js/utils/get-workspace-packages-from-graph';
|
||||
import { stripGlobToBaseDir } from '../../utils/strip-glob-to-base-dir';
|
||||
|
||||
export default async function copyWorkspaceModules(
|
||||
schema: CopyWorkspaceModulesOptions,
|
||||
context: ExecutorContext
|
||||
) {
|
||||
logger.log('Copying Workspace Modules to Build Directory...');
|
||||
const outputDirectory = getOutputDir(schema, context);
|
||||
const packageJson = getPackageJson(schema, context);
|
||||
createWorkspaceModules(outputDirectory);
|
||||
handleWorkspaceModules(outputDirectory, packageJson, context.projectGraph);
|
||||
logger.log('Success!');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function handleWorkspaceModules(
|
||||
outputDirectory: string,
|
||||
packageJson: {
|
||||
dependencies?: Record<string, string>;
|
||||
},
|
||||
projectGraph: ProjectGraph
|
||||
) {
|
||||
if (!packageJson.dependencies) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceModules = getWorkspacePackagesFromGraph(projectGraph);
|
||||
const processedModules = new Set<string>();
|
||||
const workspaceModulesDir = join(outputDirectory, 'workspace_modules');
|
||||
|
||||
function calculateRelativePath(
|
||||
fromPkgName: string,
|
||||
toPkgName: string
|
||||
): string {
|
||||
const fromPath = join(workspaceModulesDir, fromPkgName);
|
||||
const toPath = join(workspaceModulesDir, toPkgName);
|
||||
const relativePath = relative(fromPath, toPath);
|
||||
|
||||
// Ensure forward slashes for file: protocol (Windows compatibility)
|
||||
return relativePath.split(sep).join('/');
|
||||
}
|
||||
|
||||
function processModule(pkgName: string): void {
|
||||
if (processedModules.has(pkgName)) {
|
||||
logger.verbose(`Skipping ${pkgName} (already processed).`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workspaceModules.has(pkgName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
processedModules.add(pkgName);
|
||||
|
||||
logger.verbose(`Copying ${pkgName}.`);
|
||||
|
||||
const workspaceModuleProject = workspaceModules.get(pkgName);
|
||||
const workspaceModuleRoot = workspaceModuleProject.data.root;
|
||||
const newWorkspaceModulePath = join(workspaceModulesDir, pkgName);
|
||||
|
||||
// Copy the module
|
||||
mkdirSync(newWorkspaceModulePath, { recursive: true });
|
||||
cpSync(workspaceModuleRoot, newWorkspaceModulePath, {
|
||||
filter: (src) => !src.includes('node_modules'),
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
logger.verbose(`Copied ${pkgName} successfully.`);
|
||||
|
||||
// Read the copied module's package.json to process its dependencies
|
||||
const copiedPackageJsonPath = join(newWorkspaceModulePath, 'package.json');
|
||||
let copiedPackageJson: { dependencies?: Record<string, string> };
|
||||
try {
|
||||
copiedPackageJson = JSON.parse(
|
||||
readFileSync(copiedPackageJsonPath, 'utf-8')
|
||||
);
|
||||
} catch (e) {
|
||||
logger.warn(`Could not read package.json for ${pkgName}: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process and update dependencies
|
||||
if (copiedPackageJson.dependencies) {
|
||||
let packageJsonModified = false;
|
||||
|
||||
for (const [depName, depVersion] of Object.entries(
|
||||
copiedPackageJson.dependencies
|
||||
)) {
|
||||
if (workspaceModules.has(depName)) {
|
||||
const relativePath = calculateRelativePath(pkgName, depName);
|
||||
copiedPackageJson.dependencies[depName] = `file:${relativePath}`;
|
||||
packageJsonModified = true;
|
||||
processModule(depName);
|
||||
}
|
||||
}
|
||||
|
||||
if (packageJsonModified) {
|
||||
writeFileSync(
|
||||
copiedPackageJsonPath,
|
||||
JSON.stringify(copiedPackageJson, null, 2)
|
||||
);
|
||||
logger.verbose(
|
||||
`Updated package.json for ${pkgName} with relative workspace module paths.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process all top-level dependencies
|
||||
for (const [pkgName] of Object.entries(packageJson.dependencies)) {
|
||||
processModule(pkgName);
|
||||
}
|
||||
}
|
||||
|
||||
function createWorkspaceModules(outputDirectory: string) {
|
||||
mkdirSync(join(outputDirectory, 'workspace_modules'), { recursive: true });
|
||||
}
|
||||
|
||||
function getPackageJson(
|
||||
schema: CopyWorkspaceModulesOptions,
|
||||
context: ExecutorContext
|
||||
) {
|
||||
const target = parseTargetString(schema.buildTarget, context);
|
||||
const project = context.projectGraph.nodes[target.project].data;
|
||||
const packageJsonPath = join(workspaceRoot, project.root, 'package.json');
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
throw new Error(`${packageJsonPath} does not exist.`);
|
||||
}
|
||||
|
||||
const packageJson = readJsonFile(packageJsonPath);
|
||||
return packageJson;
|
||||
}
|
||||
|
||||
function getOutputDir(
|
||||
schema: CopyWorkspaceModulesOptions,
|
||||
context: ExecutorContext
|
||||
) {
|
||||
let outputDir = schema.outputPath;
|
||||
if (outputDir) {
|
||||
outputDir = normalizeOutputPath(outputDir);
|
||||
if (existsSync(outputDir)) {
|
||||
return outputDir;
|
||||
}
|
||||
}
|
||||
const target = parseTargetString(schema.buildTarget, context);
|
||||
const project = context.projectGraph.nodes[target.project].data;
|
||||
const buildTarget = project.targets[target.target];
|
||||
let maybeOutputPath =
|
||||
buildTarget.outputs?.[0] ??
|
||||
buildTarget.options.outputPath ??
|
||||
buildTarget.options.outputDir;
|
||||
|
||||
if (!maybeOutputPath) {
|
||||
throw new Error(
|
||||
`Could not infer an output directory from the '${schema.buildTarget}' target. Please provide 'outputPath'.`
|
||||
);
|
||||
}
|
||||
|
||||
maybeOutputPath = interpolate(maybeOutputPath, {
|
||||
workspaceRoot,
|
||||
projectRoot: project.root,
|
||||
projectName: project.name,
|
||||
options: {
|
||||
...(buildTarget.options ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
outputDir = normalizeOutputPath(maybeOutputPath);
|
||||
if (!existsSync(outputDir)) {
|
||||
throw new Error(
|
||||
`The output directory '${outputDir}' inferred from the '${schema.buildTarget}' target does not exist.\nPlease ensure a build has run first, and that the path is correct. Otherwise, please provide 'outputPath'.`
|
||||
);
|
||||
}
|
||||
return outputDir;
|
||||
}
|
||||
|
||||
function normalizeOutputPath(outputPath: string) {
|
||||
outputPath = stripGlobToBaseDir(outputPath);
|
||||
if (!outputPath.startsWith(workspaceRoot)) {
|
||||
outputPath = join(workspaceRoot, outputPath);
|
||||
}
|
||||
if (!lstatSync(outputPath).isDirectory()) {
|
||||
outputPath = dirname(outputPath);
|
||||
}
|
||||
return outputPath;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface CopyWorkspaceModulesOptions {
|
||||
buildTarget: string;
|
||||
outputPath?: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": 2,
|
||||
"outputCapture": "direct-nodejs",
|
||||
"title": "Copy Workspace Modules",
|
||||
"description": "Copies Workspace Modules into the output directory after a build to prepare it for use with Docker or alternatives.",
|
||||
"cli": "nx",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"buildTarget": {
|
||||
"type": "string",
|
||||
"description": "The build target that produces the output directory to transform.",
|
||||
"default": "build"
|
||||
},
|
||||
"outputPath": {
|
||||
"type": "string",
|
||||
"description": "The output path to transform. Usually inferred from the outputs of the buildTarget."
|
||||
}
|
||||
},
|
||||
"required": ["buildTarget"]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export function createCoalescingDebounce<T>(
|
||||
fn: () => Promise<T>,
|
||||
wait: number
|
||||
): { trigger: () => Promise<T>; cancel: () => void } {
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
let activePromise: Promise<T> | null = null;
|
||||
let nextPromiseResolvers: Array<{
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: any) => void;
|
||||
}> = [];
|
||||
|
||||
return {
|
||||
trigger: () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
|
||||
if (activePromise) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
nextPromiseResolvers.push({ resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
nextPromiseResolvers.push({ resolve, reject });
|
||||
|
||||
timeoutId = setTimeout(async () => {
|
||||
activePromise = fn();
|
||||
try {
|
||||
const result = await activePromise;
|
||||
for (const { resolve } of nextPromiseResolvers) {
|
||||
resolve(result);
|
||||
}
|
||||
} catch (error) {
|
||||
for (const { reject } of nextPromiseResolvers) {
|
||||
reject(error);
|
||||
}
|
||||
} finally {
|
||||
activePromise = null;
|
||||
nextPromiseResolvers = [];
|
||||
}
|
||||
}, wait);
|
||||
});
|
||||
},
|
||||
cancel: () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
for (const { reject } of nextPromiseResolvers) {
|
||||
reject(new Error('Cancelled'));
|
||||
}
|
||||
nextPromiseResolvers = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { detectModuleFormat } from './detect-module-format';
|
||||
import { readTsConfig } from '../../../utils/typescript/ts-config';
|
||||
import * as ts from 'typescript';
|
||||
import { TempFs } from '@nx/devkit/internal-testing-utils';
|
||||
import { join } from 'path';
|
||||
|
||||
jest.mock('../../../utils/typescript/ts-config');
|
||||
|
||||
const mockReadTsConfig = readTsConfig as jest.MockedFunction<
|
||||
typeof readTsConfig
|
||||
>;
|
||||
|
||||
describe('detectModuleFormat', () => {
|
||||
let tempFs: TempFs;
|
||||
let workspaceRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
tempFs = new TempFs('detect-module-format');
|
||||
workspaceRoot = tempFs.tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
tempFs.cleanup();
|
||||
});
|
||||
|
||||
describe('build executor format option detection', () => {
|
||||
it('should detect ESM from build options with ESM format', () => {
|
||||
const result = detectModuleFormat({
|
||||
projectRoot: 'apps/test',
|
||||
workspaceRoot,
|
||||
main: 'main.js',
|
||||
buildOptions: { format: ['esm'] },
|
||||
});
|
||||
|
||||
expect(result).toBe('esm');
|
||||
});
|
||||
|
||||
it('should detect CJS from build options with CJS format', () => {
|
||||
const result = detectModuleFormat({
|
||||
projectRoot: 'apps/test',
|
||||
workspaceRoot,
|
||||
main: 'main.js',
|
||||
buildOptions: { format: ['cjs'] },
|
||||
});
|
||||
|
||||
expect(result).toBe('cjs');
|
||||
});
|
||||
|
||||
it('should prefer ESM when build options contain both formats', () => {
|
||||
const result = detectModuleFormat({
|
||||
projectRoot: 'apps/test',
|
||||
workspaceRoot,
|
||||
main: 'main.js',
|
||||
buildOptions: { format: ['cjs', 'esm'] },
|
||||
});
|
||||
|
||||
expect(result).toBe('esm');
|
||||
});
|
||||
|
||||
it('should handle single format string (not array)', () => {
|
||||
const result = detectModuleFormat({
|
||||
projectRoot: 'apps/test',
|
||||
workspaceRoot,
|
||||
main: 'main.js',
|
||||
buildOptions: { format: 'esm' },
|
||||
});
|
||||
|
||||
expect(result).toBe('esm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file extension detection', () => {
|
||||
it('should detect ESM from .mjs extension', () => {
|
||||
const result = detectModuleFormat({
|
||||
projectRoot: 'apps/test',
|
||||
workspaceRoot,
|
||||
main: 'main.mjs',
|
||||
});
|
||||
|
||||
expect(result).toBe('esm');
|
||||
});
|
||||
|
||||
it('should detect CJS from .cjs extension', () => {
|
||||
const result = detectModuleFormat({
|
||||
projectRoot: 'apps/test',
|
||||
workspaceRoot,
|
||||
main: 'main.cjs',
|
||||
});
|
||||
|
||||
expect(result).toBe('cjs');
|
||||
});
|
||||
|
||||
it('should not override build options with file extension', () => {
|
||||
const result = detectModuleFormat({
|
||||
projectRoot: 'apps/test',
|
||||
workspaceRoot,
|
||||
main: 'main.cjs', // CJS extension
|
||||
buildOptions: { format: ['esm'] }, // But build options say ESM
|
||||
});
|
||||
|
||||
expect(result).toBe('esm'); // Build options should win
|
||||
});
|
||||
});
|
||||
|
||||
describe('package.json type field detection', () => {
|
||||
it('should detect ESM from package.json type: "module"', () => {
|
||||
tempFs.createFilesSync({
|
||||
'apps/test/package.json': JSON.stringify({ type: 'module' }),
|
||||
});
|
||||
|
||||
const result = detectModuleFormat({
|
||||
projectRoot: 'apps/test',
|
||||
workspaceRoot,
|
||||
main: 'main.js',
|
||||
});
|
||||
|
||||
expect(result).toBe('esm');
|
||||
});
|
||||
|
||||
it('should detect CJS from package.json type: "commonjs"', () => {
|
||||
tempFs.createFilesSync({
|
||||
'apps/test/package.json': JSON.stringify({ type: 'commonjs' }),
|
||||
});
|
||||
|
||||
const result = detectModuleFormat({
|
||||
projectRoot: 'apps/test',
|
||||
workspaceRoot,
|
||||
main: 'main.js',
|
||||
});
|
||||
|
||||
expect(result).toBe('cjs');
|
||||
});
|
||||
|
||||
it('should handle missing package.json gracefully', () => {
|
||||
// Don't create any files - package.json doesn't exist
|
||||
|
||||
const result = detectModuleFormat({
|
||||
projectRoot: 'apps/test',
|
||||
workspaceRoot,
|
||||
main: 'main.js',
|
||||
});
|
||||
|
||||
expect(result).toBe('cjs'); // Should fallback to CJS
|
||||
});
|
||||
|
||||
it('should handle package.json read errors gracefully', () => {
|
||||
tempFs.createFilesSync({
|
||||
'apps/test/package.json': 'invalid json {', // Invalid JSON to trigger parse error
|
||||
});
|
||||
|
||||
const result = detectModuleFormat({
|
||||
projectRoot: 'apps/test',
|
||||
workspaceRoot,
|
||||
main: 'main.js',
|
||||
});
|
||||
|
||||
expect(result).toBe('cjs'); // Should fallback to CJS
|
||||
});
|
||||
});
|
||||
|
||||
describe('TypeScript configuration detection', () => {
|
||||
it('should detect ESM from TypeScript ES2022 module', () => {
|
||||
tempFs.createFilesSync({
|
||||
'apps/test/tsconfig.json': '{}',
|
||||
});
|
||||
|
||||
mockReadTsConfig.mockReturnValue({
|
||||
options: { module: ts.ModuleKind.ES2022 },
|
||||
} as any);
|
||||
|
||||
const result = detectModuleFormat({
|
||||
projectRoot: 'apps/test',
|
||||
workspaceRoot,
|
||||
main: 'main.js',
|
||||
tsConfig: join(workspaceRoot, 'apps/test/tsconfig.json'),
|
||||
});
|
||||
|
||||
expect(result).toBe('esm');
|
||||
});
|
||||
|
||||
it('should detect ESM from TypeScript ESNext module', () => {
|
||||
tempFs.createFilesSync({
|
||||
'apps/test/tsconfig.json': '{}',
|
||||
});
|
||||
|
||||
mockReadTsConfig.mockReturnValue({
|
||||
options: { module: ts.ModuleKind.ESNext },
|
||||
} as any);
|
||||
|
||||
const result = detectModuleFormat({
|
||||
projectRoot: 'apps/test',
|
||||
workspaceRoot,
|
||||
main: 'main.js',
|
||||
tsConfig: join(workspaceRoot, 'apps/test/tsconfig.json'),
|
||||
});
|
||||
|
||||
expect(result).toBe('esm');
|
||||
});
|
||||
|
||||
it('should default to CJS for NodeNext module', () => {
|
||||
tempFs.createFilesSync({
|
||||
'apps/test/tsconfig.json': '{}',
|
||||
});
|
||||
|
||||
mockReadTsConfig.mockReturnValue({
|
||||
options: { module: ts.ModuleKind.NodeNext },
|
||||
} as any);
|
||||
|
||||
const result = detectModuleFormat({
|
||||
projectRoot: 'apps/test',
|
||||
workspaceRoot,
|
||||
main: 'main.js',
|
||||
tsConfig: join(workspaceRoot, 'apps/test/tsconfig.json'),
|
||||
});
|
||||
|
||||
expect(result).toBe('cjs'); // NodeNext defaults to CJS when no package.json type
|
||||
});
|
||||
|
||||
it('should handle missing TypeScript config gracefully', () => {
|
||||
// Don't create tsconfig.json file
|
||||
|
||||
const result = detectModuleFormat({
|
||||
projectRoot: 'apps/test',
|
||||
workspaceRoot,
|
||||
main: 'main.js',
|
||||
tsConfig: join(workspaceRoot, 'apps/test/tsconfig.json'),
|
||||
});
|
||||
|
||||
expect(result).toBe('cjs'); // Should fallback to CJS
|
||||
});
|
||||
});
|
||||
|
||||
describe('default behavior', () => {
|
||||
it('should default to CJS when no detection method succeeds', () => {
|
||||
const result = detectModuleFormat({
|
||||
projectRoot: 'apps/test',
|
||||
workspaceRoot,
|
||||
main: 'main.js',
|
||||
});
|
||||
|
||||
expect(result).toBe('cjs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detection priority', () => {
|
||||
it('should prioritize build options over all other methods', () => {
|
||||
tempFs.createFilesSync({
|
||||
'apps/test/package.json': JSON.stringify({ type: 'module' }), // package.json says ESM
|
||||
'apps/test/tsconfig.json': '{}',
|
||||
});
|
||||
|
||||
mockReadTsConfig.mockReturnValue({
|
||||
options: { module: ts.ModuleKind.ES2022 },
|
||||
} as any); // TypeScript says ESM
|
||||
|
||||
const result = detectModuleFormat({
|
||||
projectRoot: 'apps/test',
|
||||
workspaceRoot,
|
||||
main: 'main.mjs', // File extension says ESM
|
||||
tsConfig: join(workspaceRoot, 'apps/test/tsconfig.json'),
|
||||
buildOptions: { format: ['cjs'] }, // But build options say CJS
|
||||
});
|
||||
|
||||
expect(result).toBe('cjs'); // Build options should win
|
||||
});
|
||||
|
||||
it('should prioritize file extension over package.json and TypeScript', () => {
|
||||
tempFs.createFilesSync({
|
||||
'apps/test/package.json': JSON.stringify({ type: 'commonjs' }), // package.json says CJS
|
||||
'apps/test/tsconfig.json': '{}',
|
||||
});
|
||||
|
||||
mockReadTsConfig.mockReturnValue({
|
||||
options: { module: ts.ModuleKind.CommonJS },
|
||||
} as any); // TypeScript says CJS
|
||||
|
||||
const result = detectModuleFormat({
|
||||
projectRoot: 'apps/test',
|
||||
workspaceRoot,
|
||||
main: 'main.mjs', // But file extension says ESM
|
||||
tsConfig: join(workspaceRoot, 'apps/test/tsconfig.json'),
|
||||
});
|
||||
|
||||
expect(result).toBe('esm'); // File extension should win
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { readJsonFile } from '@nx/devkit';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { readTsConfig } from '../../../utils/typescript/ts-config';
|
||||
import {
|
||||
getPackageJsonModuleFormat,
|
||||
getTsConfigModuleFormat,
|
||||
type ModuleFormat,
|
||||
} from '../../../utils/module-format/module-format';
|
||||
|
||||
export type { ModuleFormat };
|
||||
|
||||
export interface ModuleFormatDetectionOptions {
|
||||
projectRoot: string;
|
||||
workspaceRoot: string;
|
||||
tsConfig?: string;
|
||||
main: string;
|
||||
buildOptions?: any;
|
||||
}
|
||||
|
||||
export function detectModuleFormat(
|
||||
options: ModuleFormatDetectionOptions
|
||||
): ModuleFormat {
|
||||
if (options.buildOptions?.format) {
|
||||
const formats = Array.isArray(options.buildOptions.format)
|
||||
? options.buildOptions.format
|
||||
: [options.buildOptions.format];
|
||||
|
||||
if (formats.includes('esm')) {
|
||||
return 'esm';
|
||||
}
|
||||
|
||||
if (formats.includes('cjs')) {
|
||||
return 'cjs';
|
||||
}
|
||||
}
|
||||
|
||||
if (options.main.endsWith('.mjs')) {
|
||||
return 'esm';
|
||||
}
|
||||
if (options.main.endsWith('.cjs')) {
|
||||
return 'cjs';
|
||||
}
|
||||
|
||||
const packageJsonPath = join(
|
||||
options.workspaceRoot,
|
||||
options.projectRoot,
|
||||
'package.json'
|
||||
);
|
||||
if (existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const fmt = getPackageJsonModuleFormat(readJsonFile(packageJsonPath));
|
||||
if (fmt) return fmt;
|
||||
} catch {
|
||||
// Continue to next detection method
|
||||
}
|
||||
}
|
||||
|
||||
if (options.tsConfig && existsSync(options.tsConfig)) {
|
||||
try {
|
||||
const fmt = getTsConfigModuleFormat(
|
||||
readTsConfig(options.tsConfig).options
|
||||
);
|
||||
if (fmt) return fmt;
|
||||
} catch {
|
||||
// Continue to default
|
||||
}
|
||||
}
|
||||
|
||||
return 'cjs';
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { resolve } from './esm-loader';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { TempFs } from '@nx/devkit/internal-testing-utils';
|
||||
import { setupWorkspaceContext } from 'nx/src/utils/workspace-context';
|
||||
|
||||
describe('ESM Loader', () => {
|
||||
let tempFs: TempFs;
|
||||
let cwd: string;
|
||||
const mockContext = {};
|
||||
const mockNextResolve = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = process.cwd();
|
||||
tempFs = new TempFs('esm-loader');
|
||||
process.chdir(tempFs.tempDir);
|
||||
setupWorkspaceContext(tempFs.tempDir);
|
||||
jest.clearAllMocks();
|
||||
delete process.env.NX_MAPPINGS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
tempFs.cleanup();
|
||||
process.chdir(cwd);
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
describe('resolve', () => {
|
||||
it('should resolve workspace library mappings', async () => {
|
||||
const distPath = `${tempFs.tempDir}/dist/libs/lib1`;
|
||||
await tempFs.createFiles({
|
||||
'dist/libs/lib1/index.js': 'export default {};',
|
||||
'dist/libs/lib2/index.js': 'export default {};',
|
||||
});
|
||||
|
||||
process.env.NX_MAPPINGS = JSON.stringify({
|
||||
'@myorg/lib1': distPath,
|
||||
'@myorg/lib2': `${tempFs.tempDir}/dist/libs/lib2`,
|
||||
});
|
||||
|
||||
await resolve('@myorg/lib1', mockContext, mockNextResolve);
|
||||
|
||||
expect(mockNextResolve).toHaveBeenCalledWith(
|
||||
pathToFileURL(`${distPath}/index.js`).href,
|
||||
mockContext
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve workspace library subpaths', async () => {
|
||||
const distPath = `${tempFs.tempDir}/dist/libs/lib1`;
|
||||
await tempFs.createFiles({
|
||||
'dist/libs/lib1/utils/helper.js': 'export default {};',
|
||||
});
|
||||
|
||||
process.env.NX_MAPPINGS = JSON.stringify({
|
||||
'@myorg/lib1': distPath,
|
||||
});
|
||||
|
||||
await resolve('@myorg/lib1/utils/helper', mockContext, mockNextResolve);
|
||||
|
||||
expect(mockNextResolve).toHaveBeenCalledWith(
|
||||
pathToFileURL(`${distPath}/utils/helper.js`).href,
|
||||
mockContext
|
||||
);
|
||||
});
|
||||
|
||||
it('should try index.js when directory exists', async () => {
|
||||
const distPath = `${tempFs.tempDir}/dist/libs/lib1`;
|
||||
await tempFs.createFiles({
|
||||
'dist/libs/lib1/utils/index.js': 'export default {};',
|
||||
});
|
||||
|
||||
process.env.NX_MAPPINGS = JSON.stringify({
|
||||
'@myorg/lib1': distPath,
|
||||
});
|
||||
|
||||
await resolve('@myorg/lib1/utils', mockContext, mockNextResolve);
|
||||
|
||||
expect(mockNextResolve).toHaveBeenCalledWith(
|
||||
pathToFileURL(`${distPath}/utils/index.js`).href,
|
||||
mockContext
|
||||
);
|
||||
});
|
||||
|
||||
it('should try .js extension when file has no extension', async () => {
|
||||
const distPath = `${tempFs.tempDir}/dist/libs/lib1`;
|
||||
await tempFs.createFiles({
|
||||
'dist/libs/lib1/helper.js': 'export default {};',
|
||||
});
|
||||
|
||||
process.env.NX_MAPPINGS = JSON.stringify({
|
||||
'@myorg/lib1': distPath,
|
||||
});
|
||||
|
||||
await resolve('@myorg/lib1/helper', mockContext, mockNextResolve);
|
||||
|
||||
expect(mockNextResolve).toHaveBeenCalledWith(
|
||||
pathToFileURL(`${distPath}/helper.js`).href,
|
||||
mockContext
|
||||
);
|
||||
});
|
||||
|
||||
it('should try .mjs extension when other resolutions fail', async () => {
|
||||
const distPath = `${tempFs.tempDir}/dist/libs/lib1`;
|
||||
await tempFs.createFiles({
|
||||
'dist/libs/lib1/helper.mjs': 'export default {};',
|
||||
});
|
||||
|
||||
process.env.NX_MAPPINGS = JSON.stringify({
|
||||
'@myorg/lib1': distPath,
|
||||
});
|
||||
|
||||
await resolve('@myorg/lib1/helper', mockContext, mockNextResolve);
|
||||
|
||||
expect(mockNextResolve).toHaveBeenCalledWith(
|
||||
pathToFileURL(`${distPath}/helper.mjs`).href,
|
||||
mockContext
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to default resolution when mapping not found', async () => {
|
||||
const distPath = `${tempFs.tempDir}/dist/libs/lib1`;
|
||||
|
||||
process.env.NX_MAPPINGS = JSON.stringify({
|
||||
'@myorg/lib1': distPath,
|
||||
});
|
||||
|
||||
await resolve('express', mockContext, mockNextResolve);
|
||||
|
||||
expect(mockNextResolve).toHaveBeenCalledWith('express', mockContext);
|
||||
});
|
||||
|
||||
it('should fall back to default resolution when no file found', async () => {
|
||||
const distPath = `${tempFs.tempDir}/dist/libs/lib1`;
|
||||
|
||||
process.env.NX_MAPPINGS = JSON.stringify({
|
||||
'@myorg/lib1': distPath,
|
||||
});
|
||||
|
||||
await resolve('@myorg/lib1/nonexistent', mockContext, mockNextResolve);
|
||||
|
||||
expect(mockNextResolve).toHaveBeenCalledWith(
|
||||
'@myorg/lib1/nonexistent',
|
||||
mockContext
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty mappings', async () => {
|
||||
process.env.NX_MAPPINGS = '{}';
|
||||
|
||||
await resolve('@myorg/lib1', mockContext, mockNextResolve);
|
||||
|
||||
expect(mockNextResolve).toHaveBeenCalledWith('@myorg/lib1', mockContext);
|
||||
});
|
||||
|
||||
it('should handle missing NX_MAPPINGS env var', async () => {
|
||||
await resolve('@myorg/lib1', mockContext, mockNextResolve);
|
||||
|
||||
expect(mockNextResolve).toHaveBeenCalledWith('@myorg/lib1', mockContext);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Custom ESM resolver for Node.js that handles Nx workspace library mappings.
|
||||
*
|
||||
* This resolver is necessary because:
|
||||
* 1. Node.js ESM resolution doesn't understand TypeScript path mappings (e.g., @myorg/mylib)
|
||||
* 2. Nx workspace libraries need to be resolved to their actual built output locations
|
||||
* 3. The built output might be in different formats (.js, .mjs) or locations (index.js)
|
||||
*
|
||||
* The resolver intercepts import requests for workspace libraries and maps them to their
|
||||
* actual file system locations based on the NX_MAPPINGS environment variable set by
|
||||
* the Node executor.
|
||||
*/
|
||||
export async function resolve(
|
||||
specifier: string,
|
||||
context: any,
|
||||
nextResolve: any
|
||||
) {
|
||||
// Parse mappings on each call to ensure we get the latest values
|
||||
const mappings = JSON.parse(process.env.NX_MAPPINGS || '{}');
|
||||
const mappingKeys = Object.keys(mappings);
|
||||
|
||||
// Check if this is a workspace library mapping
|
||||
const matchingKey = mappingKeys.find(
|
||||
(key) => specifier === key || specifier.startsWith(key + '/')
|
||||
);
|
||||
|
||||
if (matchingKey) {
|
||||
const mappedPath = mappings[matchingKey];
|
||||
const restOfPath = specifier.slice(matchingKey.length);
|
||||
const fullPath = join(mappedPath, restOfPath);
|
||||
|
||||
// Try to resolve the mapped path as a file first
|
||||
if (existsSync(fullPath)) {
|
||||
const stats = statSync(fullPath);
|
||||
if (stats.isFile()) {
|
||||
return nextResolve(pathToFileURL(fullPath).href, context);
|
||||
}
|
||||
}
|
||||
|
||||
// Try with index.js
|
||||
const indexPath = join(fullPath, 'index.js');
|
||||
if (existsSync(indexPath)) {
|
||||
return nextResolve(pathToFileURL(indexPath).href, context);
|
||||
}
|
||||
|
||||
const jsPath = fullPath + '.js';
|
||||
if (existsSync(jsPath)) {
|
||||
return nextResolve(pathToFileURL(jsPath).href, context);
|
||||
}
|
||||
|
||||
const mjsPath = fullPath + '.mjs';
|
||||
if (existsSync(mjsPath)) {
|
||||
return nextResolve(pathToFileURL(mjsPath).href, context);
|
||||
}
|
||||
}
|
||||
|
||||
return nextResolve(specifier, context);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export class LineAwareWriter {
|
||||
private buffer = '';
|
||||
private activeTaskId: string | null = null;
|
||||
|
||||
get currentProcessId(): string | null {
|
||||
return this.activeTaskId;
|
||||
}
|
||||
|
||||
write(data: Buffer | string, taskId: string): void {
|
||||
if (taskId !== this.activeTaskId) return;
|
||||
|
||||
const text = data.toString();
|
||||
this.buffer += text;
|
||||
|
||||
const lines = this.buffer.split('\n');
|
||||
this.buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
process.stdout.write(line + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
flush(): void {
|
||||
if (this.buffer) {
|
||||
process.stdout.write(this.buffer + '\n');
|
||||
this.buffer = '';
|
||||
}
|
||||
}
|
||||
|
||||
setActiveProcess(taskId: string | null): void {
|
||||
this.flush();
|
||||
this.activeTaskId = taskId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { getOutputFileName } from './output-file';
|
||||
|
||||
describe('getOutputFileName', () => {
|
||||
it('does not double-prefix outputPath when main is already inside the build output', () => {
|
||||
expect(
|
||||
getOutputFileName({
|
||||
buildTargetExecutor: '@nx/js:tsc',
|
||||
main: 'packages/failing-project/dist/main.js',
|
||||
outputPath: 'packages/failing-project/dist',
|
||||
rootDir: 'packages/failing-project',
|
||||
})
|
||||
).toBe('main.js');
|
||||
});
|
||||
|
||||
it('keeps project-root-relative subdirectories for source entries', () => {
|
||||
expect(
|
||||
getOutputFileName({
|
||||
buildTargetExecutor: '@nx/js:tsc',
|
||||
main: 'packages/failing-project/src/main.ts',
|
||||
outputPath: 'dist/packages/failing-project',
|
||||
rootDir: 'packages/failing-project',
|
||||
})
|
||||
).toBe('src/main.js');
|
||||
});
|
||||
|
||||
it('preserves nested subdirectories that already live under outputPath', () => {
|
||||
expect(
|
||||
getOutputFileName({
|
||||
buildTargetExecutor: '@nx/js:swc',
|
||||
main: 'packages/failing-project/dist/server/main.js',
|
||||
outputPath: 'packages/failing-project/dist',
|
||||
rootDir: 'packages/failing-project',
|
||||
})
|
||||
).toBe('server/main.js');
|
||||
});
|
||||
|
||||
it('resolves main relative to rootDir when tsc strips the rootDir prefix from the output', () => {
|
||||
expect(
|
||||
getOutputFileName({
|
||||
buildTargetExecutor: '@nx/js:tsc',
|
||||
main: 'apps/project_a/src/main.ts',
|
||||
outputPath: 'dist/apps/project_a',
|
||||
rootDir: 'apps/project_a/src',
|
||||
})
|
||||
).toBe('main.js');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { dirname, parse } from 'path';
|
||||
import { joinPathFragments, normalizePath } from 'nx/src/utils/path';
|
||||
import { getRelativeDirectoryToProjectRoot } from '../../../utils/get-main-file-dir';
|
||||
|
||||
interface OutputFileNameOptions {
|
||||
buildTargetExecutor: string;
|
||||
main: string;
|
||||
outputPath: string;
|
||||
rootDir: string;
|
||||
}
|
||||
|
||||
export function getOutputFileName({
|
||||
buildTargetExecutor,
|
||||
main,
|
||||
outputPath,
|
||||
rootDir,
|
||||
}: OutputFileNameOptions): string {
|
||||
const fileName = `${parse(main).name}.js`;
|
||||
|
||||
if (
|
||||
buildTargetExecutor !== '@nx/js:tsc' &&
|
||||
buildTargetExecutor !== '@nx/js:swc'
|
||||
) {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
const mainDirectory = normalizePath(dirname(main));
|
||||
const normalizedOutputPath = normalizePath(outputPath);
|
||||
const isMainInsideOutputPath =
|
||||
mainDirectory === normalizedOutputPath ||
|
||||
mainDirectory.startsWith(`${normalizedOutputPath}/`);
|
||||
const base = isMainInsideOutputPath ? normalizedOutputPath : rootDir;
|
||||
|
||||
return joinPathFragments(
|
||||
getRelativeDirectoryToProjectRoot(main, base),
|
||||
fileName
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const { register } = require('node:module');
|
||||
const path = require('node:path');
|
||||
|
||||
// Dynamic import helper to prevent TypeScript from transforming it
|
||||
const dynamicImportEsm = new Function('specifier', 'return import(specifier)');
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// Register ESM loader for workspace path mappings
|
||||
register(
|
||||
pathToFileURL(path.join(__dirname, 'lib', 'esm-loader.js')).href,
|
||||
pathToFileURL(__filename)
|
||||
);
|
||||
|
||||
// Import and run the file
|
||||
const fileToRun = process.env.NX_FILE_TO_RUN;
|
||||
if (!fileToRun) {
|
||||
throw new Error('NX_FILE_TO_RUN environment variable not set');
|
||||
}
|
||||
await dynamicImportEsm(pathToFileURL(fileToRun).href);
|
||||
} catch (error) {
|
||||
console.error('ESM loader error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const Module = require('module');
|
||||
const url = require('node:url');
|
||||
const originalLoader = Module._load;
|
||||
|
||||
const dynamicImport = new Function('specifier', 'return import(specifier)');
|
||||
|
||||
const mappings = JSON.parse(process.env.NX_MAPPINGS);
|
||||
const keys = Object.keys(mappings);
|
||||
const fileToRun = url.pathToFileURL(process.env.NX_FILE_TO_RUN);
|
||||
|
||||
Module._load = function (request, parent) {
|
||||
if (!parent) return originalLoader.apply(this, arguments);
|
||||
const match = keys.find((k) => request === k);
|
||||
if (match) {
|
||||
const newArguments = [...arguments];
|
||||
newArguments[0] = mappings[match];
|
||||
return originalLoader.apply(this, newArguments);
|
||||
} else {
|
||||
return originalLoader.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
|
||||
dynamicImport(fileToRun);
|
||||
@@ -0,0 +1,518 @@
|
||||
import { createAsyncIterable } from '@nx/devkit/internal';
|
||||
import chalk from 'chalk';
|
||||
import { ChildProcess, fork } from 'child_process';
|
||||
import {
|
||||
ExecutorContext,
|
||||
isDaemonEnabled,
|
||||
joinPathFragments,
|
||||
logger,
|
||||
parseTargetString,
|
||||
ProjectGraphProjectNode,
|
||||
readTargetOptions,
|
||||
runExecutor,
|
||||
} from '@nx/devkit';
|
||||
import { daemonClient } from 'nx/src/daemon/client/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as path from 'path';
|
||||
import { join } from 'path';
|
||||
|
||||
import { InspectType, NodeExecutorOptions } from './schema';
|
||||
import { calculateProjectBuildableDependencies } from '../../utils/buildable-libs-utils';
|
||||
import { killProcessTreeGraceful } from 'nx/src/native';
|
||||
import { LineAwareWriter } from './lib/line-aware-writer';
|
||||
import { createCoalescingDebounce } from './lib/coalescing-debounce';
|
||||
import { fileExists } from 'nx/src/utils/fileutils';
|
||||
import { interpolate } from 'nx/src/tasks-runner/utils';
|
||||
import { detectModuleFormat } from './lib/detect-module-format';
|
||||
import { getOutputFileName } from './lib/output-file';
|
||||
import { stripGlobToBaseDir } from '../../utils/strip-glob-to-base-dir';
|
||||
|
||||
interface ActiveTask {
|
||||
id: string;
|
||||
killed: boolean;
|
||||
promise: Promise<void>;
|
||||
childProcess: null | ChildProcess;
|
||||
start: () => Promise<void>;
|
||||
stop: (signal: NodeJS.Signals) => Promise<void>;
|
||||
}
|
||||
|
||||
const globalLineAwareWriter = new LineAwareWriter();
|
||||
|
||||
export async function* nodeExecutor(
|
||||
options: NodeExecutorOptions,
|
||||
context: ExecutorContext
|
||||
) {
|
||||
process.env.NODE_ENV ??= context?.configurationName ?? 'development';
|
||||
const project = context.projectGraph.nodes[context.projectName];
|
||||
const buildTarget = parseTargetString(options.buildTarget, context);
|
||||
|
||||
if (!project.data.targets[buildTarget.target]) {
|
||||
throw new Error(
|
||||
`Cannot find build target ${chalk.bold(
|
||||
options.buildTarget
|
||||
)} for project ${chalk.bold(context.projectName)}`
|
||||
);
|
||||
}
|
||||
|
||||
const buildTargetExecutor =
|
||||
project.data.targets[buildTarget.target]?.executor;
|
||||
|
||||
if (buildTargetExecutor === 'nx:run-commands') {
|
||||
// Run commands does not emit build event, so we have to switch to run entire build through Nx CLI.
|
||||
options.runBuildTargetDependencies = true;
|
||||
}
|
||||
|
||||
const buildOptions: Record<string, any> = {
|
||||
...readTargetOptions(buildTarget, context),
|
||||
...options.buildTargetOptions,
|
||||
target: buildTarget.target,
|
||||
};
|
||||
|
||||
if (options.waitUntilTargets && options.waitUntilTargets.length > 0) {
|
||||
const results = await runWaitUntilTargets(options, context);
|
||||
for (const [i, result] of results.entries()) {
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Wait until target failed: ${options.waitUntilTargets[i]}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-map buildable workspace projects to their output directory.
|
||||
const mappings = calculateResolveMappings(context, options);
|
||||
const fileToRun = getFileToRun(
|
||||
context,
|
||||
project,
|
||||
buildOptions,
|
||||
buildTargetExecutor
|
||||
);
|
||||
|
||||
// Detect module format for the project
|
||||
const moduleFormat = detectModuleFormat({
|
||||
projectRoot: project.data.root,
|
||||
workspaceRoot: context.root,
|
||||
tsConfig:
|
||||
buildOptions.tsConfig ||
|
||||
join(context.root, project.data.root, 'tsconfig.json'),
|
||||
main: buildOptions.main || fileToRun,
|
||||
buildOptions,
|
||||
});
|
||||
|
||||
let additionalExitHandler: null | (() => void) = null;
|
||||
let currentTask: ActiveTask = null;
|
||||
const tasks: ActiveTask[] = [];
|
||||
|
||||
yield* createAsyncIterable<{
|
||||
success: boolean;
|
||||
options?: Record<string, any>;
|
||||
}>(async ({ done, next, error, registerCleanup }) => {
|
||||
const processQueue = async () => {
|
||||
if (tasks.length === 0) return;
|
||||
|
||||
const previousTask = currentTask;
|
||||
const task = tasks.shift();
|
||||
|
||||
if (previousTask && !previousTask.killed) {
|
||||
// stop() marks the task killed, detaches listeners, and waits for the
|
||||
// process tree to exit.
|
||||
await previousTask.stop('SIGTERM');
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
currentTask = task;
|
||||
globalLineAwareWriter.setActiveProcess(task.id);
|
||||
await task.start();
|
||||
|
||||
// A file change may have queued another task while we waited for the
|
||||
// previous process to stop. Its debounce trigger piggybacked on this
|
||||
// in-flight run, so drain the queue now or it would sit unprocessed
|
||||
// until the next change.
|
||||
if (tasks.length > 0) {
|
||||
await processQueue();
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedProcessQueue = createCoalescingDebounce(
|
||||
processQueue,
|
||||
options.debounce ?? 1_000
|
||||
);
|
||||
|
||||
const addToQueue = async (
|
||||
childProcess: null | ChildProcess,
|
||||
buildResult: Promise<{ success: boolean }>
|
||||
) => {
|
||||
for (const task of tasks) {
|
||||
if (!task.killed) {
|
||||
await task.stop('SIGTERM');
|
||||
}
|
||||
}
|
||||
tasks.length = 0;
|
||||
|
||||
const task: ActiveTask = {
|
||||
id: randomUUID(),
|
||||
killed: false,
|
||||
childProcess,
|
||||
promise: null,
|
||||
start: async () => {
|
||||
// Wait for build to finish.
|
||||
const result = await buildResult;
|
||||
|
||||
if (result && !result.success) {
|
||||
// If in watch-mode, don't throw or else the process exits.
|
||||
if (options.watch) {
|
||||
if (!task.killed) {
|
||||
// Only log build error if task was not killed by a new change.
|
||||
logger.error(`Build failed, waiting for changes to restart...`);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`Build failed. See above for errors.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (task.killed) return;
|
||||
|
||||
// Run the program
|
||||
task.promise = new Promise<void>((resolve, reject) => {
|
||||
const loaderFile =
|
||||
moduleFormat === 'esm'
|
||||
? 'node-with-esm-loader'
|
||||
: 'node-with-require-overrides';
|
||||
|
||||
task.childProcess = fork(
|
||||
join(__dirname, loaderFile),
|
||||
options.args ?? [],
|
||||
{
|
||||
execArgv: getExecArgv(options),
|
||||
stdio: [0, 'pipe', 'pipe', 'ipc'],
|
||||
env: {
|
||||
...process.env,
|
||||
NX_FILE_TO_RUN: fileToRunCorrectPath(fileToRun),
|
||||
NX_MAPPINGS: JSON.stringify(mappings),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
task.childProcess.stdout?.on('data', (data) => {
|
||||
globalLineAwareWriter.write(data, task.id);
|
||||
});
|
||||
|
||||
const handleStdErr = (data) => {
|
||||
if (!options.watch || !task.killed) {
|
||||
if (task.id === globalLineAwareWriter.currentProcessId) {
|
||||
logger.error(data.toString());
|
||||
}
|
||||
}
|
||||
};
|
||||
task.childProcess.stderr?.on('data', handleStdErr);
|
||||
task.childProcess.once('exit', (code) => {
|
||||
task.childProcess.off('data', handleStdErr);
|
||||
if (options.watch && !task.killed) {
|
||||
logger.info(
|
||||
`NX Process exited with code ${code}, waiting for changes to restart...`
|
||||
);
|
||||
}
|
||||
if (!options.watch) {
|
||||
if (code !== 0) {
|
||||
error(new Error(`Process exited with code ${code}`));
|
||||
} else {
|
||||
resolve(done());
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
|
||||
next({ success: true, options: buildOptions });
|
||||
});
|
||||
},
|
||||
stop: async (signal = 'SIGTERM') => {
|
||||
task.killed = true;
|
||||
|
||||
if (task.childProcess?.pid) {
|
||||
if (task.childProcess.stdout) {
|
||||
task.childProcess.stdout.pause();
|
||||
}
|
||||
if (task.childProcess.stderr) {
|
||||
task.childProcess.stderr.pause();
|
||||
}
|
||||
|
||||
if (task.childProcess.connected) {
|
||||
task.childProcess.disconnect();
|
||||
}
|
||||
|
||||
task.childProcess.removeAllListeners();
|
||||
|
||||
// Wait for the process tree to fully exit so the port is released
|
||||
// before a watch-mode restart boots the next server (EADDRINUSE).
|
||||
// Windows cannot deliver graceful signals through this API, so
|
||||
// skip the grace period and force-kill immediately (matching the
|
||||
// previous taskkill /F behavior).
|
||||
await killProcessTreeGraceful(
|
||||
task.childProcess.pid,
|
||||
signal,
|
||||
process.platform === 'win32' ? 0 : undefined
|
||||
);
|
||||
}
|
||||
|
||||
if (task.id === globalLineAwareWriter.currentProcessId) {
|
||||
globalLineAwareWriter.setActiveProcess(null);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
tasks.push(task);
|
||||
};
|
||||
|
||||
const stopAllTasks = async (signal: NodeJS.Signals = 'SIGTERM') => {
|
||||
debouncedProcessQueue.cancel();
|
||||
|
||||
globalLineAwareWriter.flush();
|
||||
|
||||
if (typeof additionalExitHandler === 'function') {
|
||||
additionalExitHandler();
|
||||
}
|
||||
|
||||
if (typeof currentTask?.stop === 'function') {
|
||||
await currentTask.stop(signal);
|
||||
}
|
||||
|
||||
for (const task of tasks) {
|
||||
await task.stop(signal);
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await stopAllTasks('SIGTERM');
|
||||
process.exit(128 + 15);
|
||||
});
|
||||
process.on('SIGINT', async () => {
|
||||
await stopAllTasks('SIGINT');
|
||||
process.exit(128 + 2);
|
||||
});
|
||||
process.on('SIGHUP', async () => {
|
||||
await stopAllTasks('SIGHUP');
|
||||
process.exit(128 + 1);
|
||||
});
|
||||
|
||||
registerCleanup(async () => {
|
||||
await stopAllTasks('SIGTERM');
|
||||
});
|
||||
|
||||
if (options.runBuildTargetDependencies) {
|
||||
// If a all dependencies need to be rebuild on changes, then register with watcher
|
||||
// and run through CLI, otherwise only the current project will rebuild.
|
||||
const runBuild = async () => {
|
||||
let childProcess: ChildProcess = null;
|
||||
const whenReady = new Promise<{ success: boolean }>(async (resolve) => {
|
||||
childProcess = fork(
|
||||
require.resolve('nx/bin/nx.js'),
|
||||
[
|
||||
'run',
|
||||
`${context.projectName}:${buildTarget.target}${
|
||||
buildTarget.configuration ? `:${buildTarget.configuration}` : ''
|
||||
}`,
|
||||
],
|
||||
{
|
||||
cwd: context.root,
|
||||
stdio: 'inherit',
|
||||
}
|
||||
);
|
||||
childProcess.once('exit', (code) => {
|
||||
if (code === 0) resolve({ success: true });
|
||||
// If process is killed due to current task being killed, then resolve with success.
|
||||
else resolve({ success: !!currentTask?.killed });
|
||||
});
|
||||
});
|
||||
await addToQueue(childProcess, whenReady);
|
||||
await debouncedProcessQueue.trigger();
|
||||
};
|
||||
if (isDaemonEnabled()) {
|
||||
additionalExitHandler = await daemonClient.registerFileWatcher(
|
||||
{
|
||||
watchProjects: [context.projectName],
|
||||
includeDependencies: true,
|
||||
},
|
||||
async (err, data) => {
|
||||
if (err === 'reconnecting') {
|
||||
// Silent - daemon restarts automatically on lockfile changes
|
||||
return;
|
||||
} else if (err === 'reconnected') {
|
||||
// Silent - reconnection succeeded
|
||||
return;
|
||||
} else if (err === 'closed') {
|
||||
logger.error(
|
||||
`Failed to reconnect to daemon after multiple attempts`
|
||||
);
|
||||
process.exit(1);
|
||||
} else if (err) {
|
||||
logger.error(`Watch error: ${err?.message ?? 'Unknown'}`);
|
||||
} else {
|
||||
if (options.watch) {
|
||||
logger.info(`NX File change detected. Restarting...`);
|
||||
await runBuild();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
`NX Daemon is not running. Node process will not restart automatically after file changes.`
|
||||
);
|
||||
}
|
||||
await runBuild(); // run first build
|
||||
} else {
|
||||
// Otherwise, run the build executor, which will not run task dependencies.
|
||||
// This is mostly fine for bundlers like webpack that should already watch for dependency libs.
|
||||
// For tsc/swc or custom build commands, consider using `runBuildTargetDependencies` instead.
|
||||
const output = await runExecutor(
|
||||
buildTarget,
|
||||
{
|
||||
...options.buildTargetOptions,
|
||||
watch: options.watch,
|
||||
},
|
||||
context
|
||||
);
|
||||
while (true) {
|
||||
const event = await output.next();
|
||||
await addToQueue(null, Promise.resolve(event.value));
|
||||
await debouncedProcessQueue.trigger();
|
||||
if (event.done && !options.watch) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getExecArgv(options: NodeExecutorOptions) {
|
||||
const args = (options.runtimeArgs ??= []);
|
||||
args.push('-r', require.resolve('source-map-support/register'));
|
||||
|
||||
if (options.inspect === true) {
|
||||
options.inspect = InspectType.Inspect;
|
||||
}
|
||||
|
||||
if (options.inspect) {
|
||||
args.push(`--${options.inspect}=${options.host}:${options.port}`);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function calculateResolveMappings(
|
||||
context: ExecutorContext,
|
||||
options: NodeExecutorOptions
|
||||
) {
|
||||
const parsed = parseTargetString(options.buildTarget, context);
|
||||
const { dependencies } = calculateProjectBuildableDependencies(
|
||||
context.taskGraph,
|
||||
context.projectGraph,
|
||||
context.root,
|
||||
parsed.project,
|
||||
parsed.target,
|
||||
parsed.configuration
|
||||
);
|
||||
return dependencies.reduce((m, c) => {
|
||||
if (c.node.type !== 'npm' && c.outputs[0] != null) {
|
||||
// `outputs` are cache patterns and may contain globs (e.g. from the
|
||||
// inferred `@nx/js/typescript` build target). Strip the glob portion
|
||||
// so the runtime require overrides resolve to the actual output dir.
|
||||
const outputDir = stripGlobToBaseDir(c.outputs[0]);
|
||||
m[c.name] = joinPathFragments(context.root, outputDir);
|
||||
}
|
||||
return m;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function runWaitUntilTargets(
|
||||
options: NodeExecutorOptions,
|
||||
context: ExecutorContext
|
||||
): Promise<{ success: boolean }[]> {
|
||||
return Promise.all(
|
||||
options.waitUntilTargets.map(async (waitUntilTarget) => {
|
||||
const target = parseTargetString(waitUntilTarget, context);
|
||||
const output = await runExecutor(target, {}, context);
|
||||
return new Promise<{ success: boolean }>(async (resolve) => {
|
||||
let event = await output.next();
|
||||
// Resolve after first event
|
||||
resolve(event.value as { success: boolean });
|
||||
|
||||
// Continue iterating
|
||||
while (!event.done) {
|
||||
event = await output.next();
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function getFileToRun(
|
||||
context: ExecutorContext,
|
||||
project: ProjectGraphProjectNode,
|
||||
buildOptions: Record<string, any>,
|
||||
buildTargetExecutor: string
|
||||
): string {
|
||||
// If using run-commands or another custom executor, then user should set
|
||||
// outputFileName, but we can try the default value that we use.
|
||||
if (!buildOptions?.outputPath && !buildOptions?.outputFileName) {
|
||||
// If we are using crystal for infering the target, we can use the output path from the target.
|
||||
// Since the output path has a token for the project name, we need to interpolate it.
|
||||
// {workspaceRoot}/dist/{projectRoot} -> dist/my-app
|
||||
const outputPath = project.data.targets[buildOptions.target]?.outputs?.[0];
|
||||
|
||||
if (outputPath) {
|
||||
const outputFilePath = interpolate(outputPath, {
|
||||
projectName: project.name,
|
||||
projectRoot: project.data.root,
|
||||
workspaceRoot: context.root,
|
||||
});
|
||||
// `outputs` are cache patterns and may contain globs (e.g. the inferred
|
||||
// `@nx/js/typescript` build target scopes its output to
|
||||
// `{projectRoot}/dist/**/*.{js,...}` to avoid caching non-tsc files).
|
||||
// Strip the glob portion back to the last path separator before it to
|
||||
// recover the base output directory.
|
||||
const outputDir = stripGlobToBaseDir(outputFilePath);
|
||||
return path.join(outputDir, 'main.js');
|
||||
}
|
||||
const fallbackFile = path.join('dist', project.data.root, 'main.js');
|
||||
|
||||
logger.warn(
|
||||
`Build option ${chalk.bold('outputFileName')} not set for ${chalk.bold(
|
||||
project.name
|
||||
)}. Using fallback value of ${chalk.bold(fallbackFile)}.`
|
||||
);
|
||||
return join(context.root, fallbackFile);
|
||||
}
|
||||
|
||||
let outputFileName = buildOptions.outputFileName;
|
||||
|
||||
if (!outputFileName) {
|
||||
outputFileName = getOutputFileName({
|
||||
buildTargetExecutor,
|
||||
main: buildOptions.main,
|
||||
outputPath: buildOptions.outputPath,
|
||||
rootDir: buildOptions.rootDir ?? project.data.root,
|
||||
});
|
||||
}
|
||||
|
||||
return join(context.root, buildOptions.outputPath, outputFileName);
|
||||
}
|
||||
|
||||
function fileToRunCorrectPath(fileToRun: string): string {
|
||||
if (fileExists(fileToRun)) return fileToRun;
|
||||
|
||||
const extensionsToTry = ['.cjs', '.mjs', '.cjs.js', '.esm.js'];
|
||||
|
||||
for (const ext of extensionsToTry) {
|
||||
const file = fileToRun.replace(/\.js$/, ext);
|
||||
if (fileExists(file)) return file;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Could not find ${fileToRun}. Make sure your build succeeded.`
|
||||
);
|
||||
}
|
||||
|
||||
export default nodeExecutor;
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
export const enum InspectType {
|
||||
Inspect = 'inspect',
|
||||
InspectBrk = 'inspect-brk',
|
||||
}
|
||||
|
||||
export interface NodeExecutorOptions {
|
||||
inspect: boolean | InspectType;
|
||||
runtimeArgs: string[];
|
||||
args: string[];
|
||||
waitUntilTargets: string[];
|
||||
buildTarget: string;
|
||||
buildTargetOptions: Record<string, any>;
|
||||
host: string;
|
||||
port: number;
|
||||
watch?: boolean;
|
||||
debounce?: number;
|
||||
runBuildTargetDependencies?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"version": 2,
|
||||
"outputCapture": "direct-nodejs",
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"cli": "nx",
|
||||
"title": "Node executor",
|
||||
"description": "Execute Nodejs applications.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"buildTarget": {
|
||||
"type": "string",
|
||||
"description": "The target to run to build you the app."
|
||||
},
|
||||
"buildTargetOptions": {
|
||||
"type": "object",
|
||||
"description": "Additional options to pass into the build target.",
|
||||
"default": {}
|
||||
},
|
||||
"waitUntilTargets": {
|
||||
"type": "array",
|
||||
"description": "The targets to run before starting the node app. Listed in the form <project>:<target>. The main target will run once all listed targets have output something to the console.",
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"type": "string",
|
||||
"default": "localhost",
|
||||
"description": "The host to inspect the process on.",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"default": 9229,
|
||||
"description": "The port to inspect the process on. Setting port to 0 will assign random free ports to all forked processes.",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"inspect": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["inspect", "inspect-brk"]
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"description": "Ensures the app is starting with debugging.",
|
||||
"default": "inspect",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"runtimeArgs": {
|
||||
"type": "array",
|
||||
"description": "Extra args passed to the node process.",
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"x-priority": "important"
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"description": "Extra args when starting the app.",
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"x-priority": "important"
|
||||
},
|
||||
"watch": {
|
||||
"type": "boolean",
|
||||
"description": "Enable re-building when files change.",
|
||||
"default": true,
|
||||
"x-priority": "important"
|
||||
},
|
||||
"debounce": {
|
||||
"type": "number",
|
||||
"description": "Delay in milliseconds to wait before restarting. Useful to batch multiple file changes events together. Set to zero (0) to disable.",
|
||||
"default": 500,
|
||||
"x-priority": "important"
|
||||
},
|
||||
"runBuildTargetDependencies": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to run dependencies before running the build. Set this to true if the project does not build libraries from source (e.g. 'buildLibsFromSource: false').",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["buildTarget"],
|
||||
"examplesFile": "../../../docs/node-examples.md"
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { type ExecutorContext } from '@nx/devkit';
|
||||
import { TempFs } from '@nx/devkit/internal-testing-utils';
|
||||
import { getCatalogManager } from '@nx/devkit/internal';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { type PackageJson } from 'nx/src/utils/package-json';
|
||||
import pruneLockfileExecutor, {
|
||||
resolveCatalogReferences,
|
||||
} from './prune-lockfile';
|
||||
|
||||
// The executor reads `workspaceRoot` from `@nx/devkit`, which is captured at
|
||||
// module load and isn't updated by `TempFs.setWorkspaceRoot`. Point it at the
|
||||
// per-test temp dir via a getter; everything else stays real.
|
||||
let mockWorkspaceRoot = '';
|
||||
jest.mock('@nx/devkit', () => ({
|
||||
...jest.requireActual('@nx/devkit'),
|
||||
get workspaceRoot() {
|
||||
return mockWorkspaceRoot;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@nx/devkit/internal', () => ({
|
||||
...jest.requireActual('@nx/devkit/internal'),
|
||||
getCatalogManager: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('nx/src/plugins/js/lock-file/lock-file', () => ({
|
||||
...jest.requireActual('nx/src/plugins/js/lock-file/lock-file'),
|
||||
getLockFileName: jest.fn(() => 'package-lock.json'),
|
||||
createLockFile: jest.fn(() => '{}'),
|
||||
}));
|
||||
jest.mock('nx/src/plugins/js/utils/get-workspace-packages-from-graph', () => ({
|
||||
...jest.requireActual(
|
||||
'nx/src/plugins/js/utils/get-workspace-packages-from-graph'
|
||||
),
|
||||
getWorkspacePackagesFromGraph: jest.fn(() => new Map()),
|
||||
}));
|
||||
|
||||
const PROJECT_ROOT = 'apps/app';
|
||||
|
||||
describe('pruneLockfileExecutor - allowScripts', () => {
|
||||
let tempFs: TempFs;
|
||||
|
||||
beforeEach(() => {
|
||||
tempFs = new TempFs('prune-lockfile');
|
||||
mockWorkspaceRoot = tempFs.tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
tempFs.cleanup();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function setupWorkspace(
|
||||
rootPackageJson: PackageJson,
|
||||
projectPackageJson: PackageJson
|
||||
) {
|
||||
tempFs.createFilesSync({
|
||||
'package.json': JSON.stringify(rootPackageJson),
|
||||
'package-lock.json': JSON.stringify({ name: 'root', lockfileVersion: 3 }),
|
||||
[`${PROJECT_ROOT}/package.json`]: JSON.stringify(projectPackageJson),
|
||||
});
|
||||
tempFs.createDirSync('dist/app');
|
||||
}
|
||||
|
||||
async function runExecutor() {
|
||||
return pruneLockfileExecutor(
|
||||
{
|
||||
buildTarget: 'app:build',
|
||||
outputPath: join(tempFs.tempDir, 'dist/app'),
|
||||
},
|
||||
{
|
||||
root: tempFs.tempDir,
|
||||
cwd: tempFs.tempDir,
|
||||
isVerbose: false,
|
||||
projectGraph: {
|
||||
nodes: {
|
||||
app: { name: 'app', type: 'app', data: { root: PROJECT_ROOT } },
|
||||
},
|
||||
dependencies: {},
|
||||
externalNodes: {},
|
||||
},
|
||||
} as unknown as ExecutorContext
|
||||
);
|
||||
}
|
||||
|
||||
function readGeneratedPackageJson(): PackageJson {
|
||||
return JSON.parse(
|
||||
readFileSync(join(tempFs.tempDir, 'dist', 'app', 'package.json'), 'utf-8')
|
||||
);
|
||||
}
|
||||
|
||||
it('copies the root allowScripts verbatim regardless of key shape', async () => {
|
||||
setupWorkspace(
|
||||
{
|
||||
name: 'root',
|
||||
version: '0.0.0',
|
||||
allowScripts: {
|
||||
'esbuild@0.19.0': true,
|
||||
sharp: true,
|
||||
'node-sass': false,
|
||||
'@scope/pkg@1.0.0': true,
|
||||
'org/repo#main': true,
|
||||
'git@github.com:org/repo.git': true,
|
||||
'file:../local-pkg': true,
|
||||
'https://example.com/a.tgz': true,
|
||||
},
|
||||
},
|
||||
{ name: 'app', version: '0.0.1' }
|
||||
);
|
||||
|
||||
await runExecutor();
|
||||
|
||||
expect(readGeneratedPackageJson().allowScripts).toEqual({
|
||||
'esbuild@0.19.0': true,
|
||||
sharp: true,
|
||||
'node-sass': false,
|
||||
'@scope/pkg@1.0.0': true,
|
||||
'org/repo#main': true,
|
||||
'git@github.com:org/repo.git': true,
|
||||
'file:../local-pkg': true,
|
||||
'https://example.com/a.tgz': true,
|
||||
});
|
||||
});
|
||||
|
||||
it('merges root and project allowScripts, with project winning on conflict', async () => {
|
||||
setupWorkspace(
|
||||
{
|
||||
name: 'root',
|
||||
version: '0.0.0',
|
||||
allowScripts: { sharp: true, esbuild: true },
|
||||
},
|
||||
{ name: 'app', version: '0.0.1', allowScripts: { sharp: false } }
|
||||
);
|
||||
|
||||
await runExecutor();
|
||||
|
||||
expect(readGeneratedPackageJson().allowScripts).toEqual({
|
||||
sharp: false,
|
||||
esbuild: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the project allowScripts untouched when the root has none', async () => {
|
||||
setupWorkspace(
|
||||
{ name: 'root', version: '0.0.0' },
|
||||
{ name: 'app', version: '0.0.1', allowScripts: { foo: true } }
|
||||
);
|
||||
|
||||
await runExecutor();
|
||||
|
||||
expect(readGeneratedPackageJson().allowScripts).toEqual({ foo: true });
|
||||
});
|
||||
|
||||
it('omits allowScripts when neither the root nor the project define it', async () => {
|
||||
setupWorkspace(
|
||||
{ name: 'root', version: '0.0.0' },
|
||||
{ name: 'app', version: '0.0.1' }
|
||||
);
|
||||
|
||||
await runExecutor();
|
||||
|
||||
expect(readGeneratedPackageJson().allowScripts).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCatalogReferences', () => {
|
||||
const mockGetCatalogManager = getCatalogManager as jest.MockedFunction<
|
||||
typeof getCatalogManager
|
||||
>;
|
||||
|
||||
function makeManager(catalog: Record<string, string>) {
|
||||
return {
|
||||
isCatalogReference: (version: string) => version.startsWith('catalog:'),
|
||||
resolveCatalogReference: jest.fn(
|
||||
(_root: string, packageName: string, _version: string) =>
|
||||
catalog[packageName] ?? null
|
||||
),
|
||||
} as any;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetCatalogManager.mockReset();
|
||||
});
|
||||
|
||||
it('should return input unchanged when no catalog manager is available', () => {
|
||||
mockGetCatalogManager.mockReturnValue(null);
|
||||
const packageJson: PackageJson = {
|
||||
name: 'app',
|
||||
version: '0.0.1',
|
||||
dependencies: { react: 'catalog:' },
|
||||
};
|
||||
|
||||
const result = resolveCatalogReferences(packageJson);
|
||||
|
||||
expect(result).toBe(packageJson);
|
||||
});
|
||||
|
||||
it('should resolve catalog references across all dependency sections', () => {
|
||||
mockGetCatalogManager.mockReturnValue(
|
||||
makeManager({
|
||||
react: '^18.0.0',
|
||||
zod: '^3.22.0',
|
||||
jest: '^29.0.0',
|
||||
typescript: '^5.0.0',
|
||||
})
|
||||
);
|
||||
const packageJson: PackageJson = {
|
||||
name: 'app',
|
||||
version: '0.0.1',
|
||||
dependencies: { react: 'catalog:', lodash: '^4.17.0' },
|
||||
optionalDependencies: { zod: 'catalog:' },
|
||||
devDependencies: { jest: 'catalog:' },
|
||||
peerDependencies: { typescript: 'catalog:' },
|
||||
};
|
||||
|
||||
const result = resolveCatalogReferences(packageJson);
|
||||
|
||||
expect(result.dependencies).toEqual({
|
||||
react: '^18.0.0',
|
||||
lodash: '^4.17.0',
|
||||
});
|
||||
expect(result.optionalDependencies).toEqual({ zod: '^3.22.0' });
|
||||
expect(result.devDependencies).toEqual({ jest: '^29.0.0' });
|
||||
expect(result.peerDependencies).toEqual({ typescript: '^5.0.0' });
|
||||
});
|
||||
|
||||
it('should preserve non-catalog version specifiers', () => {
|
||||
mockGetCatalogManager.mockReturnValue(makeManager({ react: '^18.0.0' }));
|
||||
const packageJson: PackageJson = {
|
||||
name: 'app',
|
||||
version: '0.0.1',
|
||||
dependencies: {
|
||||
react: 'catalog:',
|
||||
lodash: '^4.17.0',
|
||||
'@scope/pkg': 'workspace:*',
|
||||
local: 'file:./local',
|
||||
},
|
||||
};
|
||||
|
||||
const result = resolveCatalogReferences(packageJson);
|
||||
|
||||
expect(result.dependencies).toEqual({
|
||||
react: '^18.0.0',
|
||||
lodash: '^4.17.0',
|
||||
'@scope/pkg': 'workspace:*',
|
||||
local: 'file:./local',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not mutate the input package.json', () => {
|
||||
mockGetCatalogManager.mockReturnValue(makeManager({ react: '^18.0.0' }));
|
||||
const packageJson: PackageJson = {
|
||||
name: 'app',
|
||||
version: '0.0.1',
|
||||
dependencies: { react: 'catalog:' },
|
||||
};
|
||||
|
||||
const result = resolveCatalogReferences(packageJson);
|
||||
|
||||
expect(packageJson.dependencies).toEqual({ react: 'catalog:' });
|
||||
expect(result).not.toBe(packageJson);
|
||||
expect(result.dependencies).not.toBe(packageJson.dependencies);
|
||||
});
|
||||
|
||||
it('should throw when a catalog reference cannot be resolved', () => {
|
||||
mockGetCatalogManager.mockReturnValue(makeManager({}));
|
||||
const packageJson: PackageJson = {
|
||||
name: 'app',
|
||||
version: '0.0.1',
|
||||
dependencies: { react: 'catalog:' },
|
||||
};
|
||||
|
||||
expect(() => resolveCatalogReferences(packageJson)).toThrow(
|
||||
'Could not resolve catalog reference for package react@catalog:.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing dependency sections', () => {
|
||||
mockGetCatalogManager.mockReturnValue(makeManager({ react: '^18.0.0' }));
|
||||
const packageJson: PackageJson = {
|
||||
name: 'app',
|
||||
version: '0.0.1',
|
||||
dependencies: { react: 'catalog:' },
|
||||
};
|
||||
|
||||
const result = resolveCatalogReferences(packageJson);
|
||||
|
||||
expect(result.dependencies).toEqual({ react: '^18.0.0' });
|
||||
expect(result.devDependencies).toBeUndefined();
|
||||
expect(result.peerDependencies).toBeUndefined();
|
||||
expect(result.optionalDependencies).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
detectPackageManager,
|
||||
type ExecutorContext,
|
||||
logger,
|
||||
parseTargetString,
|
||||
type ProjectGraph,
|
||||
readJsonFile,
|
||||
workspaceRoot,
|
||||
} from '@nx/devkit';
|
||||
import { getCatalogManager } from '@nx/devkit/internal';
|
||||
import { existsSync, lstatSync, writeFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { interpolate } from 'nx/src/tasks-runner/utils';
|
||||
import {
|
||||
type PackageJson,
|
||||
type PackageJsonDependencySection,
|
||||
} from 'nx/src/utils/package-json';
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
import {
|
||||
getLockFileName,
|
||||
createLockFile,
|
||||
} from 'nx/src/plugins/js/lock-file/lock-file';
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
import { getWorkspacePackagesFromGraph } from 'nx/src/plugins/js/utils/get-workspace-packages-from-graph';
|
||||
import { type PruneLockfileOptions } from './schema';
|
||||
import { stripGlobToBaseDir } from '../../utils/strip-glob-to-base-dir';
|
||||
|
||||
export default async function pruneLockfileExecutor(
|
||||
schema: PruneLockfileOptions,
|
||||
context: ExecutorContext
|
||||
) {
|
||||
logger.log('Pruning lockfile...');
|
||||
const outputDirectory = getOutputDir(schema, context);
|
||||
const packageJson = resolveCatalogReferences(getPackageJson(schema, context));
|
||||
mergeAllowScripts(packageJson);
|
||||
const packageManager = detectPackageManager(workspaceRoot);
|
||||
|
||||
if (packageManager === 'bun') {
|
||||
logger.warn(
|
||||
'Bun lockfile generation is not supported. Only package.json will be generated. Run "bun install" in the output directory if needed.'
|
||||
);
|
||||
writeFileSync(
|
||||
join(outputDirectory, 'package.json'),
|
||||
JSON.stringify(packageJson, null, 2)
|
||||
);
|
||||
} else {
|
||||
const { lockfileName, lockFile } = createPrunedLockfile(
|
||||
packageJson,
|
||||
context.projectGraph
|
||||
);
|
||||
const lockfileOutputPath = join(outputDirectory, lockfileName);
|
||||
writeFileSync(lockfileOutputPath, lockFile);
|
||||
writeFileSync(
|
||||
join(outputDirectory, 'package.json'),
|
||||
JSON.stringify(packageJson, null, 2)
|
||||
);
|
||||
logger.log(`Lockfile pruned: ${lockfileOutputPath}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
function createPrunedLockfile(packageJson: PackageJson, graph: ProjectGraph) {
|
||||
const packageManager = detectPackageManager(workspaceRoot);
|
||||
const lockfileName = getLockFileName(packageManager);
|
||||
const lockFile = createLockFile(packageJson, graph, packageManager);
|
||||
|
||||
const workspacePackages = getWorkspacePackagesFromGraph(graph);
|
||||
|
||||
for (const [pkgName, pkgVersion] of Object.entries(
|
||||
packageJson.dependencies ?? {}
|
||||
)) {
|
||||
if (
|
||||
pkgVersion.startsWith('workspace:') ||
|
||||
pkgVersion.startsWith('file:') ||
|
||||
pkgVersion.startsWith('link:') ||
|
||||
workspacePackages.has(pkgName)
|
||||
) {
|
||||
packageJson.dependencies[pkgName] = `file:./workspace_modules/${pkgName}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lockfileName,
|
||||
lockFile,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* npm reads the `allowScripts` install-script allowlist only from the install
|
||||
* root, but `npm approve-scripts` writes it to the workspace root, so it never
|
||||
* lives in the project package.json the prune output is built from. Carry the
|
||||
* root allowlist over, with project-level entries preserved and winning on
|
||||
* conflict. Mirrors the `pnpm.allowBuilds` handling in createPackageJson.
|
||||
*/
|
||||
function mergeAllowScripts(packageJson: PackageJson) {
|
||||
const rootPackageJson: PackageJson = readJsonFile(
|
||||
join(workspaceRoot, 'package.json')
|
||||
);
|
||||
if (!rootPackageJson.allowScripts) {
|
||||
return;
|
||||
}
|
||||
packageJson.allowScripts = {
|
||||
...rootPackageJson.allowScripts,
|
||||
...packageJson.allowScripts,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCatalogReferences(
|
||||
packageJson: PackageJson
|
||||
): PackageJson {
|
||||
const manager = getCatalogManager(workspaceRoot);
|
||||
if (!manager) {
|
||||
return packageJson;
|
||||
}
|
||||
|
||||
const sections: PackageJsonDependencySection[] = [
|
||||
'dependencies',
|
||||
'optionalDependencies',
|
||||
'devDependencies',
|
||||
'peerDependencies',
|
||||
];
|
||||
const resolved: PackageJson = { ...packageJson };
|
||||
for (const section of sections) {
|
||||
const deps = packageJson[section];
|
||||
if (!deps) {
|
||||
continue;
|
||||
}
|
||||
const resolvedDeps: Record<string, string> = { ...deps };
|
||||
for (const [packageName, version] of Object.entries(deps)) {
|
||||
if (!manager.isCatalogReference(version)) {
|
||||
continue;
|
||||
}
|
||||
const resolvedVersion = manager.resolveCatalogReference(
|
||||
workspaceRoot,
|
||||
packageName,
|
||||
version
|
||||
);
|
||||
if (!resolvedVersion) {
|
||||
throw new Error(
|
||||
`Could not resolve catalog reference for package ${packageName}@${version}.`
|
||||
);
|
||||
}
|
||||
resolvedDeps[packageName] = resolvedVersion;
|
||||
}
|
||||
resolved[section] = resolvedDeps;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function getPackageJson(
|
||||
schema: PruneLockfileOptions,
|
||||
context: ExecutorContext
|
||||
) {
|
||||
const target = parseTargetString(schema.buildTarget, context);
|
||||
const project = context.projectGraph.nodes[target.project].data;
|
||||
const packageJsonPath = join(workspaceRoot, project.root, 'package.json');
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
throw new Error(`${packageJsonPath} does not exist.`);
|
||||
}
|
||||
|
||||
const packageJson = readJsonFile(packageJsonPath);
|
||||
return packageJson;
|
||||
}
|
||||
|
||||
function getOutputDir(schema: PruneLockfileOptions, context: ExecutorContext) {
|
||||
let outputDir = schema.outputPath;
|
||||
if (outputDir) {
|
||||
outputDir = normalizeOutputPath(outputDir);
|
||||
if (existsSync(outputDir)) {
|
||||
return outputDir;
|
||||
}
|
||||
}
|
||||
const target = parseTargetString(schema.buildTarget, context);
|
||||
const project = context.projectGraph.nodes[target.project].data;
|
||||
const buildTarget = project.targets[target.target];
|
||||
let maybeOutputPath =
|
||||
buildTarget.outputs?.[0] ??
|
||||
buildTarget.options.outputPath ??
|
||||
buildTarget.options.outputDir;
|
||||
|
||||
if (!maybeOutputPath) {
|
||||
throw new Error(
|
||||
`Could not infer an output directory from the '${schema.buildTarget}' target. Please provide 'outputPath'.`
|
||||
);
|
||||
}
|
||||
|
||||
maybeOutputPath = interpolate(maybeOutputPath, {
|
||||
workspaceRoot,
|
||||
projectRoot: project.root,
|
||||
projectName: project.name,
|
||||
options: {
|
||||
...(buildTarget.options ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
outputDir = normalizeOutputPath(maybeOutputPath);
|
||||
if (!existsSync(outputDir)) {
|
||||
throw new Error(
|
||||
`The output directory '${outputDir}' inferred from the '${schema.buildTarget}' target does not exist.\nPlease ensure a build has run first, and that the path is correct. Otherwise, please provide 'outputPath'.`
|
||||
);
|
||||
}
|
||||
return outputDir;
|
||||
}
|
||||
|
||||
function normalizeOutputPath(outputPath: string) {
|
||||
outputPath = stripGlobToBaseDir(outputPath);
|
||||
if (!outputPath.startsWith(workspaceRoot)) {
|
||||
outputPath = join(workspaceRoot, outputPath);
|
||||
}
|
||||
if (!lstatSync(outputPath).isDirectory()) {
|
||||
outputPath = dirname(outputPath);
|
||||
}
|
||||
return outputPath;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface PruneLockfileOptions {
|
||||
buildTarget: string;
|
||||
outputPath?: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": 2,
|
||||
"outputCapture": "direct-nodejs",
|
||||
"title": "Prune Lockfile",
|
||||
"description": "Creates a pruned lockfile based on the project dependencies and places it into the output directory.",
|
||||
"cli": "nx",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"buildTarget": {
|
||||
"type": "string",
|
||||
"description": "The build target that produces the output directory to place the pruned lockfile.",
|
||||
"default": "build"
|
||||
},
|
||||
"outputPath": {
|
||||
"type": "string",
|
||||
"description": "The output path to place the pruned lockfile. Usually inferred from the outputs of the buildTarget."
|
||||
}
|
||||
},
|
||||
"required": ["buildTarget"]
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { extractNpmPublishJsonData } from './extract-npm-publish-json-data';
|
||||
|
||||
describe('extractNpmPublishJsonData()', () => {
|
||||
describe('only unrelated JSON data', () => {
|
||||
// Does not match expected npm publish JSON data
|
||||
const data = {
|
||||
foo: true,
|
||||
bar: [1, 2, 3],
|
||||
};
|
||||
|
||||
it('should safely ignore unrelated formatted JSON data', () => {
|
||||
const formattedJsonStr = JSON.stringify(data, null, 2);
|
||||
const res = extractNpmPublishJsonData(formattedJsonStr);
|
||||
|
||||
expect(res.beforeJsonData).toMatchInlineSnapshot(`
|
||||
"{
|
||||
"foo": true,
|
||||
"bar": [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]
|
||||
}"
|
||||
`);
|
||||
expect(res.jsonData).toEqual(null);
|
||||
expect(res.afterJsonData).toMatchInlineSnapshot(`""`);
|
||||
});
|
||||
|
||||
it('should safely ignore unrelated unformatted JSON data', () => {
|
||||
const unformattedJsonStr = JSON.stringify(data);
|
||||
const res = extractNpmPublishJsonData(unformattedJsonStr);
|
||||
|
||||
expect(res.beforeJsonData).toMatchInlineSnapshot(
|
||||
`"{"foo":true,"bar":[1,2,3]}"`
|
||||
);
|
||||
expect(res.jsonData).toEqual(null);
|
||||
expect(res.afterJsonData).toMatchInlineSnapshot(`""`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed unrelated JSON and non-JSON data', () => {
|
||||
// Does not match expected npm publish JSON data
|
||||
const data = {
|
||||
foo: true,
|
||||
bar: [1, 2, 3],
|
||||
};
|
||||
const extraContentBefore = 'Some random text';
|
||||
const extraContentAfter = 'More random text';
|
||||
|
||||
it('should safely ignore unrelated mixed data containing formatted JSON', () => {
|
||||
const formattedJsonStr = JSON.stringify(data, null, 2);
|
||||
const res = extractNpmPublishJsonData(`${extraContentBefore}
|
||||
${formattedJsonStr}
|
||||
${extraContentAfter}`);
|
||||
|
||||
expect(res.beforeJsonData).toMatchInlineSnapshot(`
|
||||
"Some random text
|
||||
{
|
||||
"foo": true,
|
||||
"bar": [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]
|
||||
}
|
||||
More random text"
|
||||
`);
|
||||
expect(res.jsonData).toEqual(null);
|
||||
expect(res.afterJsonData).toMatchInlineSnapshot(`""`);
|
||||
});
|
||||
|
||||
it('should safely ignore unrelated mixed data containing unformatted JSON', () => {
|
||||
const unformattedJsonStr = JSON.stringify(data);
|
||||
const res = extractNpmPublishJsonData(`${extraContentBefore}
|
||||
${unformattedJsonStr}
|
||||
${extraContentAfter}`);
|
||||
|
||||
expect(res.beforeJsonData).toMatchInlineSnapshot(`
|
||||
"Some random text
|
||||
{"foo":true,"bar":[1,2,3]}
|
||||
More random text"
|
||||
`);
|
||||
expect(res.jsonData).toEqual(null);
|
||||
expect(res.afterJsonData).toMatchInlineSnapshot(`""`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('output containing npm publish JSON data', () => {
|
||||
it('should extract the relevant JSON data from a simple publish output string containing only the data', () => {
|
||||
const commandOutput = `{
|
||||
"id": "package-a@1.0.0",
|
||||
"name": "package-a",
|
||||
"version": "1.0.0",
|
||||
"size": 251,
|
||||
"unpackedSize": 233,
|
||||
"shasum": "cf4a6657f230ddf5375102bafc8f5184002a620a",
|
||||
"integrity": "sha512-Qra/YIkAxVavs3tumB/svugHLY5CISujdeUcMd2FfvtVkjEEsVAEYbqZTq0ixnkvjVrLr27mAvH94GjjMKWzIg==",
|
||||
"filename": "package-a-1.0.0.tgz",
|
||||
"files": [
|
||||
{
|
||||
"path": "package.json",
|
||||
"size": 233,
|
||||
"mode": 420
|
||||
}
|
||||
],
|
||||
"entryCount": 1,
|
||||
"bundled": []
|
||||
}`;
|
||||
const res = extractNpmPublishJsonData(commandOutput);
|
||||
|
||||
expect(res.beforeJsonData).toMatchInlineSnapshot(`""`);
|
||||
expect(res.jsonData).toMatchInlineSnapshot(`
|
||||
{
|
||||
"bundled": [],
|
||||
"entryCount": 1,
|
||||
"filename": "package-a-1.0.0.tgz",
|
||||
"files": [
|
||||
{
|
||||
"mode": 420,
|
||||
"path": "package.json",
|
||||
"size": 233,
|
||||
},
|
||||
],
|
||||
"id": "package-a@1.0.0",
|
||||
"integrity": "sha512-Qra/YIkAxVavs3tumB/svugHLY5CISujdeUcMd2FfvtVkjEEsVAEYbqZTq0ixnkvjVrLr27mAvH94GjjMKWzIg==",
|
||||
"name": "package-a",
|
||||
"shasum": "cf4a6657f230ddf5375102bafc8f5184002a620a",
|
||||
"size": 251,
|
||||
"unpackedSize": 233,
|
||||
"version": "1.0.0",
|
||||
}
|
||||
`);
|
||||
expect(res.afterJsonData).toMatchInlineSnapshot(`""`);
|
||||
});
|
||||
|
||||
it('should extract the relevant JSON data from a publish output string containing lifecycle script outputs', () => {
|
||||
const exampleCommandOutputWithLifecycleScripts = `
|
||||
> package-a@1.0.0 prepublishOnly
|
||||
> echo 'prepublishOnly from package-a'
|
||||
|
||||
prepublishOnly from package-a
|
||||
{
|
||||
"id": "package-a@1.0.0",
|
||||
"name": "package-a",
|
||||
"version": "1.0.0",
|
||||
"size": 206,
|
||||
"unpackedSize": 179,
|
||||
"shasum": "f01c6f5c8d72ed33e70c1c1b1258f46c92360e57",
|
||||
"integrity": "sha512-24/pgfxiTiNB/dw7ZbBZ+I1vidq09KU6n/QgXCtx1y4+ezYpEBSncdrEpDxuMD6YaP8twg3H8zQBLoG8xwygcA==",
|
||||
"filename": "package-a-1.0.0.tgz",
|
||||
"files": [
|
||||
{
|
||||
"path": "package.json",
|
||||
"size": 179,
|
||||
"mode": 420
|
||||
}
|
||||
],
|
||||
"entryCount": 1,
|
||||
"bundled": []
|
||||
}
|
||||
`;
|
||||
const res = extractNpmPublishJsonData(
|
||||
exampleCommandOutputWithLifecycleScripts
|
||||
);
|
||||
|
||||
expect(res.beforeJsonData).toMatchInlineSnapshot(`
|
||||
"
|
||||
> package-a@1.0.0 prepublishOnly
|
||||
> echo 'prepublishOnly from package-a'
|
||||
|
||||
prepublishOnly from package-a
|
||||
"
|
||||
`);
|
||||
|
||||
expect(res.jsonData).toMatchInlineSnapshot(`
|
||||
{
|
||||
"bundled": [],
|
||||
"entryCount": 1,
|
||||
"filename": "package-a-1.0.0.tgz",
|
||||
"files": [
|
||||
{
|
||||
"mode": 420,
|
||||
"path": "package.json",
|
||||
"size": 179,
|
||||
},
|
||||
],
|
||||
"id": "package-a@1.0.0",
|
||||
"integrity": "sha512-24/pgfxiTiNB/dw7ZbBZ+I1vidq09KU6n/QgXCtx1y4+ezYpEBSncdrEpDxuMD6YaP8twg3H8zQBLoG8xwygcA==",
|
||||
"name": "package-a",
|
||||
"shasum": "f01c6f5c8d72ed33e70c1c1b1258f46c92360e57",
|
||||
"size": 206,
|
||||
"unpackedSize": 179,
|
||||
"version": "1.0.0",
|
||||
}
|
||||
`);
|
||||
|
||||
expect(res.afterJsonData).toMatchInlineSnapshot(`
|
||||
"
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should work when a user lifecycle script adds custom, unformatted JSON data to the output', () => {
|
||||
const exampleCommandOutputWithLifecycleScripts = `
|
||||
> package-a@1.0.0 prepublishOnly
|
||||
> node -e 'console.log(JSON.stringify({"name": "package-a", "version": "1.0.0"}));'
|
||||
|
||||
{"name":"package-a","version":"1.0.0"}
|
||||
{
|
||||
"id": "package-a@1.0.0",
|
||||
"name": "package-a",
|
||||
"version": "1.0.0",
|
||||
"size": 249,
|
||||
"unpackedSize": 232,
|
||||
"shasum": "63caa58603b8f9b76a5151ad4e965c3ac0b83c71",
|
||||
"integrity": "sha512-mXgusXuPfyvqNpnHY3F0TwLiitKzt98hcAxgEq6/uueEM53haisRQx+tf5FEE6uNRhE+9U0A2y9//KD2OPnSBQ==",
|
||||
"filename": "package-a-1.0.0.tgz",
|
||||
"files": [
|
||||
{
|
||||
"path": "package.json",
|
||||
"size": 232,
|
||||
"mode": 420
|
||||
}
|
||||
],
|
||||
"entryCount": 1,
|
||||
"bundled": []
|
||||
}`;
|
||||
const res = extractNpmPublishJsonData(
|
||||
exampleCommandOutputWithLifecycleScripts
|
||||
);
|
||||
|
||||
expect(res.beforeJsonData).toMatchInlineSnapshot(`
|
||||
"
|
||||
> package-a@1.0.0 prepublishOnly
|
||||
> node -e 'console.log(JSON.stringify({"name": "package-a", "version": "1.0.0"}));'
|
||||
|
||||
{"name":"package-a","version":"1.0.0"}
|
||||
"
|
||||
`);
|
||||
|
||||
expect(res.jsonData).toMatchInlineSnapshot(`
|
||||
{
|
||||
"bundled": [],
|
||||
"entryCount": 1,
|
||||
"filename": "package-a-1.0.0.tgz",
|
||||
"files": [
|
||||
{
|
||||
"mode": 420,
|
||||
"path": "package.json",
|
||||
"size": 232,
|
||||
},
|
||||
],
|
||||
"id": "package-a@1.0.0",
|
||||
"integrity": "sha512-mXgusXuPfyvqNpnHY3F0TwLiitKzt98hcAxgEq6/uueEM53haisRQx+tf5FEE6uNRhE+9U0A2y9//KD2OPnSBQ==",
|
||||
"name": "package-a",
|
||||
"shasum": "63caa58603b8f9b76a5151ad4e965c3ac0b83c71",
|
||||
"size": 249,
|
||||
"unpackedSize": 232,
|
||||
"version": "1.0.0",
|
||||
}
|
||||
`);
|
||||
|
||||
expect(res.afterJsonData).toMatchInlineSnapshot(`""`);
|
||||
});
|
||||
|
||||
it('should extract and unwrap the JSON data when npm >= 11.16 nests the summary under the package name', () => {
|
||||
// npm >= 11.16 (bundled with Node 26) nests the publish summary under the
|
||||
// package name instead of printing it as a flat object.
|
||||
const commandOutput = `{
|
||||
"@scope/package-a": {
|
||||
"id": "@scope/package-a@1.0.0",
|
||||
"name": "@scope/package-a",
|
||||
"version": "1.0.0",
|
||||
"size": 251,
|
||||
"unpackedSize": 233,
|
||||
"shasum": "cf4a6657f230ddf5375102bafc8f5184002a620a",
|
||||
"integrity": "sha512-Qra/YIkAxVavs3tumB/svugHLY5CISujdeUcMd2FfvtVkjEEsVAEYbqZTq0ixnkvjVrLr27mAvH94GjjMKWzIg==",
|
||||
"filename": "scope-package-a-1.0.0.tgz",
|
||||
"files": [
|
||||
{
|
||||
"path": "package.json",
|
||||
"size": 233,
|
||||
"mode": 420
|
||||
}
|
||||
],
|
||||
"entryCount": 1,
|
||||
"bundled": []
|
||||
}
|
||||
}`;
|
||||
const res = extractNpmPublishJsonData(commandOutput);
|
||||
|
||||
// The wrapper braces must not leak into beforeJsonData/afterJsonData.
|
||||
expect(res.beforeJsonData).toMatchInlineSnapshot(`""`);
|
||||
expect(res.jsonData).toMatchInlineSnapshot(`
|
||||
{
|
||||
"bundled": [],
|
||||
"entryCount": 1,
|
||||
"filename": "scope-package-a-1.0.0.tgz",
|
||||
"files": [
|
||||
{
|
||||
"mode": 420,
|
||||
"path": "package.json",
|
||||
"size": 233,
|
||||
},
|
||||
],
|
||||
"id": "@scope/package-a@1.0.0",
|
||||
"integrity": "sha512-Qra/YIkAxVavs3tumB/svugHLY5CISujdeUcMd2FfvtVkjEEsVAEYbqZTq0ixnkvjVrLr27mAvH94GjjMKWzIg==",
|
||||
"name": "@scope/package-a",
|
||||
"shasum": "cf4a6657f230ddf5375102bafc8f5184002a620a",
|
||||
"size": 251,
|
||||
"unpackedSize": 233,
|
||||
"version": "1.0.0",
|
||||
}
|
||||
`);
|
||||
expect(res.afterJsonData).toMatchInlineSnapshot(`""`);
|
||||
});
|
||||
|
||||
it('should extract and unwrap nested JSON data alongside lifecycle script outputs', () => {
|
||||
const commandOutput = `
|
||||
> @scope/package-a@1.0.0 prepublishOnly
|
||||
> echo 'prepublishOnly from package-a'
|
||||
|
||||
prepublishOnly from package-a
|
||||
{
|
||||
"@scope/package-a": {
|
||||
"id": "@scope/package-a@1.0.0",
|
||||
"name": "@scope/package-a",
|
||||
"version": "1.0.0",
|
||||
"size": 206,
|
||||
"unpackedSize": 179,
|
||||
"shasum": "f01c6f5c8d72ed33e70c1c1b1258f46c92360e57",
|
||||
"integrity": "sha512-24/pgfxiTiNB/dw7ZbBZ+I1vidq09KU6n/QgXCtx1y4+ezYpEBSncdrEpDxuMD6YaP8twg3H8zQBLoG8xwygcA==",
|
||||
"filename": "scope-package-a-1.0.0.tgz",
|
||||
"files": [
|
||||
{
|
||||
"path": "package.json",
|
||||
"size": 179,
|
||||
"mode": 420
|
||||
}
|
||||
],
|
||||
"entryCount": 1,
|
||||
"bundled": []
|
||||
}
|
||||
}
|
||||
`;
|
||||
const res = extractNpmPublishJsonData(commandOutput);
|
||||
|
||||
expect(res.beforeJsonData).toMatchInlineSnapshot(`
|
||||
"
|
||||
> @scope/package-a@1.0.0 prepublishOnly
|
||||
> echo 'prepublishOnly from package-a'
|
||||
|
||||
prepublishOnly from package-a
|
||||
"
|
||||
`);
|
||||
expect(res.jsonData).toMatchInlineSnapshot(`
|
||||
{
|
||||
"bundled": [],
|
||||
"entryCount": 1,
|
||||
"filename": "scope-package-a-1.0.0.tgz",
|
||||
"files": [
|
||||
{
|
||||
"mode": 420,
|
||||
"path": "package.json",
|
||||
"size": 179,
|
||||
},
|
||||
],
|
||||
"id": "@scope/package-a@1.0.0",
|
||||
"integrity": "sha512-24/pgfxiTiNB/dw7ZbBZ+I1vidq09KU6n/QgXCtx1y4+ezYpEBSncdrEpDxuMD6YaP8twg3H8zQBLoG8xwygcA==",
|
||||
"name": "@scope/package-a",
|
||||
"shasum": "f01c6f5c8d72ed33e70c1c1b1258f46c92360e57",
|
||||
"size": 206,
|
||||
"unpackedSize": 179,
|
||||
"version": "1.0.0",
|
||||
}
|
||||
`);
|
||||
expect(res.afterJsonData).toMatchInlineSnapshot(`
|
||||
"
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should extract the relevant JSON data when formatted JSON data is present alongside the expected npm publish JSON data', () => {
|
||||
const exampleCommandOutputWithFormattedJSON = `
|
||||
{
|
||||
"unrelated": true,
|
||||
"data": [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]
|
||||
}
|
||||
{
|
||||
"id": "package-a@1.0.0",
|
||||
"name": "package-a",
|
||||
"version": "1.0.0",
|
||||
"size": 249,
|
||||
"unpackedSize": 232,
|
||||
"shasum": "63caa58603b8f9b76a5151ad4e965c3ac0b83c71",
|
||||
"integrity": "sha512-mXgusXuPfyvqNpnHY3F0TwLiitKzt98hcAxgEq6/uueEM53haisRQx+tf5FEE6uNRhE+9U0A2y9//KD2OPnSBQ==",
|
||||
"filename": "package-a-1.0.0.tgz",
|
||||
"files": [
|
||||
{
|
||||
"path": "package.json",
|
||||
"size": 232,
|
||||
"mode": 420
|
||||
}
|
||||
],
|
||||
"entryCount": 1,
|
||||
"bundled": []
|
||||
}
|
||||
{
|
||||
"extra": "data",
|
||||
"foo": "bar"
|
||||
}`;
|
||||
|
||||
const res = extractNpmPublishJsonData(
|
||||
exampleCommandOutputWithFormattedJSON
|
||||
);
|
||||
|
||||
expect(res.beforeJsonData).toMatchInlineSnapshot(`
|
||||
"
|
||||
{
|
||||
"unrelated": true,
|
||||
"data": [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]
|
||||
}
|
||||
"
|
||||
`);
|
||||
|
||||
expect(res.jsonData).toMatchInlineSnapshot(`
|
||||
{
|
||||
"bundled": [],
|
||||
"entryCount": 1,
|
||||
"filename": "package-a-1.0.0.tgz",
|
||||
"files": [
|
||||
{
|
||||
"mode": 420,
|
||||
"path": "package.json",
|
||||
"size": 232,
|
||||
},
|
||||
],
|
||||
"id": "package-a@1.0.0",
|
||||
"integrity": "sha512-mXgusXuPfyvqNpnHY3F0TwLiitKzt98hcAxgEq6/uueEM53haisRQx+tf5FEE6uNRhE+9U0A2y9//KD2OPnSBQ==",
|
||||
"name": "package-a",
|
||||
"shasum": "63caa58603b8f9b76a5151ad4e965c3ac0b83c71",
|
||||
"size": 249,
|
||||
"unpackedSize": 232,
|
||||
"version": "1.0.0",
|
||||
}
|
||||
`);
|
||||
|
||||
expect(res.afterJsonData).toMatchInlineSnapshot(`
|
||||
"
|
||||
{
|
||||
"extra": "data",
|
||||
"foo": "bar"
|
||||
}"
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
const expectedNpmPublishJsonKeys = [
|
||||
'id',
|
||||
'name',
|
||||
'version',
|
||||
'size',
|
||||
'filename',
|
||||
];
|
||||
|
||||
// Regular expression to match JSON-like objects, including up to two levels of
|
||||
// nested objects.
|
||||
// /{(?:[^{}]|{(?:[^{}]|{[^{}]*})*})*}/g
|
||||
// Two levels of nesting are required to support both shapes of npm/pnpm publish
|
||||
// output:
|
||||
// - Older npm (<= 11.13) prints the publish summary as a flat object whose only
|
||||
// nesting is its own "files" array (one level deep).
|
||||
// - Newer npm (>= 11.16, bundled with Node 26) nests that same summary under the
|
||||
// package name, e.g. { "@scope/pkg": { ...id/name/files... } }, adding a
|
||||
// second level. pnpm publish nests the same way when run from the workspace
|
||||
// root. Matching two levels lets us capture the wrapper object in the nested
|
||||
// case so its braces are not left behind in beforeJsonData/afterJsonData.
|
||||
const jsonRegex = /{(?:[^{}]|{(?:[^{}]|{[^{}]*})*})*}/g;
|
||||
|
||||
function isNpmPublishSummary(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
expectedNpmPublishJsonKeys.every(
|
||||
(key) => (value as Record<string, unknown>)[key] !== undefined
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function extractNpmPublishJsonData(str: string): {
|
||||
beforeJsonData: string;
|
||||
jsonData: Record<string, unknown> | null;
|
||||
afterJsonData: string;
|
||||
} {
|
||||
const jsonMatches = str.match(jsonRegex);
|
||||
if (jsonMatches) {
|
||||
for (const match of jsonMatches) {
|
||||
// Cheap upfront check to see if the stringified JSON data has the expected keys as substrings
|
||||
if (!expectedNpmPublishJsonKeys.every((key) => str.includes(key))) {
|
||||
continue;
|
||||
}
|
||||
// Full JSON parsing to identify the JSON object
|
||||
let parsedJson: unknown;
|
||||
try {
|
||||
parsedJson = JSON.parse(match);
|
||||
} catch {
|
||||
// Ignore parsing errors for unrelated JSON blocks
|
||||
continue;
|
||||
}
|
||||
|
||||
// npm <= 11.13 emits the summary as a flat object, while npm >= 11.16 (and
|
||||
// pnpm publish run from the workspace root) nest it one level deep under the
|
||||
// package name. Support both by unwrapping a single level when the matched
|
||||
// object isn't itself the summary.
|
||||
let publishData: Record<string, unknown> | null = null;
|
||||
if (isNpmPublishSummary(parsedJson)) {
|
||||
publishData = parsedJson;
|
||||
} else if (typeof parsedJson === 'object' && parsedJson !== null) {
|
||||
for (const value of Object.values(
|
||||
parsedJson as Record<string, unknown>
|
||||
)) {
|
||||
if (isNpmPublishSummary(value)) {
|
||||
publishData = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!publishData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const jsonStartIndex = str.indexOf(match);
|
||||
return {
|
||||
beforeJsonData: str.slice(0, jsonStartIndex),
|
||||
jsonData: publishData,
|
||||
afterJsonData: str.slice(jsonStartIndex + match.length),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No applicable jsonData detected, the whole contents is the beforeJsonData
|
||||
return {
|
||||
beforeJsonData: str,
|
||||
jsonData: null,
|
||||
afterJsonData: '',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Taken from https://github.com/npm/cli/blob/c736b622b8504b07f5a19f631ade42dd40063269/lib/utils/format-bytes.js
|
||||
|
||||
// Convert bytes to printable output, for file reporting in tarballs
|
||||
// Only supports up to GB because that's way larger than anything the registry
|
||||
// supports anyways.
|
||||
|
||||
export const formatBytes = (bytes, space = true) => {
|
||||
let spacer = '';
|
||||
if (space) {
|
||||
spacer = ' ';
|
||||
}
|
||||
|
||||
if (bytes < 1000) {
|
||||
// B
|
||||
return `${bytes}${spacer}B`;
|
||||
}
|
||||
|
||||
if (bytes < 1000000) {
|
||||
// kB
|
||||
return `${(bytes / 1000).toFixed(1)}${spacer}kB`;
|
||||
}
|
||||
|
||||
if (bytes < 1000000000) {
|
||||
// MB
|
||||
return `${(bytes / 1000000).toFixed(1)}${spacer}MB`;
|
||||
}
|
||||
|
||||
// GB
|
||||
return `${(bytes / 1000000000).toFixed(1)}${spacer}GB`;
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
// Adapted from https://github.com/npm/cli/blob/c736b622b8504b07f5a19f631ade42dd40063269/lib/utils/tar.js
|
||||
import chalk from 'chalk';
|
||||
import columnify from 'columnify';
|
||||
import { formatBytes } from './format-bytes';
|
||||
|
||||
export const logTar = (tarball, opts = {}) => {
|
||||
// @ts-ignore
|
||||
const { unicode = true } = opts;
|
||||
console.log('');
|
||||
console.log(
|
||||
`${unicode ? '📦 ' : 'package:'} ${tarball.name}@${tarball.version}`
|
||||
);
|
||||
console.log(chalk.magenta('=== Tarball Contents ==='));
|
||||
if (tarball.files.length) {
|
||||
console.log('');
|
||||
const columnData = columnify(
|
||||
tarball.files
|
||||
.map((f) => {
|
||||
const bytes = formatBytes(f.size, false);
|
||||
return /^node_modules\//.test(f.path)
|
||||
? null
|
||||
: { path: f.path, size: `${bytes}` };
|
||||
})
|
||||
.filter((f) => f),
|
||||
{
|
||||
include: ['size', 'path'],
|
||||
showHeaders: false,
|
||||
}
|
||||
);
|
||||
columnData.split('\n').forEach((line) => {
|
||||
console.log(line);
|
||||
});
|
||||
}
|
||||
if (tarball.bundled.length) {
|
||||
console.log(chalk.magenta('=== Bundled Dependencies ==='));
|
||||
tarball.bundled.forEach((name) => console.log('', name));
|
||||
}
|
||||
console.log(chalk.magenta('=== Tarball Details ==='));
|
||||
console.log(
|
||||
columnify(
|
||||
[
|
||||
{ name: 'name:', value: tarball.name },
|
||||
{ name: 'version:', value: tarball.version },
|
||||
tarball.filename && { name: 'filename:', value: tarball.filename },
|
||||
{ name: 'package size:', value: formatBytes(tarball.size) },
|
||||
{ name: 'unpacked size:', value: formatBytes(tarball.unpackedSize) },
|
||||
{ name: 'shasum:', value: tarball.shasum },
|
||||
{
|
||||
name: 'integrity:',
|
||||
value:
|
||||
tarball.integrity.toString().slice(0, 20) +
|
||||
'[...]' +
|
||||
tarball.integrity.toString().slice(80),
|
||||
},
|
||||
tarball.bundled.length && {
|
||||
name: 'bundled deps:',
|
||||
value: tarball.bundled.length,
|
||||
},
|
||||
tarball.bundled.length && {
|
||||
name: 'bundled files:',
|
||||
value: tarball.entryCount - tarball.files.length,
|
||||
},
|
||||
tarball.bundled.length && {
|
||||
name: 'own files:',
|
||||
value: tarball.files.length,
|
||||
},
|
||||
{ name: 'total files:', value: tarball.entryCount },
|
||||
].filter((x) => x),
|
||||
{
|
||||
include: ['name', 'value'],
|
||||
showHeaders: false,
|
||||
}
|
||||
)
|
||||
);
|
||||
console.log('', '');
|
||||
};
|
||||
@@ -0,0 +1,534 @@
|
||||
import {
|
||||
ExecutorContext,
|
||||
readJsonFile,
|
||||
detectPackageManager,
|
||||
} from '@nx/devkit';
|
||||
import { execSync } from 'child_process';
|
||||
import { PublishExecutorSchema } from './schema';
|
||||
import runExecutor from './release-publish.impl';
|
||||
import * as npmConfigModule from '../../utils/npm-config';
|
||||
import * as npmRunPath from 'npm-run-path';
|
||||
import * as extractModule from './extract-npm-publish-json-data';
|
||||
|
||||
jest.mock('child_process');
|
||||
jest.mock('npm-run-path', () => ({
|
||||
env: jest.fn(() => ({})),
|
||||
}));
|
||||
jest.mock('../../utils/npm-config');
|
||||
jest.mock('@nx/devkit', () => ({
|
||||
...jest.requireActual('@nx/devkit'),
|
||||
detectPackageManager: jest.fn(() => 'npm'),
|
||||
readJsonFile: jest.fn(),
|
||||
}));
|
||||
jest.mock('./extract-npm-publish-json-data');
|
||||
jest.mock('./log-tar');
|
||||
|
||||
describe('release-publish executor', () => {
|
||||
let context: ExecutorContext;
|
||||
let options: PublishExecutorSchema;
|
||||
const mockExecSync = execSync as jest.MockedFunction<typeof execSync>;
|
||||
const mockDetectPackageManager = detectPackageManager as jest.MockedFunction<
|
||||
typeof detectPackageManager
|
||||
>;
|
||||
const mockParseRegistryOptions =
|
||||
npmConfigModule.parseRegistryOptions as jest.MockedFunction<
|
||||
typeof npmConfigModule.parseRegistryOptions
|
||||
>;
|
||||
const mockReadJsonFile = readJsonFile as jest.MockedFunction<
|
||||
typeof readJsonFile
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDetectPackageManager.mockReturnValue('npm');
|
||||
jest.spyOn(console, 'log').mockImplementation();
|
||||
jest.spyOn(console, 'warn').mockImplementation();
|
||||
jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
context = {
|
||||
root: '/root',
|
||||
cwd: '/root',
|
||||
projectGraph: {
|
||||
nodes: {},
|
||||
dependencies: {},
|
||||
},
|
||||
projectsConfigurations: {
|
||||
version: 2,
|
||||
projects: {
|
||||
'test-project': {
|
||||
root: 'packages/test-package',
|
||||
},
|
||||
},
|
||||
},
|
||||
nxJsonConfiguration: {},
|
||||
isVerbose: false,
|
||||
projectName: 'test-project',
|
||||
targetName: 'release-publish',
|
||||
};
|
||||
|
||||
options = {
|
||||
packageRoot: 'packages/test-package',
|
||||
};
|
||||
|
||||
// Mock package.json reading
|
||||
mockReadJsonFile.mockReturnValue({
|
||||
name: '@scope/test-package',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
// Mock npm config parsing
|
||||
mockParseRegistryOptions.mockResolvedValue({
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
tag: 'latest',
|
||||
registryConfigKey: 'registry',
|
||||
});
|
||||
|
||||
// Default mock for npm --version check (first execSync call in the executor)
|
||||
mockExecSync.mockReturnValueOnce('11.5.1' as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('already published error handling', () => {
|
||||
function mockNpmViewNotFound() {
|
||||
mockExecSync.mockImplementationOnce(() => {
|
||||
const error: any = new Error('npm view failed');
|
||||
error.stdout = Buffer.from(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 'E404',
|
||||
summary: 'Not found',
|
||||
},
|
||||
})
|
||||
);
|
||||
error.stderr = Buffer.from('npm ERR! 404 Not Found');
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
it('should skip publishing when pnpm reports that the version was previously published', async () => {
|
||||
mockDetectPackageManager.mockReturnValue('pnpm');
|
||||
mockNpmViewNotFound();
|
||||
mockExecSync.mockImplementationOnce(() => {
|
||||
const error: any = new Error('pnpm publish failed');
|
||||
error.stdout = Buffer.from(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 'E403',
|
||||
message:
|
||||
'You cannot publish over the previously published versions: 1.0.0.',
|
||||
},
|
||||
})
|
||||
);
|
||||
error.stderr = Buffer.from('');
|
||||
throw error;
|
||||
});
|
||||
|
||||
const result = await runExecutor(options, context);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('has already been published')
|
||||
);
|
||||
expect(console.error).not.toHaveBeenCalledWith('pnpm publish error:');
|
||||
});
|
||||
|
||||
it('should skip publishing when raw publish output says the version was previously published', async () => {
|
||||
mockDetectPackageManager.mockReturnValue('pnpm');
|
||||
mockNpmViewNotFound();
|
||||
mockExecSync.mockImplementationOnce(() => {
|
||||
const error: any = new Error('pnpm publish failed');
|
||||
error.stdout = Buffer.from('not json');
|
||||
error.stderr = Buffer.from(
|
||||
'ERR_PNPM_PUBLISH_CONFLICT 403 Forbidden - You cannot publish over the previously published versions: 1.0.0.'
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
|
||||
const result = await runExecutor(options, context);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('has already been published')
|
||||
);
|
||||
expect(console.error).not.toHaveBeenCalledWith('pnpm publish error:');
|
||||
});
|
||||
|
||||
it('should fail when pnpm publish returns a generic 403 error', async () => {
|
||||
mockDetectPackageManager.mockReturnValue('pnpm');
|
||||
mockNpmViewNotFound();
|
||||
mockExecSync.mockImplementationOnce(() => {
|
||||
const error: any = new Error('pnpm publish failed');
|
||||
error.stdout = Buffer.from(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 'E403',
|
||||
message: '403 Forbidden - You do not have permission to publish',
|
||||
},
|
||||
})
|
||||
);
|
||||
error.stderr = Buffer.from('');
|
||||
throw error;
|
||||
});
|
||||
|
||||
const result = await runExecutor(options, context);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(console.error).toHaveBeenCalledWith('pnpm publish error:');
|
||||
expect(console.warn).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('has already been published')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nxReleaseVersionData skip behavior', () => {
|
||||
it('should skip publishing when nxReleaseVersionData indicates no new version', async () => {
|
||||
const optionsWithVersionData = {
|
||||
...options,
|
||||
nxReleaseVersionData: {
|
||||
'test-project': {
|
||||
currentVersion: '1.0.0',
|
||||
newVersion: null,
|
||||
dependentProjects: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await runExecutor(optionsWithVersionData, context);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Skipped')
|
||||
);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('no new version was resolved')
|
||||
);
|
||||
// Should only have called npm --version, not npm view or npm publish
|
||||
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should proceed with publishing when nxReleaseVersionData indicates a new version', async () => {
|
||||
mockExecSync
|
||||
.mockReturnValueOnce(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
versions: ['0.9.0'],
|
||||
'dist-tags': { latest: '0.9.0' },
|
||||
})
|
||||
)
|
||||
) // npm view
|
||||
.mockReturnValueOnce(Buffer.from('{}') as any); // npm publish
|
||||
|
||||
jest.spyOn(extractModule, 'extractNpmPublishJsonData').mockReturnValue({
|
||||
beforeJsonData: '',
|
||||
jsonData: {
|
||||
id: '@scope/test-package@1.0.0',
|
||||
name: '@scope/test-package',
|
||||
version: '1.0.0',
|
||||
size: 100,
|
||||
unpackedSize: 200,
|
||||
shasum: 'abc123',
|
||||
integrity: 'sha512-abc',
|
||||
filename: 'test-package-1.0.0.tgz',
|
||||
files: [],
|
||||
entryCount: 1,
|
||||
bundled: [],
|
||||
},
|
||||
afterJsonData: '',
|
||||
} as any);
|
||||
|
||||
const optionsWithVersionData = {
|
||||
...options,
|
||||
nxReleaseVersionData: {
|
||||
'test-project': {
|
||||
currentVersion: '0.9.0',
|
||||
newVersion: '1.0.0',
|
||||
dependentProjects: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await runExecutor(optionsWithVersionData, context);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Should have proceeded with npm --version, npm view, and publish
|
||||
expect(mockExecSync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should proceed with publishing when nxReleaseVersionData is not provided', async () => {
|
||||
mockExecSync
|
||||
.mockReturnValueOnce(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
versions: ['0.9.0'],
|
||||
'dist-tags': { latest: '0.9.0' },
|
||||
})
|
||||
)
|
||||
) // npm view
|
||||
.mockReturnValueOnce(Buffer.from('{}') as any); // npm publish
|
||||
|
||||
jest.spyOn(extractModule, 'extractNpmPublishJsonData').mockReturnValue({
|
||||
beforeJsonData: '',
|
||||
jsonData: {
|
||||
id: '@scope/test-package@1.0.0',
|
||||
name: '@scope/test-package',
|
||||
version: '1.0.0',
|
||||
size: 100,
|
||||
unpackedSize: 200,
|
||||
shasum: 'abc123',
|
||||
integrity: 'sha512-abc',
|
||||
filename: 'test-package-1.0.0.tgz',
|
||||
files: [],
|
||||
entryCount: 1,
|
||||
bundled: [],
|
||||
},
|
||||
afterJsonData: '',
|
||||
} as any);
|
||||
|
||||
const result = await runExecutor(options, context);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Should have proceeded with npm --version, npm view, and publish
|
||||
expect(mockExecSync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('npm dist-tag error handling', () => {
|
||||
it('returns failure and logs only the dist-tag add error when add fails with empty stdout', async () => {
|
||||
mockExecSync
|
||||
.mockReturnValueOnce(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
versions: ['1.0.0'],
|
||||
'dist-tags': { latest: '0.9.0' },
|
||||
})
|
||||
)
|
||||
)
|
||||
.mockImplementationOnce(() => {
|
||||
const error: any = new Error('npm dist-tag add failed');
|
||||
error.stdout = Buffer.from('');
|
||||
error.stderr = Buffer.from('npm ERR! permission denied');
|
||||
error.code = 1;
|
||||
throw error;
|
||||
});
|
||||
|
||||
const result = await runExecutor(options, context);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(console.error).toHaveBeenCalledWith('npm dist-tag add error:');
|
||||
expect(console.error).not.toHaveBeenCalledWith(
|
||||
'Something unexpected went wrong when processing the npm dist-tag add output\n',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('npm availability check', () => {
|
||||
it('should continue without error when pm is bun and npm is not installed', async () => {
|
||||
mockDetectPackageManager.mockReturnValue('bun');
|
||||
mockExecSync.mockReset();
|
||||
|
||||
// npm --version throws (npm not installed)
|
||||
mockExecSync
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('Command not found: npm');
|
||||
})
|
||||
// bun info call for view command
|
||||
.mockReturnValueOnce(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
versions: ['0.9.0'],
|
||||
'dist-tags': { latest: '0.9.0' },
|
||||
})
|
||||
)
|
||||
)
|
||||
// bun publish call
|
||||
.mockReturnValueOnce(Buffer.from('bun publish output'));
|
||||
|
||||
jest
|
||||
.spyOn(extractModule, 'extractNpmPublishJsonData')
|
||||
.mockReturnValue(null);
|
||||
|
||||
const result = await runExecutor(options, context);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Verify the view command used bun info (not npm view)
|
||||
expect(mockExecSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('bun info'),
|
||||
expect.anything()
|
||||
);
|
||||
// Verify npm dist-tag add was NOT called (npm not installed)
|
||||
expect(mockExecSync).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('npm dist-tag add'),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to npm publish when bun publish fails with an authentication error and npm is installed', async () => {
|
||||
mockDetectPackageManager.mockReturnValue('bun');
|
||||
mockExecSync.mockReset();
|
||||
|
||||
mockExecSync
|
||||
// npm --version succeeds (npm is installed)
|
||||
.mockReturnValueOnce('11.5.1' as any)
|
||||
// bun info (view) call
|
||||
.mockReturnValueOnce(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
versions: ['0.9.0'],
|
||||
'dist-tags': { latest: '0.9.0' },
|
||||
})
|
||||
)
|
||||
)
|
||||
// bun publish fails with missing authentication
|
||||
.mockImplementationOnce(() => {
|
||||
const error: any = new Error('bun publish failed');
|
||||
error.stdout = Buffer.from('');
|
||||
error.stderr = Buffer.from(
|
||||
'error: missing authentication (run `bunx npm login`)'
|
||||
);
|
||||
throw error;
|
||||
})
|
||||
// npm publish (fallback) succeeds
|
||||
.mockReturnValueOnce(Buffer.from('{}') as any);
|
||||
|
||||
jest.spyOn(extractModule, 'extractNpmPublishJsonData').mockReturnValue({
|
||||
beforeJsonData: '',
|
||||
jsonData: {
|
||||
id: '@scope/test-package@1.0.0',
|
||||
name: '@scope/test-package',
|
||||
version: '1.0.0',
|
||||
size: 100,
|
||||
unpackedSize: 200,
|
||||
shasum: 'abc123',
|
||||
integrity: 'sha512-abc',
|
||||
filename: 'test-package-1.0.0.tgz',
|
||||
files: [],
|
||||
entryCount: 1,
|
||||
bundled: [],
|
||||
},
|
||||
afterJsonData: '',
|
||||
} as any);
|
||||
|
||||
const result = await runExecutor(options, context);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// bun publish was tried first
|
||||
expect(mockExecSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('bun publish'),
|
||||
expect.anything()
|
||||
);
|
||||
// npm publish was tried after bun failed
|
||||
expect(mockExecSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('npm publish'),
|
||||
expect.anything()
|
||||
);
|
||||
// the user-facing fallback warning was logged
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('falling back to npm publish')
|
||||
);
|
||||
});
|
||||
|
||||
it('should not fall back to npm publish when bun publish fails with a non-auth error', async () => {
|
||||
mockDetectPackageManager.mockReturnValue('bun');
|
||||
mockExecSync.mockReset();
|
||||
|
||||
mockExecSync
|
||||
// npm --version succeeds (npm is installed)
|
||||
.mockReturnValueOnce('11.5.1' as any)
|
||||
// bun info (view) call
|
||||
.mockReturnValueOnce(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
versions: ['0.9.0'],
|
||||
'dist-tags': { latest: '0.9.0' },
|
||||
})
|
||||
)
|
||||
)
|
||||
// bun publish fails with a non-auth error (e.g., version conflict)
|
||||
.mockImplementationOnce(() => {
|
||||
const error: any = new Error('bun publish failed');
|
||||
error.stdout = Buffer.from('');
|
||||
error.stderr = Buffer.from(
|
||||
'error: version 1.0.0 already exists in the registry'
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
|
||||
const result = await runExecutor(options, context);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(console.error).toHaveBeenCalledWith('bun publish error:');
|
||||
// npm publish must NOT be attempted for non-auth bun errors
|
||||
expect(mockExecSync).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('npm publish'),
|
||||
expect.anything()
|
||||
);
|
||||
// the fallback warning must NOT have been logged
|
||||
expect(console.warn).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('falling back to npm publish')
|
||||
);
|
||||
});
|
||||
|
||||
it('should not fall back to npm publish when bun publish fails with an authentication error but npm is not installed', async () => {
|
||||
mockDetectPackageManager.mockReturnValue('bun');
|
||||
mockExecSync.mockReset();
|
||||
|
||||
mockExecSync
|
||||
// npm --version throws (npm not installed)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('Command not found: npm');
|
||||
})
|
||||
// bun info (view) call
|
||||
.mockReturnValueOnce(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
versions: ['0.9.0'],
|
||||
'dist-tags': { latest: '0.9.0' },
|
||||
})
|
||||
)
|
||||
)
|
||||
// bun publish fails with an auth error — but npm is unavailable, so no fallback
|
||||
.mockImplementationOnce(() => {
|
||||
const error: any = new Error('bun publish failed');
|
||||
error.stdout = Buffer.from('');
|
||||
error.stderr = Buffer.from(
|
||||
'error: missing authentication (run `bunx npm login`)'
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
|
||||
const result = await runExecutor(options, context);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(console.error).toHaveBeenCalledWith('bun publish error:');
|
||||
// npm publish must NOT be attempted when npm is unavailable
|
||||
expect(mockExecSync).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('npm publish'),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('should return failure when pm is not bun and npm is not installed', async () => {
|
||||
mockDetectPackageManager.mockReturnValue('pnpm');
|
||||
mockExecSync.mockReset();
|
||||
|
||||
// npm --version throws (npm not installed)
|
||||
mockExecSync.mockImplementationOnce(() => {
|
||||
throw new Error('Command not found: npm');
|
||||
});
|
||||
|
||||
const result = await runExecutor(options, context);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('npm was not found in the current environment')
|
||||
);
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"pnpm"')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,621 @@
|
||||
import {
|
||||
detectPackageManager,
|
||||
getPackageManagerCommand,
|
||||
ExecutorContext,
|
||||
readJsonFile,
|
||||
} from '@nx/devkit';
|
||||
import { execSync } from 'child_process';
|
||||
import { env as appendLocalEnv } from 'npm-run-path';
|
||||
import { join } from 'path';
|
||||
import { isLocallyLinkedPackageVersion } from '../../utils/is-locally-linked-package-version';
|
||||
import { parseRegistryOptions } from '../../utils/npm-config';
|
||||
import { extractNpmPublishJsonData } from './extract-npm-publish-json-data';
|
||||
import { logTar } from './log-tar';
|
||||
import { PublishExecutorSchema } from './schema';
|
||||
import chalk = require('chalk');
|
||||
|
||||
const LARGE_BUFFER = 1024 * 1000000;
|
||||
|
||||
function processEnv(color: boolean) {
|
||||
const env = {
|
||||
...process.env,
|
||||
...appendLocalEnv(),
|
||||
};
|
||||
|
||||
if (color) {
|
||||
env.FORCE_COLOR = `${color}`;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function isAlreadyPublishedPublishError(
|
||||
stdoutData?: any,
|
||||
stderr = '',
|
||||
stdout = ''
|
||||
): boolean {
|
||||
const error = stdoutData?.error;
|
||||
const errorMessage = [
|
||||
error?.summary,
|
||||
error?.detail,
|
||||
error?.message,
|
||||
error?.body?.error,
|
||||
stderr,
|
||||
stdout,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
return (
|
||||
error?.code === 'EPUBLISHCONFLICT' ||
|
||||
errorMessage.includes(
|
||||
'You cannot publish over the previously published versions'
|
||||
) ||
|
||||
(error?.code === 'E409' &&
|
||||
errorMessage.includes('this package is already present'))
|
||||
);
|
||||
}
|
||||
|
||||
export default async function runExecutor(
|
||||
options: PublishExecutorSchema,
|
||||
context: ExecutorContext
|
||||
) {
|
||||
const pm = detectPackageManager();
|
||||
|
||||
// Check if npm is installed (needed for dist-tag management and as fallback for view command)
|
||||
let isNpmInstalled = false;
|
||||
try {
|
||||
isNpmInstalled =
|
||||
execSync('npm --version', {
|
||||
encoding: 'utf-8',
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim() !== '';
|
||||
} catch {
|
||||
// Allow missing npm only when using bun
|
||||
if (pm !== 'bun') {
|
||||
console.error(
|
||||
`npm was not found in the current environment. This is only supported when using \`bun\` as a package manager, but your detected package manager is "${pm}"`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We need to check both the env var and the option because the executor may have been triggered
|
||||
* indirectly via dependsOn, in which case the env var will be set, but the option will not.
|
||||
*/
|
||||
const isDryRun = process.env.NX_DRY_RUN === 'true' || options.dryRun || false;
|
||||
|
||||
const projectConfig =
|
||||
context.projectsConfigurations!.projects[context.projectName!]!;
|
||||
|
||||
const packageRoot = join(
|
||||
context.root,
|
||||
options.packageRoot ?? projectConfig.root
|
||||
);
|
||||
|
||||
const packageJsonPath = join(packageRoot, 'package.json');
|
||||
const packageJson = readJsonFile(packageJsonPath);
|
||||
const packageName = packageJson.name;
|
||||
|
||||
/**
|
||||
* Whether or not dynamically replacing local dependency protocols (such as "workspace:*") is supported during `nx release publish` is
|
||||
* dependent on the package manager the user is using.
|
||||
*
|
||||
* npm does not support the workspace protocol at all, and `npm publish` does not support dynamically updating locally linked packages
|
||||
* during its packing phase, so we give the user a clear error message informing them of that.
|
||||
*
|
||||
* - `pnpm publish` provides ideal support, it has the possibility of providing JSON output consistent with npm
|
||||
* - `bun publish`, provides very good support, including all the flags we need apart from the JSON output, so we just have to accept that
|
||||
* it will look and feel different and print what it gives us and perform one bit of string manipulation for the dry-run case.
|
||||
* - `yarn npm publish`, IS NOT YET SUPPORTED, and will be tricky because it does not support the majority of the flags we need. However, it
|
||||
* does support replacing local dependency protocols with the correct version during its packing phase.
|
||||
*/
|
||||
if (pm === 'npm' || pm === 'yarn') {
|
||||
const depTypes = ['dependencies', 'devDependencies', 'peerDependencies'];
|
||||
for (const depType of depTypes) {
|
||||
const deps = packageJson[depType];
|
||||
if (deps) {
|
||||
for (const depName in deps) {
|
||||
if (isLocallyLinkedPackageVersion(deps[depName])) {
|
||||
if (pm === 'npm') {
|
||||
console.error(
|
||||
`Error: Cannot publish package "${packageName}" because it contains a local dependency protocol in its "${depType}", and your package manager is npm.
|
||||
|
||||
Please update the local dependency on "${depName}" to be a valid semantic version (e.g. using \`nx release\`) before publishing, or switch to pnpm or bun as a package manager, which support dynamically replacing these protocols during publishing.`
|
||||
);
|
||||
} else if (pm === 'yarn') {
|
||||
console.error(
|
||||
`Error: Cannot publish package "${packageName}" because it contains a local dependency protocol in its "${depType}", and your package manager is yarn.
|
||||
|
||||
Currently, yarn is not supported for this use case because its \`yarn npm publish\` command does not support the customization needed.
|
||||
|
||||
Please update the local dependency on "${depName}" to be a valid semantic version (e.g. using \`nx release\`) before publishing, or switch to pnpm or bun as a package manager, which support dynamically replacing these protocols during publishing.`
|
||||
);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If package and project name match, we can make log messages terser
|
||||
let packageTxt =
|
||||
packageName === context.projectName
|
||||
? `package "${packageName}"`
|
||||
: `package "${packageName}" from project "${context.projectName}"`;
|
||||
|
||||
if (packageJson.private === true) {
|
||||
console.warn(
|
||||
`Skipped ${packageTxt}, because it has \`"private": true\` in ${packageJsonPath}`
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* If version data was provided by the nx release version step, check if this project
|
||||
* actually had a new version resolved. If not (newVersion is null), there is nothing
|
||||
* to publish, so we can skip this project entirely.
|
||||
*/
|
||||
if (options.nxReleaseVersionData) {
|
||||
const projectVersionData =
|
||||
options.nxReleaseVersionData[context.projectName];
|
||||
if (projectVersionData && projectVersionData.newVersion === null) {
|
||||
console.warn(
|
||||
`Skipped ${packageTxt}, because no new version was resolved for this project`
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const warnFn = (message: string) => {
|
||||
console.log(chalk.keyword('orange')(message));
|
||||
};
|
||||
const { registry, tag, registryConfigKey } = await parseRegistryOptions(
|
||||
context.root,
|
||||
{
|
||||
packageRoot,
|
||||
packageJson,
|
||||
},
|
||||
{
|
||||
registry: options.registry,
|
||||
tag: options.tag,
|
||||
},
|
||||
warnFn
|
||||
);
|
||||
|
||||
// Use bun info when bun is the package manager, otherwise use npm view
|
||||
// (npm view works across npm/pnpm/yarn environments and is the established default)
|
||||
const npmViewCommandSegments =
|
||||
pm === 'bun'
|
||||
? ['bun info', packageName, `--json --"${registryConfigKey}=${registry}"`]
|
||||
: [
|
||||
`npm view ${packageName} versions dist-tags --json --"${registryConfigKey}=${registry}"`,
|
||||
];
|
||||
const npmDistTagAddCommandSegments = [
|
||||
`npm dist-tag add ${packageName}@${packageJson.version} ${tag} --"${registryConfigKey}=${registry}"`,
|
||||
];
|
||||
|
||||
/**
|
||||
* In a dry-run scenario, it is most likely that all commands are being run with dry-run, therefore
|
||||
* the most up to date/relevant version might not exist on disk for us to read and make the npm view
|
||||
* request with.
|
||||
*
|
||||
* Therefore, so as to not produce misleading output in dry around dist-tags being altered, we do not
|
||||
* perform the npm view step, and just show npm/pnpm publish's dry-run output.
|
||||
*/
|
||||
if (!isDryRun && !options.firstRelease) {
|
||||
const currentVersion = packageJson.version;
|
||||
try {
|
||||
const result = execSync(npmViewCommandSegments.join(' '), {
|
||||
env: processEnv(true),
|
||||
cwd: context.root,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
const resultJson = JSON.parse(result.toString());
|
||||
const distTags = resultJson['dist-tags'] || {};
|
||||
if (distTags[tag] === currentVersion) {
|
||||
console.warn(
|
||||
`Skipped ${packageTxt} because v${currentVersion} already exists in ${registry} with tag "${tag}"`
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isNpmInstalled) {
|
||||
// If only one version of a package exists in the registry, versions will be a string instead of an array.
|
||||
const versions = Array.isArray(resultJson.versions)
|
||||
? resultJson.versions
|
||||
: [resultJson.versions];
|
||||
|
||||
if (versions.includes(currentVersion)) {
|
||||
try {
|
||||
if (!isDryRun) {
|
||||
execSync(npmDistTagAddCommandSegments.join(' '), {
|
||||
env: processEnv(true),
|
||||
cwd: context.root,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
console.log(
|
||||
`Added the dist-tag ${tag} to v${currentVersion} for registry ${registry}.\n`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`Would add the dist-tag ${tag} to v${currentVersion} for registry ${registry}, but ${chalk.keyword(
|
||||
'orange'
|
||||
)('[dry-run]')} was set.\n`
|
||||
);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (err) {
|
||||
try {
|
||||
const stdoutData = JSON.parse(err.stdout?.toString() || '{}');
|
||||
|
||||
// If the error is that the package doesn't exist, then we can ignore it because we will be publishing it for the first time in the next step
|
||||
if (
|
||||
!(
|
||||
stdoutData.error?.code?.includes('E404') &&
|
||||
stdoutData.error?.summary?.includes(
|
||||
'no such package available'
|
||||
)
|
||||
) &&
|
||||
!(
|
||||
err.stderr?.toString().includes('E404') &&
|
||||
err.stderr?.toString().includes('no such package available')
|
||||
)
|
||||
) {
|
||||
console.error('npm dist-tag add error:');
|
||||
// npm returns error.summary and error.detail
|
||||
if (stdoutData.error?.summary) {
|
||||
console.error(stdoutData.error.summary);
|
||||
}
|
||||
if (stdoutData.error?.detail) {
|
||||
console.error(stdoutData.error.detail);
|
||||
}
|
||||
// pnpm returns error.code and error.message
|
||||
if (stdoutData.error?.code && !stdoutData.error?.summary) {
|
||||
console.error(`Error code: ${stdoutData.error.code}`);
|
||||
}
|
||||
if (stdoutData.error?.message && !stdoutData.error?.summary) {
|
||||
console.error(stdoutData.error.message);
|
||||
}
|
||||
|
||||
if (context.isVerbose) {
|
||||
console.error('npm dist-tag add stdout:');
|
||||
console.error(JSON.stringify(stdoutData, null, 2));
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'Something unexpected went wrong when processing the npm dist-tag add output\n',
|
||||
err
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
try {
|
||||
const stdoutData = JSON.parse(err.stdout?.toString() || '{}');
|
||||
// If the error is that the package doesn't exist, then we can ignore it because we will be publishing it for the first time in the next step
|
||||
if (
|
||||
!(
|
||||
stdoutData.error?.code?.includes('E404') &&
|
||||
stdoutData.error?.summary?.toLowerCase().includes('not found')
|
||||
) &&
|
||||
!(
|
||||
err.stderr?.toString().includes('E404') &&
|
||||
err.stderr?.toString().toLowerCase().includes('not found')
|
||||
) &&
|
||||
// bun uses plain '404' instead of 'E404'
|
||||
!(
|
||||
err.stderr?.toString().includes('404') &&
|
||||
err.stderr?.toString().toLowerCase().includes('not found')
|
||||
)
|
||||
) {
|
||||
console.error(
|
||||
`Something unexpected went wrong when checking for existing dist-tags.\n`,
|
||||
err
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// JSON parse failed entirely — check stderr/stdout for plain 404
|
||||
const stderrStr = err.stderr?.toString() || '';
|
||||
const stdoutStr = err.stdout?.toString() || '';
|
||||
if (
|
||||
!(
|
||||
(stderrStr.includes('404') &&
|
||||
stderrStr.toLowerCase().includes('not found')) ||
|
||||
(stdoutStr.includes('404') &&
|
||||
stdoutStr.toLowerCase().includes('not found'))
|
||||
)
|
||||
) {
|
||||
console.error(
|
||||
`Something unexpected went wrong when checking for existing dist-tags.\n`,
|
||||
err
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.firstRelease && context.isVerbose) {
|
||||
console.log('Skipped npm view because --first-release was set');
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: If this is ever changed away from running the command at the workspace root and pointing at the package root (e.g. back
|
||||
* to running from the package root directly), then special attention should be paid to the fact that npm/pnpm publish will nest its
|
||||
* JSON output under the name of the package in that case (and it would need to be handled below).
|
||||
*/
|
||||
return runPublish({
|
||||
pm,
|
||||
options,
|
||||
context,
|
||||
packageRoot,
|
||||
packageJson,
|
||||
registry,
|
||||
registryConfigKey,
|
||||
tag,
|
||||
isDryRun,
|
||||
isNpmInstalled,
|
||||
packageTxt,
|
||||
});
|
||||
}
|
||||
|
||||
interface RunPublishContext {
|
||||
pm: ReturnType<typeof detectPackageManager>;
|
||||
options: PublishExecutorSchema;
|
||||
context: ExecutorContext;
|
||||
packageRoot: string;
|
||||
packageJson: any;
|
||||
registry: string;
|
||||
registryConfigKey: string;
|
||||
tag: string;
|
||||
isDryRun: boolean;
|
||||
isNpmInstalled: boolean;
|
||||
packageTxt: string;
|
||||
}
|
||||
|
||||
function runPublish(ctx: RunPublishContext): { success: boolean } {
|
||||
const {
|
||||
pm,
|
||||
options,
|
||||
context,
|
||||
packageRoot,
|
||||
packageJson,
|
||||
registry,
|
||||
registryConfigKey,
|
||||
tag,
|
||||
isDryRun,
|
||||
isNpmInstalled,
|
||||
packageTxt,
|
||||
} = ctx;
|
||||
const pmCommand = getPackageManagerCommand(pm);
|
||||
const publishCommandSegments = [
|
||||
pmCommand.publish(packageRoot, registry, registryConfigKey, tag),
|
||||
];
|
||||
|
||||
if (options.otp) {
|
||||
publishCommandSegments.push(`--otp=${options.otp}`);
|
||||
}
|
||||
|
||||
if (options.access) {
|
||||
publishCommandSegments.push(`--access=${options.access}`);
|
||||
}
|
||||
|
||||
if (isDryRun) {
|
||||
publishCommandSegments.push(`--dry-run`);
|
||||
}
|
||||
|
||||
try {
|
||||
const output = execSync(publishCommandSegments.join(' '), {
|
||||
maxBuffer: LARGE_BUFFER,
|
||||
env: processEnv(true),
|
||||
cwd: context.root,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
// If in dry-run mode, the version on disk will not represent the version that would be published, so we scrub it from the output to avoid confusion.
|
||||
const dryRunVersionPlaceholder = 'X.X.X-dry-run';
|
||||
|
||||
const publishSummaryMessage = isDryRun
|
||||
? `Would publish to ${registry} with tag "${tag}", but ${chalk.keyword(
|
||||
'orange'
|
||||
)('[dry-run]')} was set`
|
||||
: `Published to ${registry} with tag "${tag}"`;
|
||||
|
||||
// bun publish does not support outputting JSON, so we need to modify and print the output string directly
|
||||
if (pm === 'bun') {
|
||||
let outputStr = output.toString();
|
||||
if (isDryRun) {
|
||||
outputStr = outputStr.replace(
|
||||
new RegExp(`${packageJson.name}@${packageJson.version}`, 'g'),
|
||||
`${packageJson.name}@${dryRunVersionPlaceholder}`
|
||||
);
|
||||
}
|
||||
console.log(outputStr);
|
||||
console.log(publishSummaryMessage);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* We cannot JSON.parse the output directly because if the user is using lifecycle scripts, npm/pnpm will mix its publish output with the JSON output all on stdout.
|
||||
* Additionally, we want to capture and show the lifecycle script outputs as beforeJsonData and afterJsonData and print them accordingly below.
|
||||
*/
|
||||
const { beforeJsonData, jsonData, afterJsonData } =
|
||||
extractNpmPublishJsonData(output.toString());
|
||||
if (!jsonData) {
|
||||
console.error(
|
||||
`The ${pm} publish output data could not be extracted. Please report this issue on https://github.com/nrwl/nx`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (isDryRun) {
|
||||
for (const [key, val] of Object.entries(jsonData)) {
|
||||
if (typeof val !== 'string') {
|
||||
continue;
|
||||
}
|
||||
jsonData[key] = val.replace(
|
||||
new RegExp(packageJson.version, 'g'),
|
||||
dryRunVersionPlaceholder
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof beforeJsonData === 'string' &&
|
||||
beforeJsonData.trim().length > 0
|
||||
) {
|
||||
console.log(beforeJsonData);
|
||||
}
|
||||
|
||||
logTar(jsonData);
|
||||
|
||||
if (typeof afterJsonData === 'string' && afterJsonData.trim().length > 0) {
|
||||
console.log(afterJsonData);
|
||||
}
|
||||
|
||||
// Print the summary message after the JSON data has been printed
|
||||
console.log(publishSummaryMessage);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (err) {
|
||||
try {
|
||||
// bun publish does not support outputting JSON, so we cannot perform any further processing
|
||||
if (pm === 'bun') {
|
||||
const bunStderr = err.stderr?.toString() || '';
|
||||
const bunStdout = err.stdout?.toString() || '';
|
||||
// bun publish does not yet support npm's OIDC trusted publishing flow. If the failure
|
||||
// looks like an authentication error and npm is available, retry via npm to recover.
|
||||
// Other failures (e.g. version conflict, 403) would produce the same error from npm,
|
||||
// so we surface bun's error directly instead.
|
||||
const looksLikeAuthError =
|
||||
/missing authentication|bunx npm login|unauthorized|\b401\b/i.test(
|
||||
bunStderr + bunStdout
|
||||
);
|
||||
if (isNpmInstalled && looksLikeAuthError) {
|
||||
console.warn(
|
||||
`bun publish failed with an authentication error; falling back to npm publish (bun does not support npm OIDC trusted publishing).`
|
||||
);
|
||||
return runPublish({ ...ctx, pm: 'npm' });
|
||||
}
|
||||
console.error(`bun publish error:`);
|
||||
console.error(bunStderr);
|
||||
console.error(bunStdout);
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// stdout is not guaranteed to be JSON (e.g. lifecycle script failures).
|
||||
// If parsing fails, print raw stderr/stdout so the underlying error is visible to the user.
|
||||
let stdoutData;
|
||||
try {
|
||||
stdoutData = JSON.parse(err.stdout?.toString() || '{}');
|
||||
} catch {
|
||||
const stderr = err.stderr?.toString() || '';
|
||||
const stdout = err.stdout?.toString() || '';
|
||||
if (isAlreadyPublishedPublishError(undefined, stderr, stdout)) {
|
||||
console.warn(
|
||||
`Skipped ${packageTxt}, as v${packageJson.version} has already been published to ${registry} with tag "${tag}"`
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
console.error(err.stderr?.toString() || '');
|
||||
console.error(err.stdout?.toString() || '');
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
isAlreadyPublishedPublishError(
|
||||
stdoutData,
|
||||
err.stderr?.toString() || '',
|
||||
err.stdout?.toString() || ''
|
||||
)
|
||||
) {
|
||||
console.warn(
|
||||
`Skipped ${packageTxt}, as v${packageJson.version} has already been published to ${registry} with tag "${tag}"`
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
console.error(`${pm} publish error:`);
|
||||
// npm returns error.summary and error.detail
|
||||
if (stdoutData.error?.summary) {
|
||||
console.error(stdoutData.error.summary);
|
||||
}
|
||||
if (stdoutData.error?.detail) {
|
||||
console.error(stdoutData.error.detail);
|
||||
}
|
||||
// pnpm returns error.code and error.message
|
||||
if (stdoutData.error?.code && !stdoutData.error?.summary) {
|
||||
console.error(`Error code: ${stdoutData.error.code}`);
|
||||
}
|
||||
if (stdoutData.error?.message && !stdoutData.error?.summary) {
|
||||
console.error(stdoutData.error.message);
|
||||
}
|
||||
|
||||
if (context.isVerbose) {
|
||||
console.error(`${pm} publish stdout:`);
|
||||
console.error(JSON.stringify(stdoutData, null, 2));
|
||||
}
|
||||
|
||||
if (!stdoutData.error) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Something unexpected went wrong when processing the ${pm} publish output\n`,
|
||||
err
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface PublishExecutorSchema {
|
||||
packageRoot?: string;
|
||||
registry?: string;
|
||||
tag?: string;
|
||||
otp?: number;
|
||||
dryRun?: boolean;
|
||||
access?: 'public' | 'restricted';
|
||||
firstRelease?: boolean;
|
||||
nxReleaseVersionData?: Record<
|
||||
string,
|
||||
{ currentVersion: string; newVersion: string | null; [key: string]: any }
|
||||
>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"version": 2,
|
||||
"title": "Implementation details of `nx release publish`",
|
||||
"description": "DO NOT INVOKE DIRECTLY WITH `nx run`. Use `nx release publish` instead.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"packageRoot": {
|
||||
"type": "string",
|
||||
"description": "The root directory of the directory (containing a manifest file at its root) to publish. Defaults to the project root."
|
||||
},
|
||||
"registry": {
|
||||
"type": "string",
|
||||
"description": "The registry to publish the package to."
|
||||
},
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"description": "The distribution tag to apply to the published package."
|
||||
},
|
||||
"access": {
|
||||
"type": "string",
|
||||
"enum": ["public", "restricted"],
|
||||
"description": "Overrides the access level of the published package. Unscoped packages cannot be set to restricted. See the npm publish documentation for more information."
|
||||
},
|
||||
"dryRun": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to run the command without actually publishing the package to the registry."
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"version": 2,
|
||||
"outputCapture": "direct-nodejs",
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"cli": "nx",
|
||||
"title": "Typescript Build Target",
|
||||
"description": "Builds using SWC.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"main": {
|
||||
"type": "string",
|
||||
"description": "The name of the main entry-point file.",
|
||||
"x-completion-type": "file",
|
||||
"x-completion-glob": "main@(.js|.ts|.tsx)",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"generateExportsField": {
|
||||
"type": "boolean",
|
||||
"alias": "exports",
|
||||
"description": "Update the output package.json file's 'exports' field. This field is used by Node and bundles.",
|
||||
"x-priority": "important",
|
||||
"default": false
|
||||
},
|
||||
"additionalEntryPoints": {
|
||||
"type": "array",
|
||||
"description": "Additional entry-points to add to exports field in the package.json file.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"x-priority": "important"
|
||||
},
|
||||
"outputPath": {
|
||||
"type": "string",
|
||||
"description": "The output path of the generated files.",
|
||||
"x-completion-type": "directory",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"tsConfig": {
|
||||
"type": "string",
|
||||
"description": "The path to the Typescript configuration file.",
|
||||
"x-completion-type": "file",
|
||||
"x-completion-glob": "tsconfig.*.json",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"swcrc": {
|
||||
"type": "string",
|
||||
"description": "The path to the SWC configuration file. Default: .swcrc",
|
||||
"x-completion-type": "file",
|
||||
"x-completion-glob": ".swcrc"
|
||||
},
|
||||
"assets": {
|
||||
"type": "array",
|
||||
"description": "List of static assets.",
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/assetPattern"
|
||||
}
|
||||
},
|
||||
"watch": {
|
||||
"type": "boolean",
|
||||
"description": "Enable re-building when files change.",
|
||||
"default": false
|
||||
},
|
||||
"clean": {
|
||||
"type": "boolean",
|
||||
"description": "Remove previous output before build.",
|
||||
"default": true
|
||||
},
|
||||
"skipTypeCheck": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to skip TypeScript type checking.",
|
||||
"default": false,
|
||||
"x-priority": "important"
|
||||
},
|
||||
"swcExclude": {
|
||||
"type": "array",
|
||||
"description": "List of SWC Glob/Regex to be excluded from compilation (https://swc.rs/docs/configuration/compilation#exclude).",
|
||||
"default": [
|
||||
"./src/**/.*.spec.ts$",
|
||||
"./**/.*.spec.ts$",
|
||||
"./src/**/jest-setup.ts$",
|
||||
"./**/jest-setup.ts$",
|
||||
"./**/.*.js$"
|
||||
],
|
||||
"hidden": true
|
||||
},
|
||||
"generateLockfile": {
|
||||
"type": "boolean",
|
||||
"description": "Generate a lockfile (e.g. package-lock.json) that matches the workspace lockfile to ensure package versions match.",
|
||||
"default": false,
|
||||
"x-priority": "internal"
|
||||
},
|
||||
"stripLeadingPaths": {
|
||||
"type": "boolean",
|
||||
"description": "Remove leading directory from output (e.g. src). See: https://swc.rs/docs/usage/cli#--strip-leading-paths",
|
||||
"default": false
|
||||
},
|
||||
"includeIgnoredAssetFiles": {
|
||||
"type": "boolean",
|
||||
"description": "Include files that are ignored by .gitignore and .nxignore when copying assets. WARNING: Ignored files are not automatically considered when calculating the task hash. To ensure Nx tracks these files for caching, add them to your target's inputs using 'dependentTasksOutputs' or 'runtime' configuration.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["main", "outputPath", "tsConfig"],
|
||||
"definitions": {
|
||||
"assetPattern": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"glob": {
|
||||
"type": "string",
|
||||
"description": "The pattern to match."
|
||||
},
|
||||
"input": {
|
||||
"type": "string",
|
||||
"description": "The input directory path in which to apply 'glob'. Defaults to the project root."
|
||||
},
|
||||
"ignore": {
|
||||
"description": "An array of globs to ignore.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "string",
|
||||
"description": "Absolute path within the output."
|
||||
},
|
||||
"includeIgnoredFiles": {
|
||||
"type": "boolean",
|
||||
"description": "Include files that are ignored by .gitignore and .nxignore for this specific asset pattern. WARNING: Ignored files are not automatically considered when calculating the task hash. To ensure Nx tracks these files for caching, add them to your target's inputs using 'dependentTasksOutputs' or 'runtime' configuration.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["glob", "input", "output"]
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"examplesFile": "../../../docs/swc-examples.md"
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { ExecutorContext, readJsonFile } from '@nx/devkit';
|
||||
import { rmSync } from 'node:fs';
|
||||
import { dirname, join, normalize, relative, resolve } from 'path';
|
||||
import { globSync } from 'tinyglobby';
|
||||
import { copyAssets } from '../../utils/assets';
|
||||
import { assetGlobsToFiles, FileInputOutput } from '../../utils/assets/assets';
|
||||
import type { DependentBuildableProjectNode } from '../../utils/buildable-libs-utils';
|
||||
import { checkDependencies } from '../../utils/check-dependencies';
|
||||
import {
|
||||
getHelperDependency,
|
||||
HelperDependency,
|
||||
} from '../../utils/compiler-helper-dependency';
|
||||
import {
|
||||
copyPackageJson,
|
||||
type CopyPackageJsonResult,
|
||||
} from '../../utils/package-json';
|
||||
import {
|
||||
NormalizedSwcExecutorOptions,
|
||||
SwcExecutorOptions,
|
||||
} from '../../utils/schema';
|
||||
import { compileSwc, compileSwcWatch } from '../../utils/swc/compile-swc';
|
||||
import { getSwcrcPath } from '../../utils/swc/get-swcrc-path';
|
||||
import { isUsingTsSolutionSetup } from '../../utils/typescript/ts-solution-setup';
|
||||
|
||||
function normalizeOptions(
|
||||
options: SwcExecutorOptions,
|
||||
root: string,
|
||||
sourceRoot: string,
|
||||
projectRoot: string
|
||||
): NormalizedSwcExecutorOptions {
|
||||
const isTsSolutionSetup = isUsingTsSolutionSetup();
|
||||
if (isTsSolutionSetup) {
|
||||
if (options.generateLockfile) {
|
||||
throw new Error(
|
||||
`Setting 'generateLockfile: true' is not supported with the current TypeScript setup. Unset the 'generateLockfile' option and try again.`
|
||||
);
|
||||
}
|
||||
if (options.generateExportsField) {
|
||||
throw new Error(
|
||||
`Setting 'generateExportsField: true' is not supported with the current TypeScript setup. Set 'exports' field in the 'package.json' file at the project root and unset the 'generateExportsField' option.`
|
||||
);
|
||||
}
|
||||
if (options.additionalEntryPoints?.length) {
|
||||
throw new Error(
|
||||
`Setting 'additionalEntryPoints' is not supported with the current TypeScript setup. Set additional entry points in the 'package.json' file at the project root and unset the 'additionalEntryPoints' option.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const outputPath = join(root, options.outputPath);
|
||||
|
||||
options.skipTypeCheck ??= !isTsSolutionSetup;
|
||||
|
||||
if (options.watch == null) {
|
||||
options.watch = false;
|
||||
}
|
||||
|
||||
const files: FileInputOutput[] = assetGlobsToFiles(
|
||||
options.assets,
|
||||
root,
|
||||
outputPath
|
||||
);
|
||||
|
||||
// Always execute from root of project, same as with SWC CLI.
|
||||
const swcCwd = join(root, projectRoot);
|
||||
const { swcrcPath, tmpSwcrcPath } = getSwcrcPath(options, root, projectRoot);
|
||||
|
||||
const swcCliOptions = {
|
||||
srcPath: projectRoot,
|
||||
destPath: relative(swcCwd, outputPath),
|
||||
swcCwd,
|
||||
swcrcPath,
|
||||
stripLeadingPaths: Boolean(options.stripLeadingPaths),
|
||||
};
|
||||
|
||||
return {
|
||||
...options,
|
||||
mainOutputPath: resolve(
|
||||
outputPath,
|
||||
options.main.replace(`${projectRoot}/`, '').replace('.ts', '.js')
|
||||
),
|
||||
files,
|
||||
root,
|
||||
sourceRoot,
|
||||
projectRoot,
|
||||
originalProjectRoot: projectRoot,
|
||||
outputPath,
|
||||
tsConfig: join(root, options.tsConfig),
|
||||
swcCliOptions,
|
||||
tmpSwcrcPath,
|
||||
isTsSolutionSetup: isTsSolutionSetup,
|
||||
} as NormalizedSwcExecutorOptions;
|
||||
}
|
||||
|
||||
export async function* swcExecutor(
|
||||
_options: SwcExecutorOptions,
|
||||
context: ExecutorContext
|
||||
) {
|
||||
const { sourceRoot, root } =
|
||||
context.projectsConfigurations.projects[context.projectName];
|
||||
const options = normalizeOptions(_options, context.root, sourceRoot, root);
|
||||
|
||||
let swcHelperDependency: DependentBuildableProjectNode;
|
||||
if (!options.isTsSolutionSetup) {
|
||||
const { tmpTsConfig, dependencies } = checkDependencies(
|
||||
context,
|
||||
options.tsConfig
|
||||
);
|
||||
|
||||
if (tmpTsConfig) {
|
||||
options.tsConfig = tmpTsConfig;
|
||||
}
|
||||
|
||||
swcHelperDependency = getHelperDependency(
|
||||
HelperDependency.swc,
|
||||
options.swcCliOptions.swcrcPath,
|
||||
dependencies,
|
||||
context.projectGraph
|
||||
);
|
||||
|
||||
if (swcHelperDependency) {
|
||||
dependencies.push(swcHelperDependency);
|
||||
}
|
||||
}
|
||||
|
||||
function determineModuleFormatFromSwcrc(
|
||||
absolutePathToSwcrc: string
|
||||
): 'cjs' | 'esm' {
|
||||
const swcrc = readJsonFile(absolutePathToSwcrc);
|
||||
return swcrc.module?.type?.startsWith('es') ? 'esm' : 'cjs';
|
||||
}
|
||||
|
||||
if (options.watch) {
|
||||
let disposeFn: () => void;
|
||||
process.on('SIGINT', () => disposeFn?.());
|
||||
process.on('SIGTERM', () => disposeFn?.());
|
||||
|
||||
return yield* compileSwcWatch(context, options, async () => {
|
||||
const assetResult = await copyAssets(options, context);
|
||||
let packageJsonResult: CopyPackageJsonResult;
|
||||
if (!options.isTsSolutionSetup) {
|
||||
packageJsonResult = await copyPackageJson(
|
||||
{
|
||||
...options,
|
||||
additionalEntryPoints: createEntryPoints(options, context),
|
||||
format: [
|
||||
determineModuleFormatFromSwcrc(options.swcCliOptions.swcrcPath),
|
||||
],
|
||||
},
|
||||
context
|
||||
);
|
||||
}
|
||||
removeTmpSwcrc(options.swcCliOptions.swcrcPath);
|
||||
disposeFn = () => {
|
||||
assetResult?.stop();
|
||||
packageJsonResult?.stop();
|
||||
};
|
||||
});
|
||||
} else {
|
||||
return yield compileSwc(context, options, async () => {
|
||||
await copyAssets(options, context);
|
||||
if (!options.isTsSolutionSetup) {
|
||||
await copyPackageJson(
|
||||
{
|
||||
...options,
|
||||
additionalEntryPoints: createEntryPoints(options, context),
|
||||
format: [
|
||||
determineModuleFormatFromSwcrc(options.swcCliOptions.swcrcPath),
|
||||
],
|
||||
extraDependencies: swcHelperDependency ? [swcHelperDependency] : [],
|
||||
},
|
||||
context
|
||||
);
|
||||
}
|
||||
removeTmpSwcrc(options.swcCliOptions.swcrcPath);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function removeTmpSwcrc(swcrcPath: string) {
|
||||
if (
|
||||
swcrcPath.includes(normalize('tmp/')) &&
|
||||
swcrcPath.includes('.generated.swcrc')
|
||||
) {
|
||||
rmSync(dirname(swcrcPath), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function createEntryPoints(
|
||||
options: { additionalEntryPoints?: string[] },
|
||||
context: ExecutorContext
|
||||
): string[] {
|
||||
if (!options.additionalEntryPoints?.length) return [];
|
||||
return globSync(options.additionalEntryPoints, {
|
||||
cwd: context.root,
|
||||
expandDirectories: false,
|
||||
});
|
||||
}
|
||||
|
||||
export default swcExecutor;
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { ExecutorContext } from '@nx/devkit';
|
||||
import { parseTargetString } from '@nx/devkit';
|
||||
import { join, relative } from 'path';
|
||||
import { CopyAssetsHandler } from '../../../../utils/assets/copy-assets-handler';
|
||||
import { calculateProjectBuildableDependencies } from '../../../../utils/buildable-libs-utils';
|
||||
import type { NormalizedExecutorOptions } from '../../../../utils/schema';
|
||||
import { getTaskOptions } from '../get-task-options';
|
||||
import type { TypescriptInMemoryTsConfig } from '../typescript-compilation';
|
||||
import type { TaskInfo } from './types';
|
||||
|
||||
const taskTsConfigCache = new Set<string>();
|
||||
|
||||
export function createTaskInfoPerTsConfigMap(
|
||||
tasksOptions: Record<string, NormalizedExecutorOptions>,
|
||||
context: ExecutorContext,
|
||||
tasks: string[],
|
||||
taskInMemoryTsConfigMap: Record<string, TypescriptInMemoryTsConfig>
|
||||
): Record<string, TaskInfo> {
|
||||
const tsConfigTaskInfoMap: Record<string, TaskInfo> = {};
|
||||
|
||||
processTasksAndPopulateTsConfigTaskInfoMap(
|
||||
tsConfigTaskInfoMap,
|
||||
tasksOptions,
|
||||
context,
|
||||
tasks,
|
||||
taskInMemoryTsConfigMap
|
||||
);
|
||||
|
||||
return tsConfigTaskInfoMap;
|
||||
}
|
||||
|
||||
function processTasksAndPopulateTsConfigTaskInfoMap(
|
||||
tsConfigTaskInfoMap: Record<string, TaskInfo>,
|
||||
tasksOptions: Record<string, NormalizedExecutorOptions>,
|
||||
context: ExecutorContext,
|
||||
tasks: string[],
|
||||
taskInMemoryTsConfigMap: Record<string, TypescriptInMemoryTsConfig>
|
||||
): void {
|
||||
for (const taskName of tasks) {
|
||||
if (taskTsConfigCache.has(taskName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tsConfig = taskInMemoryTsConfigMap[taskName];
|
||||
if (!tsConfig) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let taskOptions =
|
||||
tasksOptions[taskName] ?? getTaskOptions(taskName, context);
|
||||
if (taskOptions) {
|
||||
const taskInfo = createTaskInfo(taskName, taskOptions, context, tsConfig);
|
||||
const tsConfigPath = join(
|
||||
context.root,
|
||||
relative(context.root, taskOptions.tsConfig)
|
||||
).replace(/\\/g, '/');
|
||||
|
||||
tsConfigTaskInfoMap[tsConfigPath] = taskInfo;
|
||||
taskTsConfigCache.add(taskName);
|
||||
}
|
||||
|
||||
processTasksAndPopulateTsConfigTaskInfoMap(
|
||||
tsConfigTaskInfoMap,
|
||||
tasksOptions,
|
||||
context,
|
||||
context.taskGraph.dependencies[taskName],
|
||||
taskInMemoryTsConfigMap
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createTaskInfo(
|
||||
taskName: string,
|
||||
taskOptions: NormalizedExecutorOptions,
|
||||
context: ExecutorContext,
|
||||
tsConfig: TypescriptInMemoryTsConfig
|
||||
): TaskInfo {
|
||||
const target = parseTargetString(taskName, context);
|
||||
|
||||
const taskContext = {
|
||||
...context,
|
||||
// batch executors don't get these in the context, we provide them
|
||||
// here per task
|
||||
projectName: target.project,
|
||||
targetName: target.target,
|
||||
configurationName: target.configuration,
|
||||
};
|
||||
|
||||
const assetsHandler = new CopyAssetsHandler({
|
||||
projectDir: taskOptions.projectRoot,
|
||||
rootDir: context.root,
|
||||
outputDir: taskOptions.outputPath,
|
||||
assets: taskOptions.assets,
|
||||
includeIgnoredFiles: taskOptions.includeIgnoredAssetFiles,
|
||||
});
|
||||
|
||||
const {
|
||||
target: projectGraphNode,
|
||||
dependencies: buildableProjectNodeDependencies,
|
||||
} = calculateProjectBuildableDependencies(
|
||||
context.taskGraph,
|
||||
context.projectGraph,
|
||||
context.root,
|
||||
context.taskGraph.tasks[taskName].target.project,
|
||||
context.taskGraph.tasks[taskName].target.target,
|
||||
context.taskGraph.tasks[taskName].target.configuration
|
||||
);
|
||||
|
||||
return {
|
||||
task: taskName,
|
||||
options: taskOptions,
|
||||
context: taskContext,
|
||||
assetsHandler,
|
||||
buildableProjectNodeDependencies,
|
||||
projectGraphNode,
|
||||
tsConfig,
|
||||
terminalOutput: '',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './build-task-info-per-tsconfig-map';
|
||||
export * from './normalize-tasks-options';
|
||||
export * from './types';
|
||||
export * from './watch';
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ExecutorContext } from '@nx/devkit';
|
||||
import { parseTargetString } from '@nx/devkit';
|
||||
import type {
|
||||
ExecutorOptions,
|
||||
NormalizedExecutorOptions,
|
||||
} from '../../../../utils/schema';
|
||||
import { normalizeOptions } from '../normalize-options';
|
||||
|
||||
export function normalizeTasksOptions(
|
||||
inputs: Record<string, ExecutorOptions>,
|
||||
context: ExecutorContext
|
||||
): Record<string, NormalizedExecutorOptions> {
|
||||
return Object.entries(inputs).reduce(
|
||||
(tasksOptions, [taskName, options]) => {
|
||||
const { project } = parseTargetString(taskName, context);
|
||||
const { sourceRoot, root } =
|
||||
context.projectsConfigurations.projects[project];
|
||||
tasksOptions[taskName] = normalizeOptions(
|
||||
options,
|
||||
context.root,
|
||||
sourceRoot,
|
||||
root
|
||||
);
|
||||
return tasksOptions;
|
||||
},
|
||||
{} as Record<string, NormalizedExecutorOptions>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ExecutorContext, ProjectGraphProjectNode } from '@nx/devkit';
|
||||
import type { CopyAssetsHandler } from '../../../../utils/assets/copy-assets-handler';
|
||||
import type { DependentBuildableProjectNode } from '../../../../utils/buildable-libs-utils';
|
||||
import type { NormalizedExecutorOptions } from '../../../../utils/schema';
|
||||
import type { TypescriptInMemoryTsConfig } from '../typescript-compilation';
|
||||
|
||||
export interface TaskInfo {
|
||||
task: string;
|
||||
options: NormalizedExecutorOptions;
|
||||
context: ExecutorContext;
|
||||
assetsHandler: CopyAssetsHandler;
|
||||
buildableProjectNodeDependencies: DependentBuildableProjectNode[];
|
||||
projectGraphNode: ProjectGraphProjectNode;
|
||||
tsConfig: TypescriptInMemoryTsConfig;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
terminalOutput: string;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { logger } from '@nx/devkit';
|
||||
import { daemonClient } from 'nx/src/daemon/client/client';
|
||||
import { join } from 'path';
|
||||
import type { TaskInfo } from './types';
|
||||
|
||||
export async function watchTaskProjectsPackageJsonFileChanges(
|
||||
taskInfos: TaskInfo[],
|
||||
callback: (changedTaskInfos: TaskInfo[]) => void
|
||||
): Promise<() => void> {
|
||||
const projects: string[] = [];
|
||||
const packageJsonTaskInfoMap = new Map<string, TaskInfo>();
|
||||
taskInfos.forEach((t) => {
|
||||
projects.push(t.context.projectName);
|
||||
packageJsonTaskInfoMap.set(join(t.options.projectRoot, 'package.json'), t);
|
||||
});
|
||||
|
||||
const unregisterFileWatcher = await daemonClient.registerFileWatcher(
|
||||
{ watchProjects: projects },
|
||||
(err, data) => {
|
||||
if (err === 'reconnecting') {
|
||||
// Silent - daemon restarts automatically on lockfile changes
|
||||
return;
|
||||
} else if (err === 'reconnected') {
|
||||
// Silent - reconnection succeeded
|
||||
return;
|
||||
} else if (err === 'closed') {
|
||||
logger.error(`Failed to reconnect to daemon after multiple attempts`);
|
||||
process.exit(1);
|
||||
} else if (err) {
|
||||
logger.error(`Watch error: ${err?.message ?? 'Unknown'}`);
|
||||
} else {
|
||||
const changedTasks = [];
|
||||
data.changedFiles.forEach((file) => {
|
||||
if (packageJsonTaskInfoMap.has(file.path)) {
|
||||
changedTasks.push(packageJsonTaskInfoMap.get(file.path));
|
||||
}
|
||||
});
|
||||
|
||||
if (changedTasks.length) {
|
||||
callback(changedTasks);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => unregisterFileWatcher();
|
||||
}
|
||||
|
||||
export async function watchTaskProjectsFileChangesForAssets(
|
||||
taskInfos: TaskInfo[]
|
||||
): Promise<() => void> {
|
||||
const unregisterFileWatcher = await daemonClient.registerFileWatcher(
|
||||
{
|
||||
watchProjects: taskInfos.map((t) => t.context.projectName),
|
||||
includeDependencies: true,
|
||||
includeGlobalWorkspaceFiles: true,
|
||||
},
|
||||
(err, data) => {
|
||||
if (err === 'reconnecting') {
|
||||
// Silent - daemon restarts automatically on lockfile changes
|
||||
return;
|
||||
} else if (err === 'reconnected') {
|
||||
// Silent - reconnection succeeded
|
||||
return;
|
||||
} else if (err === 'closed') {
|
||||
logger.error(`Failed to reconnect to daemon after multiple attempts`);
|
||||
process.exit(1);
|
||||
} else if (err) {
|
||||
logger.error(`Watch error: ${err?.message ?? 'Unknown'}`);
|
||||
} else {
|
||||
taskInfos.forEach((t) =>
|
||||
t.assetsHandler.processWatchEvents(data.changedFiles)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => unregisterFileWatcher();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as ts from 'typescript';
|
||||
import { loadTsTransformers } from '../../../utils/typescript/load-ts-transformers';
|
||||
import type { TransformerEntry } from '../../../utils/typescript/types';
|
||||
|
||||
export function getCustomTrasformersFactory(
|
||||
transformers: TransformerEntry[]
|
||||
): (program: ts.Program) => ts.CustomTransformers {
|
||||
const { compilerPluginHooks } = loadTsTransformers(transformers);
|
||||
|
||||
return (program: ts.Program): ts.CustomTransformers => ({
|
||||
before: compilerPluginHooks.beforeHooks.map(
|
||||
(hook) => hook(program) as ts.TransformerFactory<ts.SourceFile>
|
||||
),
|
||||
after: compilerPluginHooks.afterHooks.map(
|
||||
(hook) => hook(program) as ts.TransformerFactory<ts.SourceFile>
|
||||
),
|
||||
afterDeclarations: compilerPluginHooks.afterDeclarationsHooks.map(
|
||||
(hook) => hook(program) as ts.TransformerFactory<ts.SourceFile>
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type {
|
||||
ExecutorContext,
|
||||
ProjectGraphProjectNode,
|
||||
Target,
|
||||
} from '@nx/devkit';
|
||||
import type {
|
||||
ExecutorOptions,
|
||||
NormalizedExecutorOptions,
|
||||
} from '../../../utils/schema';
|
||||
import { normalizeOptions } from './normalize-options';
|
||||
|
||||
const tasksOptionsCache = new Map<string, NormalizedExecutorOptions>();
|
||||
export function getTaskOptions(
|
||||
taskName: string,
|
||||
context: ExecutorContext
|
||||
): NormalizedExecutorOptions | null {
|
||||
if (tasksOptionsCache.has(taskName)) {
|
||||
return tasksOptionsCache.get(taskName);
|
||||
}
|
||||
|
||||
try {
|
||||
const { taskOptions, sourceRoot, root } = parseTaskInfo<ExecutorOptions>(
|
||||
taskName,
|
||||
context
|
||||
);
|
||||
|
||||
const normalizedTaskOptions = normalizeOptions(
|
||||
taskOptions,
|
||||
context.root,
|
||||
sourceRoot,
|
||||
root
|
||||
);
|
||||
|
||||
tasksOptionsCache.set(taskName, normalizedTaskOptions);
|
||||
|
||||
return normalizedTaskOptions;
|
||||
} catch {
|
||||
tasksOptionsCache.set(taskName, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseTaskInfo<T = Record<string, any>>(
|
||||
taskName: string,
|
||||
context: ExecutorContext
|
||||
): {
|
||||
taskOptions: T;
|
||||
root: string;
|
||||
sourceRoot: string;
|
||||
projectNode: ProjectGraphProjectNode;
|
||||
target: Target;
|
||||
} {
|
||||
const target = context.taskGraph.tasks[taskName].target;
|
||||
const projectNode = context.projectGraph.nodes[target.project];
|
||||
const targetConfig = projectNode.data.targets?.[target.target];
|
||||
const { sourceRoot, root } = projectNode.data;
|
||||
|
||||
const taskOptions = {
|
||||
...targetConfig.options,
|
||||
...(target.configuration
|
||||
? targetConfig.configurations?.[target.configuration]
|
||||
: {}),
|
||||
};
|
||||
|
||||
return { taskOptions, root, sourceRoot, projectNode, target };
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
parseTargetString,
|
||||
readJsonFile,
|
||||
stripIndents,
|
||||
type ExecutorContext,
|
||||
} from '@nx/devkit';
|
||||
import { join } from 'path';
|
||||
import type { NormalizedExecutorOptions } from '../../../utils/schema';
|
||||
import { getTaskOptions } from './get-task-options';
|
||||
import type { TypescriptInMemoryTsConfig } from './typescript-compilation';
|
||||
|
||||
export function getProcessedTaskTsConfigs(
|
||||
tasks: string[],
|
||||
tasksOptions: Record<string, NormalizedExecutorOptions>,
|
||||
context: ExecutorContext
|
||||
): Record<string, TypescriptInMemoryTsConfig> {
|
||||
const taskInMemoryTsConfigMap: Record<string, TypescriptInMemoryTsConfig> =
|
||||
{};
|
||||
|
||||
for (const task of tasks) {
|
||||
generateTaskProjectTsConfig(
|
||||
task,
|
||||
tasksOptions,
|
||||
context,
|
||||
taskInMemoryTsConfigMap
|
||||
);
|
||||
}
|
||||
|
||||
return taskInMemoryTsConfigMap;
|
||||
}
|
||||
|
||||
const projectTsConfigCache = new Map<
|
||||
string,
|
||||
{ tsConfigPath: string; tsConfig: TypescriptInMemoryTsConfig }
|
||||
>();
|
||||
function generateTaskProjectTsConfig(
|
||||
task: string,
|
||||
tasksOptions: Record<string, NormalizedExecutorOptions>,
|
||||
context: ExecutorContext,
|
||||
taskInMemoryTsConfigMap: Record<string, TypescriptInMemoryTsConfig>
|
||||
): string {
|
||||
const { project } = parseTargetString(task, context);
|
||||
if (projectTsConfigCache.has(project)) {
|
||||
const { tsConfig, tsConfigPath } = projectTsConfigCache.get(project);
|
||||
taskInMemoryTsConfigMap[task] = tsConfig;
|
||||
return tsConfigPath;
|
||||
}
|
||||
|
||||
const tasksInProject = [
|
||||
task,
|
||||
...getDependencyTasksInSameProject(task, context),
|
||||
];
|
||||
const taskWithTscExecutor = tasksInProject.find((t) =>
|
||||
hasTscExecutor(t, context)
|
||||
);
|
||||
|
||||
if (!taskWithTscExecutor) {
|
||||
throw new Error(
|
||||
stripIndents`The "@nx/js:tsc" batch executor requires all dependencies to use the "@nx/js:tsc" executor.
|
||||
None of the following tasks in the "${project}" project use the "@nx/js:tsc" executor:
|
||||
${tasksInProject.map((t) => `- ${t}`).join('\n')}`
|
||||
);
|
||||
}
|
||||
|
||||
const projectReferences = [];
|
||||
for (const task of tasksInProject) {
|
||||
for (const depTask of getDependencyTasksInOtherProjects(
|
||||
task,
|
||||
project,
|
||||
context
|
||||
)) {
|
||||
const tsConfigPath = generateTaskProjectTsConfig(
|
||||
depTask,
|
||||
tasksOptions,
|
||||
context,
|
||||
taskInMemoryTsConfigMap
|
||||
);
|
||||
projectReferences.push(tsConfigPath);
|
||||
}
|
||||
}
|
||||
|
||||
const taskOptions =
|
||||
tasksOptions[taskWithTscExecutor] ??
|
||||
getTaskOptions(taskWithTscExecutor, context);
|
||||
const tsConfigPath = taskOptions.tsConfig;
|
||||
|
||||
taskInMemoryTsConfigMap[taskWithTscExecutor] = getInMemoryTsConfig(
|
||||
tsConfigPath,
|
||||
taskOptions,
|
||||
projectReferences
|
||||
);
|
||||
|
||||
projectTsConfigCache.set(project, {
|
||||
tsConfigPath: tsConfigPath,
|
||||
tsConfig: taskInMemoryTsConfigMap[taskWithTscExecutor],
|
||||
});
|
||||
|
||||
return tsConfigPath;
|
||||
}
|
||||
|
||||
function getDependencyTasksInOtherProjects(
|
||||
task: string,
|
||||
project: string,
|
||||
context: ExecutorContext
|
||||
): string[] {
|
||||
const implicitDependencies = new Set(
|
||||
context.projectGraph.nodes[project].data.implicitDependencies ?? []
|
||||
);
|
||||
return context.taskGraph.dependencies[task].filter((t) => {
|
||||
const { project: dependencyProject } = parseTargetString(t, context);
|
||||
// Tasks for implicit dependencies are skipped since incremental builds only apply to explicit dependencies
|
||||
return (
|
||||
t !== task &&
|
||||
dependencyProject !== project &&
|
||||
!implicitDependencies.has(dependencyProject)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getDependencyTasksInSameProject(
|
||||
task: string,
|
||||
context: ExecutorContext
|
||||
): string[] {
|
||||
const { project: taskProject } = parseTargetString(task, context);
|
||||
|
||||
return Object.keys(context.taskGraph.tasks).filter(
|
||||
(t) => t !== task && parseTargetString(t, context).project === taskProject
|
||||
);
|
||||
}
|
||||
|
||||
function getInMemoryTsConfig(
|
||||
tsConfig: string,
|
||||
taskOptions: {
|
||||
tsConfig: string | null;
|
||||
rootDir: string;
|
||||
outputPath: string;
|
||||
},
|
||||
projectReferences: string[]
|
||||
): TypescriptInMemoryTsConfig {
|
||||
const originalTsConfig = readJsonFile(tsConfig, {
|
||||
allowTrailingComma: true,
|
||||
disallowComments: false,
|
||||
});
|
||||
|
||||
const allProjectReferences = Array.from(
|
||||
new Set<string>(
|
||||
(originalTsConfig.references ?? [])
|
||||
.map((r: { path: string }) => r.path)
|
||||
.concat(projectReferences)
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
content: JSON.stringify({
|
||||
...originalTsConfig,
|
||||
compilerOptions: {
|
||||
...originalTsConfig.compilerOptions,
|
||||
rootDir: taskOptions.rootDir,
|
||||
outDir: taskOptions.outputPath,
|
||||
composite: true,
|
||||
declaration: true,
|
||||
declarationMap: true,
|
||||
tsBuildInfoFile: join(taskOptions.outputPath, 'tsconfig.tsbuildinfo'),
|
||||
},
|
||||
references: allProjectReferences.map((pr) => ({ path: pr })),
|
||||
}),
|
||||
path: tsConfig.replace(/\\/g, '/'),
|
||||
};
|
||||
}
|
||||
|
||||
function hasTscExecutor(task: string, context: ExecutorContext): boolean {
|
||||
const { project, target } = parseTargetString(task, context);
|
||||
|
||||
return (
|
||||
context.projectGraph.nodes[project].data.targets[target].executor ===
|
||||
'@nx/js:tsc'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './get-custom-transformers-factory';
|
||||
export * from './get-tsconfig';
|
||||
export * from './normalize-options';
|
||||
export * from './typescript-compilation';
|
||||
@@ -0,0 +1,48 @@
|
||||
import { join, resolve } from 'path';
|
||||
import type {
|
||||
ExecutorOptions,
|
||||
NormalizedExecutorOptions,
|
||||
} from '../../../utils/schema';
|
||||
import {
|
||||
FileInputOutput,
|
||||
assetGlobsToFiles,
|
||||
} from '../../../utils/assets/assets';
|
||||
|
||||
export function normalizeOptions(
|
||||
options: ExecutorOptions,
|
||||
contextRoot: string,
|
||||
sourceRoot: string,
|
||||
projectRoot: string
|
||||
): NormalizedExecutorOptions {
|
||||
const outputPath = join(contextRoot, options.outputPath);
|
||||
const rootDir = options.rootDir
|
||||
? join(contextRoot, options.rootDir)
|
||||
: join(contextRoot, projectRoot);
|
||||
|
||||
if (options.watch == null) {
|
||||
options.watch = false;
|
||||
}
|
||||
|
||||
options.assets ??= [];
|
||||
const files: FileInputOutput[] = assetGlobsToFiles(
|
||||
options.assets,
|
||||
contextRoot,
|
||||
outputPath
|
||||
);
|
||||
|
||||
return {
|
||||
...options,
|
||||
root: contextRoot,
|
||||
sourceRoot,
|
||||
projectRoot,
|
||||
files,
|
||||
outputPath,
|
||||
tsConfig: join(contextRoot, options.tsConfig),
|
||||
rootDir,
|
||||
mainOutputPath: resolve(
|
||||
outputPath,
|
||||
options.main.replace(`${projectRoot}/`, '').replace('.ts', '.js')
|
||||
),
|
||||
generatePackageJson: options.generatePackageJson ?? true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { createAsyncIterable } from '@nx/devkit/internal';
|
||||
import * as ts from 'typescript';
|
||||
import type { TransformerEntry } from '../../../utils/typescript/types';
|
||||
import { getCustomTrasformersFactory } from './get-custom-transformers-factory';
|
||||
import {
|
||||
formatDiagnosticReport,
|
||||
formatSolutionBuilderStatusReport,
|
||||
} from './typescript-diagnostic-reporters';
|
||||
|
||||
export interface TypescriptInMemoryTsConfig {
|
||||
content: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface TypescripCompilationLogger {
|
||||
error: (message: string, tsConfig?: string) => void;
|
||||
info: (message: string, tsConfig?: string) => void;
|
||||
warn: (message: string, tsConfig?: string) => void;
|
||||
}
|
||||
|
||||
export interface TypescriptProjectContext {
|
||||
project: string;
|
||||
tsConfig: TypescriptInMemoryTsConfig;
|
||||
transformers: TransformerEntry[];
|
||||
}
|
||||
|
||||
export interface TypescriptCompilationResult {
|
||||
tsConfig: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export type ReporterWithTsConfig<Fn extends (...args: any[]) => any> = (
|
||||
tsConfig: string | undefined,
|
||||
...foo: Parameters<Fn>
|
||||
) => ReturnType<Fn>;
|
||||
|
||||
// https://github.com/microsoft/TypeScript/blob/d45012c5e2ab122919ee4777a7887307c5f4a1e0/src/compiler/diagnosticMessages.json#L4050-L4053
|
||||
// Typescript diagnostic message for 5083: Cannot read file '{0}'.
|
||||
const TYPESCRIPT_CANNOT_READ_FILE = 5083;
|
||||
// https://github.com/microsoft/TypeScript/blob/d45012c5e2ab122919ee4777a7887307c5f4a1e0/src/compiler/diagnosticMessages.json#L4211-4214
|
||||
// Typescript diagnostic message for 6032: File change detected. Starting incremental compilation...
|
||||
const TYPESCRIPT_FILE_CHANGE_DETECTED_STARTING_INCREMENTAL_COMPILATION = 6032;
|
||||
|
||||
export function compileTypescriptSolution(
|
||||
context: Record<string, TypescriptProjectContext>,
|
||||
watch: boolean,
|
||||
logger: TypescripCompilationLogger,
|
||||
hooks?: {
|
||||
beforeProjectCompilationCallback?: (tsConfig: string) => void;
|
||||
afterProjectCompilationCallback?: (
|
||||
tsConfig: string,
|
||||
success: boolean
|
||||
) => void;
|
||||
},
|
||||
reporters?: {
|
||||
diagnosticReporter?: ReporterWithTsConfig<ts.DiagnosticReporter>;
|
||||
solutionBuilderStatusReporter?: ReporterWithTsConfig<ts.DiagnosticReporter>;
|
||||
watchStatusReporter?: ReporterWithTsConfig<ts.WatchStatusReporter>;
|
||||
}
|
||||
): AsyncIterable<TypescriptCompilationResult> {
|
||||
if (watch) {
|
||||
// create an AsyncIterable that doesn't complete, watch mode is only
|
||||
// stopped by killing the process
|
||||
return createAsyncIterable<TypescriptCompilationResult>(
|
||||
async ({ next }) => {
|
||||
hooks ??= {};
|
||||
const callerAfterProjectCompilationCallback =
|
||||
hooks.afterProjectCompilationCallback;
|
||||
hooks.afterProjectCompilationCallback = (tsConfig, success) => {
|
||||
callerAfterProjectCompilationCallback?.(tsConfig, success);
|
||||
next({ tsConfig, success });
|
||||
};
|
||||
|
||||
compileTSWithWatch(context, logger, hooks, reporters);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// turn it into an AsyncIterable
|
||||
const compilationGenerator = compileTS(context, logger, hooks, reporters);
|
||||
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
return {
|
||||
next() {
|
||||
return Promise.resolve(compilationGenerator.next());
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function* compileTS(
|
||||
context: Record<string, TypescriptProjectContext>,
|
||||
logger: TypescripCompilationLogger,
|
||||
hooks?: {
|
||||
beforeProjectCompilationCallback?: (tsConfig: string) => void;
|
||||
afterProjectCompilationCallback?: (
|
||||
tsConfig: string,
|
||||
success: boolean
|
||||
) => void;
|
||||
},
|
||||
reporters?: {
|
||||
diagnosticReporter?: ReporterWithTsConfig<ts.DiagnosticReporter>;
|
||||
solutionBuilderStatusReporter?: ReporterWithTsConfig<ts.DiagnosticReporter>;
|
||||
watchStatusReporter?: ReporterWithTsConfig<ts.WatchStatusReporter>;
|
||||
}
|
||||
): Generator<TypescriptCompilationResult, void, TypescriptCompilationResult> {
|
||||
let project: ts.InvalidatedProject<ts.EmitAndSemanticDiagnosticsBuilderProgram>;
|
||||
|
||||
const formatDiagnosticsHost: ts.FormatDiagnosticsHost = {
|
||||
getCurrentDirectory: () => ts.sys.getCurrentDirectory(),
|
||||
getNewLine: () => ts.sys.newLine,
|
||||
getCanonicalFileName: (filename: string) =>
|
||||
ts.sys.useCaseSensitiveFileNames ? filename : filename.toLowerCase(),
|
||||
};
|
||||
const solutionBuilderHost = ts.createSolutionBuilderHost(
|
||||
getSystem(context),
|
||||
/*createProgram*/ undefined,
|
||||
(diagnostic) => {
|
||||
const formattedDiagnostic = formatDiagnosticReport(
|
||||
diagnostic,
|
||||
formatDiagnosticsHost
|
||||
);
|
||||
|
||||
// handles edge case where a wrong a project reference path can't be read
|
||||
if (diagnostic.code === TYPESCRIPT_CANNOT_READ_FILE) {
|
||||
throw new Error(formattedDiagnostic);
|
||||
}
|
||||
|
||||
logger.info(formattedDiagnostic, project.project);
|
||||
|
||||
reporters?.diagnosticReporter?.(project.project, diagnostic);
|
||||
},
|
||||
(diagnostic) => {
|
||||
const formattedDiagnostic = formatSolutionBuilderStatusReport(diagnostic);
|
||||
logger.info(formattedDiagnostic, project.project);
|
||||
|
||||
reporters?.solutionBuilderStatusReporter?.(project.project, diagnostic);
|
||||
}
|
||||
);
|
||||
const rootNames = Object.keys(context);
|
||||
const solutionBuilder = ts.createSolutionBuilder(
|
||||
solutionBuilderHost,
|
||||
rootNames,
|
||||
{}
|
||||
);
|
||||
|
||||
while (true) {
|
||||
project = solutionBuilder.getNextInvalidatedProject();
|
||||
if (!project) {
|
||||
break;
|
||||
}
|
||||
|
||||
const projectContext = context[project.project];
|
||||
const projectName = projectContext?.project;
|
||||
|
||||
/**
|
||||
* This only applies when the deprecated `prepend` option is set to `true`.
|
||||
* This was completely dropped in TS 5.5.
|
||||
* Skip support.
|
||||
*/
|
||||
if (
|
||||
project.kind !== ts.InvalidatedProjectKind.Build &&
|
||||
project.kind !== ts.InvalidatedProjectKind.UpdateOutputFileStamps
|
||||
) {
|
||||
logger.warn(
|
||||
`The project ${projectName} ` +
|
||||
`is using the deprecated "prepend" Typescript compiler option. ` +
|
||||
`This option is not supported by the batch executor and it's ignored.\n`,
|
||||
(project as any).project
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
hooks?.beforeProjectCompilationCallback?.(project.project);
|
||||
|
||||
if (project.kind === ts.InvalidatedProjectKind.UpdateOutputFileStamps) {
|
||||
logger.info(
|
||||
`Updating output timestamps of project "${projectName}"...\n`,
|
||||
project.project
|
||||
);
|
||||
|
||||
// update output timestamps and mark project as complete
|
||||
const status = project.done();
|
||||
const success = status === ts.ExitStatus.Success;
|
||||
|
||||
if (success) {
|
||||
logger.info(
|
||||
`Done updating output timestamps of project "${projectName}"...\n`,
|
||||
project.project
|
||||
);
|
||||
}
|
||||
|
||||
hooks?.afterProjectCompilationCallback?.(project.project, success);
|
||||
yield { success, tsConfig: project.project };
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Compiling TypeScript files for project "${projectName}"...\n`,
|
||||
project.project
|
||||
);
|
||||
// build and mark project as complete
|
||||
const status = project.done(
|
||||
undefined,
|
||||
undefined,
|
||||
getCustomTrasformersFactory(projectContext.transformers)(
|
||||
project.getProgram()
|
||||
)
|
||||
);
|
||||
const success = status === ts.ExitStatus.Success;
|
||||
|
||||
if (success) {
|
||||
logger.info(
|
||||
`Done compiling TypeScript files for project "${projectName}".\n`,
|
||||
project.project
|
||||
);
|
||||
}
|
||||
|
||||
hooks?.afterProjectCompilationCallback?.(project.project, success);
|
||||
|
||||
yield {
|
||||
success: status === ts.ExitStatus.Success,
|
||||
tsConfig: project.project,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function compileTSWithWatch(
|
||||
context: Record<string, TypescriptProjectContext>,
|
||||
logger: TypescripCompilationLogger,
|
||||
hooks?: {
|
||||
beforeProjectCompilationCallback?: (tsConfig: string) => void;
|
||||
afterProjectCompilationCallback?: (
|
||||
tsConfig: string,
|
||||
success: boolean
|
||||
) => void;
|
||||
},
|
||||
reporters?: {
|
||||
diagnosticReporter?: ReporterWithTsConfig<ts.DiagnosticReporter>;
|
||||
solutionBuilderStatusReporter?: ReporterWithTsConfig<ts.DiagnosticReporter>;
|
||||
watchStatusReporter?: ReporterWithTsConfig<ts.WatchStatusReporter>;
|
||||
}
|
||||
): void {
|
||||
let project: ts.InvalidatedProject<ts.EmitAndSemanticDiagnosticsBuilderProgram>;
|
||||
|
||||
const solutionHost = ts.createSolutionBuilderWithWatchHost(
|
||||
getSystem(context),
|
||||
/*createProgram*/ undefined
|
||||
);
|
||||
|
||||
if (reporters?.diagnosticReporter) {
|
||||
const originalDiagnosticReporter = solutionHost.reportDiagnostic;
|
||||
solutionHost.reportDiagnostic = (diagnostic) => {
|
||||
originalDiagnosticReporter(diagnostic);
|
||||
reporters.diagnosticReporter(project.project, diagnostic);
|
||||
};
|
||||
}
|
||||
|
||||
if (reporters?.solutionBuilderStatusReporter) {
|
||||
const originalSolutionBuilderStatusReporter =
|
||||
solutionHost.reportSolutionBuilderStatus;
|
||||
solutionHost.reportDiagnostic = (diagnostic) => {
|
||||
originalSolutionBuilderStatusReporter(diagnostic);
|
||||
reporters.solutionBuilderStatusReporter(project.project, diagnostic);
|
||||
};
|
||||
}
|
||||
|
||||
const originalWatchStatusReporter = solutionHost.onWatchStatusChange;
|
||||
solutionHost.onWatchStatusChange = (
|
||||
diagnostic,
|
||||
newLine,
|
||||
options,
|
||||
errorCount
|
||||
) => {
|
||||
originalWatchStatusReporter(diagnostic, newLine, options, errorCount);
|
||||
|
||||
if (
|
||||
diagnostic.code ===
|
||||
TYPESCRIPT_FILE_CHANGE_DETECTED_STARTING_INCREMENTAL_COMPILATION
|
||||
) {
|
||||
// there's a change, build invalidated projects
|
||||
build();
|
||||
}
|
||||
|
||||
reporters?.watchStatusReporter?.(
|
||||
project?.project,
|
||||
diagnostic,
|
||||
newLine,
|
||||
options,
|
||||
errorCount
|
||||
);
|
||||
};
|
||||
|
||||
const rootNames = Object.keys(context);
|
||||
const solutionBuilder = ts.createSolutionBuilderWithWatch(
|
||||
solutionHost,
|
||||
rootNames,
|
||||
{}
|
||||
);
|
||||
|
||||
const build = () => {
|
||||
while (true) {
|
||||
project = solutionBuilder.getNextInvalidatedProject();
|
||||
if (!project) {
|
||||
break;
|
||||
}
|
||||
|
||||
const projectContext = context[project.project];
|
||||
const projectName = projectContext.project;
|
||||
|
||||
/**
|
||||
* This only applies when the deprecated `prepend` option is set to `true`.
|
||||
* This was completely dropped in TS 5.5.
|
||||
* Skip support.
|
||||
*/
|
||||
if (
|
||||
project.kind !== ts.InvalidatedProjectKind.Build &&
|
||||
project.kind !== ts.InvalidatedProjectKind.UpdateOutputFileStamps
|
||||
) {
|
||||
logger.warn(
|
||||
`The project ${projectName} ` +
|
||||
`is using the deprecated "prepend" Typescript compiler option. ` +
|
||||
`This option is not supported by the batch executor and it's ignored.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
hooks?.beforeProjectCompilationCallback(project.project);
|
||||
|
||||
if (project.kind === ts.InvalidatedProjectKind.UpdateOutputFileStamps) {
|
||||
if (projectName) {
|
||||
logger.info(
|
||||
`Updating output timestamps of project "${projectName}"...\n`,
|
||||
project.project
|
||||
);
|
||||
}
|
||||
|
||||
// update output timestamps and mark project as complete
|
||||
const status = project.done();
|
||||
const success = status === ts.ExitStatus.Success;
|
||||
|
||||
if (projectName && success) {
|
||||
logger.info(
|
||||
`Done updating output timestamps of project "${projectName}"...\n`,
|
||||
project.project
|
||||
);
|
||||
}
|
||||
|
||||
hooks?.afterProjectCompilationCallback?.(project.project, success);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Compiling TypeScript files for project "${projectName}"...\n`,
|
||||
project.project
|
||||
);
|
||||
// build and mark project as complete
|
||||
const status = project.done(
|
||||
undefined,
|
||||
undefined,
|
||||
getCustomTrasformersFactory(projectContext.transformers)(
|
||||
project.getProgram()
|
||||
)
|
||||
);
|
||||
const success = status === ts.ExitStatus.Success;
|
||||
|
||||
if (success) {
|
||||
logger.info(
|
||||
`Done compiling TypeScript files for project "${projectName}".\n`,
|
||||
project.project
|
||||
);
|
||||
}
|
||||
|
||||
hooks?.afterProjectCompilationCallback?.(project.project, success);
|
||||
}
|
||||
};
|
||||
|
||||
// initial build
|
||||
build();
|
||||
|
||||
/**
|
||||
* This is a workaround to get the TS file watching to kick off. It won't
|
||||
* build twice since the `build` call above will mark invalidated projects
|
||||
* as completed and then, the implementation of the `solutionBuilder.build`
|
||||
* skips them.
|
||||
* We can't rely solely in `solutionBuilder.build()` because it doesn't
|
||||
* accept custom transformers.
|
||||
*/
|
||||
solutionBuilder.build();
|
||||
}
|
||||
|
||||
function getSystem(
|
||||
context: Record<string, TypescriptProjectContext>
|
||||
): ts.System {
|
||||
return {
|
||||
...ts.sys,
|
||||
readFile(path, encoding) {
|
||||
if (context[path]) {
|
||||
return context[path].tsConfig.content;
|
||||
}
|
||||
return ts.sys.readFile(path, encoding);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as ts from 'typescript';
|
||||
|
||||
// adapted from TS default diagnostic reporter
|
||||
export function formatDiagnosticReport(
|
||||
diagnostic: ts.Diagnostic,
|
||||
host: ts.FormatDiagnosticsHost
|
||||
): string {
|
||||
const diagnostics: ts.Diagnostic[] = new Array(1);
|
||||
diagnostics[0] = diagnostic;
|
||||
const formattedDiagnostic =
|
||||
'\n' +
|
||||
ts.formatDiagnosticsWithColorAndContext(diagnostics, host) +
|
||||
host.getNewLine();
|
||||
diagnostics[0] = undefined;
|
||||
|
||||
return formattedDiagnostic;
|
||||
}
|
||||
|
||||
// adapted from TS default solution builder status reporter
|
||||
export function formatSolutionBuilderStatusReport(
|
||||
diagnostic: ts.Diagnostic
|
||||
): string {
|
||||
let formattedDiagnostic = `[${formatColorAndReset(
|
||||
getLocaleTimeString(),
|
||||
ForegroundColorEscapeSequences.Grey
|
||||
)}] `;
|
||||
formattedDiagnostic += `${ts.flattenDiagnosticMessageText(
|
||||
diagnostic.messageText,
|
||||
ts.sys.newLine
|
||||
)}${ts.sys.newLine + ts.sys.newLine}`;
|
||||
|
||||
return formattedDiagnostic;
|
||||
}
|
||||
|
||||
function formatColorAndReset(text: string, formatStyle: string) {
|
||||
const resetEscapeSequence = '\u001b[0m';
|
||||
return formatStyle + text + resetEscapeSequence;
|
||||
}
|
||||
|
||||
function getLocaleTimeString() {
|
||||
return new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
enum ForegroundColorEscapeSequences {
|
||||
Grey = '\u001b[90m',
|
||||
Red = '\u001b[91m',
|
||||
Yellow = '\u001b[93m',
|
||||
Blue = '\u001b[94m',
|
||||
Cyan = '\u001b[96m',
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
{
|
||||
"version": 2,
|
||||
"outputCapture": "direct-nodejs",
|
||||
"title": "Typescript Build Target",
|
||||
"description": "Builds using TypeScript.",
|
||||
"cli": "nx",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"main": {
|
||||
"type": "string",
|
||||
"description": "The name of the main entry-point file.",
|
||||
"x-completion-type": "file",
|
||||
"x-completion-glob": "main@(.js|.ts|.jsx|.tsx)",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"generateExportsField": {
|
||||
"type": "boolean",
|
||||
"alias": "exports",
|
||||
"description": "Update the output package.json file's 'exports' field. This field is used by Node and bundlers. Ignored when `generatePackageJson` is set to `false`.",
|
||||
"default": false,
|
||||
"x-priority": "important"
|
||||
},
|
||||
"additionalEntryPoints": {
|
||||
"type": "array",
|
||||
"description": "Additional entry-points to add to exports field in the package.json file. Ignored when `generatePackageJson` is set to `false`.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"x-priority": "important"
|
||||
},
|
||||
"rootDir": {
|
||||
"type": "string",
|
||||
"description": "Sets the rootDir for TypeScript compilation. When not defined, it uses the root of project."
|
||||
},
|
||||
"outputPath": {
|
||||
"type": "string",
|
||||
"description": "The output path of the generated files.",
|
||||
"x-completion-type": "directory",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"outputFileName": {
|
||||
"type": "string",
|
||||
"description": "The path to the main file relative to the outputPath",
|
||||
"x-completion-type": "file"
|
||||
},
|
||||
"tsConfig": {
|
||||
"type": "string",
|
||||
"description": "The path to the Typescript configuration file.",
|
||||
"x-completion-type": "file",
|
||||
"x-completion-glob": "tsconfig.*.json",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"assets": {
|
||||
"type": "array",
|
||||
"description": "List of static assets.",
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/assetPattern"
|
||||
}
|
||||
},
|
||||
"watch": {
|
||||
"type": "boolean",
|
||||
"description": "Enable re-building when files change.",
|
||||
"default": false
|
||||
},
|
||||
"clean": {
|
||||
"type": "boolean",
|
||||
"description": "Remove previous output before build.",
|
||||
"default": true
|
||||
},
|
||||
"transformers": {
|
||||
"type": "array",
|
||||
"description": "List of TypeScript Transformer Plugins.",
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/transformerPattern"
|
||||
},
|
||||
"x-priority": "important"
|
||||
},
|
||||
"generateLockfile": {
|
||||
"type": "boolean",
|
||||
"description": "Generate a lockfile (e.g. package-lock.json) that matches the workspace lockfile to ensure package versions match. Ignored when `generatePackageJson` is set to `false`.",
|
||||
"default": false,
|
||||
"x-priority": "internal"
|
||||
},
|
||||
"generatePackageJson": {
|
||||
"type": "boolean",
|
||||
"description": "Generate package.json file in the output folder.",
|
||||
"default": true
|
||||
},
|
||||
"includeIgnoredAssetFiles": {
|
||||
"type": "boolean",
|
||||
"description": "Include files that are ignored by .gitignore and .nxignore when copying assets. WARNING: Ignored files are not automatically considered when calculating the task hash. To ensure Nx tracks these files for caching, add them to your target's inputs using 'dependentTasksOutputs' or 'runtime' configuration.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["main", "outputPath", "tsConfig"],
|
||||
"definitions": {
|
||||
"assetPattern": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"glob": {
|
||||
"type": "string",
|
||||
"description": "The pattern to match."
|
||||
},
|
||||
"input": {
|
||||
"type": "string",
|
||||
"description": "The input directory path in which to apply 'glob'. Defaults to the project root."
|
||||
},
|
||||
"ignore": {
|
||||
"description": "An array of globs to ignore.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "string",
|
||||
"description": "Absolute path within the output."
|
||||
},
|
||||
"includeIgnoredFiles": {
|
||||
"type": "boolean",
|
||||
"description": "Include files that are ignored by .gitignore and .nxignore for this specific asset pattern. WARNING: Ignored files are not automatically considered when calculating the task hash. To ensure Nx tracks these files for caching, add them to your target's inputs using 'dependentTasksOutputs' or 'runtime' configuration.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["glob", "input", "output"]
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"transformerPattern": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["name"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"examplesFile": "../../../docs/tsc-examples.md"
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
ExecutorContext,
|
||||
isDaemonEnabled,
|
||||
output,
|
||||
parseTargetString,
|
||||
TaskGraph,
|
||||
} from '@nx/devkit';
|
||||
import { rmSync } from 'fs';
|
||||
import type { BatchExecutorTaskResult } from 'nx/src/config/misc-interfaces';
|
||||
import { getLastValueFromAsyncIterableIterator } from 'nx/src/utils/async-iterator';
|
||||
import { updatePackageJson } from '../../utils/package-json/update-package-json';
|
||||
import type { ExecutorOptions } from '../../utils/schema';
|
||||
import { determineModuleFormatFromTsConfig } from './tsc.impl';
|
||||
import {
|
||||
compileTypescriptSolution,
|
||||
getProcessedTaskTsConfigs,
|
||||
TypescripCompilationLogger,
|
||||
TypescriptCompilationResult,
|
||||
TypescriptInMemoryTsConfig,
|
||||
TypescriptProjectContext,
|
||||
} from './lib';
|
||||
import {
|
||||
createTaskInfoPerTsConfigMap,
|
||||
normalizeTasksOptions,
|
||||
TaskInfo,
|
||||
watchTaskProjectsFileChangesForAssets,
|
||||
watchTaskProjectsPackageJsonFileChanges,
|
||||
} from './lib/batch';
|
||||
import { createEntryPoints } from '../../utils/package-json/create-entry-points';
|
||||
|
||||
export async function* tscBatchExecutor(
|
||||
taskGraph: TaskGraph,
|
||||
inputs: Record<string, ExecutorOptions>,
|
||||
overrides: ExecutorOptions,
|
||||
context: ExecutorContext
|
||||
) {
|
||||
const tasksOptions = normalizeTasksOptions(inputs, context);
|
||||
|
||||
let shouldWatch = false;
|
||||
Object.values(tasksOptions).forEach((taskOptions) => {
|
||||
if (taskOptions.clean) {
|
||||
rmSync(taskOptions.outputPath, { force: true, recursive: true });
|
||||
}
|
||||
if (taskOptions.watch) {
|
||||
shouldWatch = true;
|
||||
}
|
||||
});
|
||||
|
||||
const taskInMemoryTsConfigMap = getProcessedTaskTsConfigs(
|
||||
Object.keys(taskGraph.tasks),
|
||||
tasksOptions,
|
||||
context
|
||||
);
|
||||
const tsConfigTaskInfoMap = createTaskInfoPerTsConfigMap(
|
||||
tasksOptions,
|
||||
context,
|
||||
Object.keys(taskGraph.tasks),
|
||||
taskInMemoryTsConfigMap
|
||||
);
|
||||
const tsCompilationContext = createTypescriptCompilationContext(
|
||||
tsConfigTaskInfoMap,
|
||||
taskInMemoryTsConfigMap,
|
||||
context
|
||||
);
|
||||
|
||||
const logger: TypescripCompilationLogger = {
|
||||
error: (message, tsConfig) => {
|
||||
process.stderr.write(message);
|
||||
if (tsConfig) {
|
||||
tsConfigTaskInfoMap[tsConfig].terminalOutput += message;
|
||||
}
|
||||
},
|
||||
info: (message, tsConfig) => {
|
||||
process.stdout.write(message);
|
||||
if (tsConfig) {
|
||||
tsConfigTaskInfoMap[tsConfig].terminalOutput += message;
|
||||
}
|
||||
},
|
||||
warn: (message, tsConfig) => {
|
||||
process.stdout.write(message);
|
||||
if (tsConfig) {
|
||||
tsConfigTaskInfoMap[tsConfig].terminalOutput += message;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const processTaskPostCompilation = (tsConfig: string) => {
|
||||
if (tsConfigTaskInfoMap[tsConfig]) {
|
||||
const taskInfo = tsConfigTaskInfoMap[tsConfig];
|
||||
taskInfo.assetsHandler.processAllAssetsOnceSync();
|
||||
updatePackageJson(
|
||||
{
|
||||
...taskInfo.options,
|
||||
additionalEntryPoints: createEntryPoints(
|
||||
taskInfo.options.additionalEntryPoints,
|
||||
context.root
|
||||
),
|
||||
format: [determineModuleFormatFromTsConfig(tsConfig)],
|
||||
},
|
||||
taskInfo.context,
|
||||
taskInfo.projectGraphNode,
|
||||
taskInfo.buildableProjectNodeDependencies
|
||||
);
|
||||
taskInfo.endTime = Date.now();
|
||||
}
|
||||
};
|
||||
|
||||
const typescriptCompilation = compileTypescriptSolution(
|
||||
tsCompilationContext,
|
||||
shouldWatch,
|
||||
logger,
|
||||
{
|
||||
beforeProjectCompilationCallback: (tsConfig) => {
|
||||
if (tsConfigTaskInfoMap[tsConfig]) {
|
||||
tsConfigTaskInfoMap[tsConfig].startTime = Date.now();
|
||||
}
|
||||
},
|
||||
afterProjectCompilationCallback: processTaskPostCompilation,
|
||||
}
|
||||
);
|
||||
if (shouldWatch && !isDaemonEnabled()) {
|
||||
output.warn({
|
||||
title:
|
||||
'Nx Daemon is not enabled. Assets and package.json files will not be updated on file changes.',
|
||||
});
|
||||
}
|
||||
if (shouldWatch && isDaemonEnabled()) {
|
||||
const taskInfos = Object.values(tsConfigTaskInfoMap);
|
||||
const watchAssetsChangesDisposer =
|
||||
await watchTaskProjectsFileChangesForAssets(taskInfos);
|
||||
const watchProjectsChangesDisposer =
|
||||
await watchTaskProjectsPackageJsonFileChanges(
|
||||
taskInfos,
|
||||
(changedTaskInfos: TaskInfo[]) => {
|
||||
for (const t of changedTaskInfos) {
|
||||
updatePackageJson(
|
||||
{
|
||||
...t.options,
|
||||
additionalEntryPoints: createEntryPoints(
|
||||
t.options.additionalEntryPoints,
|
||||
context.root
|
||||
),
|
||||
format: [determineModuleFormatFromTsConfig(t.options.tsConfig)],
|
||||
},
|
||||
t.context,
|
||||
t.projectGraphNode,
|
||||
t.buildableProjectNodeDependencies
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const handleTermination = async (exitCode: number) => {
|
||||
watchAssetsChangesDisposer();
|
||||
watchProjectsChangesDisposer();
|
||||
process.exit(exitCode);
|
||||
};
|
||||
process.on('SIGINT', () => handleTermination(128 + 2));
|
||||
process.on('SIGTERM', () => handleTermination(128 + 15));
|
||||
|
||||
return yield* mapAsyncIterable(typescriptCompilation, async (iterator) => {
|
||||
// drain the iterator, we don't use the results
|
||||
await getLastValueFromAsyncIterableIterator(iterator);
|
||||
return { value: undefined, done: true };
|
||||
});
|
||||
}
|
||||
|
||||
const toBatchExecutorTaskResult = (
|
||||
tsConfig: string,
|
||||
success: boolean
|
||||
): BatchExecutorTaskResult => ({
|
||||
task: tsConfigTaskInfoMap[tsConfig].task,
|
||||
result: {
|
||||
success: success,
|
||||
terminalOutput: tsConfigTaskInfoMap[tsConfig].terminalOutput,
|
||||
startTime: tsConfigTaskInfoMap[tsConfig].startTime,
|
||||
endTime: tsConfigTaskInfoMap[tsConfig].endTime,
|
||||
},
|
||||
});
|
||||
|
||||
let isCompilationDone = false;
|
||||
const taskTsConfigsToReport = new Set(
|
||||
Object.keys(taskGraph.tasks).map((t) => taskInMemoryTsConfigMap[t].path)
|
||||
);
|
||||
let tasksToReportIterator: IterableIterator<string>;
|
||||
|
||||
const processSkippedTasks = () => {
|
||||
const { value: tsConfig, done } = tasksToReportIterator.next();
|
||||
if (done) {
|
||||
return { value: undefined, done: true };
|
||||
}
|
||||
|
||||
tsConfigTaskInfoMap[tsConfig].startTime = Date.now();
|
||||
processTaskPostCompilation(tsConfig);
|
||||
|
||||
return { value: toBatchExecutorTaskResult(tsConfig, true), done: false };
|
||||
};
|
||||
|
||||
return yield* mapAsyncIterable(typescriptCompilation, async (iterator) => {
|
||||
if (isCompilationDone) {
|
||||
return processSkippedTasks();
|
||||
}
|
||||
|
||||
const { value, done } = await iterator.next();
|
||||
if (done) {
|
||||
if (taskTsConfigsToReport.size > 0) {
|
||||
/**
|
||||
* TS compilation is done but we still have tasks to report. This can
|
||||
* happen if, for example, a project is identified as affected, but
|
||||
* no file in the TS project is actually changed or if running a
|
||||
* task with `--skip-nx-cache` and the outputs are already there. There
|
||||
* can still be changes to assets or other files we need to process.
|
||||
*
|
||||
* Switch to handle the iterator for the tasks we still need to report.
|
||||
*/
|
||||
isCompilationDone = true;
|
||||
tasksToReportIterator = taskTsConfigsToReport.values();
|
||||
return processSkippedTasks();
|
||||
}
|
||||
|
||||
return { value: undefined, done: true };
|
||||
}
|
||||
|
||||
taskTsConfigsToReport.delete(value.tsConfig);
|
||||
|
||||
return {
|
||||
value: toBatchExecutorTaskResult(value.tsConfig, value.success),
|
||||
done: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default tscBatchExecutor;
|
||||
|
||||
async function* mapAsyncIterable(
|
||||
iterable: AsyncIterable<TypescriptCompilationResult>,
|
||||
nextFn: (
|
||||
iterator: AsyncIterableIterator<TypescriptCompilationResult>
|
||||
) => Promise<IteratorResult<BatchExecutorTaskResult>>
|
||||
) {
|
||||
return yield* {
|
||||
[Symbol.asyncIterator]() {
|
||||
const iterator: AsyncIterableIterator<TypescriptCompilationResult> =
|
||||
iterable[Symbol.asyncIterator].call(iterable);
|
||||
|
||||
return {
|
||||
async next() {
|
||||
return await nextFn(iterator);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTypescriptCompilationContext(
|
||||
tsConfigTaskInfoMap: Record<string, TaskInfo>,
|
||||
taskInMemoryTsConfigMap: Record<string, TypescriptInMemoryTsConfig>,
|
||||
context: ExecutorContext
|
||||
): Record<string, TypescriptProjectContext> {
|
||||
const tsCompilationContext: Record<string, TypescriptProjectContext> =
|
||||
Object.entries(tsConfigTaskInfoMap).reduce(
|
||||
(acc, [tsConfig, taskInfo]) => {
|
||||
acc[tsConfig] = {
|
||||
project: taskInfo.context.projectName,
|
||||
tsConfig: taskInfo.tsConfig,
|
||||
transformers: taskInfo.options.transformers,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, TypescriptProjectContext>
|
||||
);
|
||||
|
||||
Object.entries(taskInMemoryTsConfigMap).forEach(([task, tsConfig]) => {
|
||||
if (!tsCompilationContext[tsConfig.path]) {
|
||||
tsCompilationContext[tsConfig.path] = {
|
||||
project: parseTargetString(task, context).project,
|
||||
transformers: [],
|
||||
tsConfig: tsConfig,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return tsCompilationContext;
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { ExecutorContext } from '@nx/devkit';
|
||||
import { TempFs } from '@nx/devkit/internal-testing-utils';
|
||||
import * as ts from 'typescript';
|
||||
import { ExecutorOptions } from '../../utils/schema';
|
||||
import { readTsConfig } from '../../utils/typescript/ts-config';
|
||||
import { normalizeOptions } from './lib';
|
||||
import {
|
||||
createTypeScriptCompilationOptions,
|
||||
determineModuleFormatFromTsConfig,
|
||||
} from './tsc.impl';
|
||||
|
||||
jest.mock('../../utils/typescript/ts-config');
|
||||
|
||||
const mockReadTsConfig = readTsConfig as jest.MockedFunction<
|
||||
typeof readTsConfig
|
||||
>;
|
||||
|
||||
describe('tscExecutor', () => {
|
||||
let context: ExecutorContext;
|
||||
let testOptions: ExecutorOptions;
|
||||
|
||||
beforeEach(async () => {
|
||||
context = {
|
||||
root: '/root',
|
||||
cwd: '/root',
|
||||
projectGraph: {
|
||||
nodes: {},
|
||||
dependencies: {},
|
||||
},
|
||||
projectsConfigurations: {
|
||||
version: 2,
|
||||
projects: {},
|
||||
},
|
||||
nxJsonConfiguration: {},
|
||||
isVerbose: false,
|
||||
projectName: 'example',
|
||||
targetName: 'build',
|
||||
};
|
||||
testOptions = {
|
||||
main: 'libs/ui/src/index.ts',
|
||||
outputPath: 'dist/libs/ui',
|
||||
tsConfig: 'libs/ui/tsconfig.json',
|
||||
assets: [],
|
||||
transformers: [],
|
||||
watch: false,
|
||||
clean: true,
|
||||
};
|
||||
});
|
||||
|
||||
describe('createTypeScriptCompilationOptions', () => {
|
||||
it('should create typescript compilation options for valid config', () => {
|
||||
const result = createTypeScriptCompilationOptions(
|
||||
normalizeOptions(testOptions, '/root', 'libs/ui/src', 'libs/ui'),
|
||||
context
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
outputPath: '/root/dist/libs/ui',
|
||||
projectName: 'example',
|
||||
projectRoot: 'libs/ui',
|
||||
rootDir: '/root/libs/ui',
|
||||
tsConfig: '/root/libs/ui/tsconfig.json',
|
||||
watch: false,
|
||||
deleteOutputPath: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle custom rootDir', () => {
|
||||
const result = createTypeScriptCompilationOptions(
|
||||
normalizeOptions(
|
||||
{ ...testOptions, rootDir: 'libs/ui/src' },
|
||||
'/root',
|
||||
'libs/ui/src',
|
||||
'libs/ui'
|
||||
),
|
||||
context
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
rootDir: '/root/libs/ui/src',
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep the Windows drive letter in rootDir and tsConfig', () => {
|
||||
const result = createTypeScriptCompilationOptions(
|
||||
normalizeOptions(testOptions, 'C:\\root', 'libs/ui/src', 'libs/ui'),
|
||||
context
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
rootDir: 'C:/root/libs/ui',
|
||||
tsConfig: 'C:/root/libs/ui/tsconfig.json',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('determineModuleFormatFromTsConfig', () => {
|
||||
let tempFs: TempFs;
|
||||
let workspaceRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
tempFs = new TempFs('tsc-module-format');
|
||||
workspaceRoot = tempFs.tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
tempFs.cleanup();
|
||||
});
|
||||
|
||||
describe('ESM module kinds', () => {
|
||||
it('should return esm for ES2015 module', () => {
|
||||
tempFs.createFilesSync({
|
||||
'libs/ui/tsconfig.json': '{}',
|
||||
});
|
||||
|
||||
mockReadTsConfig.mockReturnValue({
|
||||
options: { module: ts.ModuleKind.ES2015 },
|
||||
} as any);
|
||||
|
||||
const result = determineModuleFormatFromTsConfig(
|
||||
`${workspaceRoot}/libs/ui/tsconfig.json`
|
||||
);
|
||||
|
||||
expect(result).toBe('esm');
|
||||
});
|
||||
|
||||
it('should return esm for ES2020 module', () => {
|
||||
tempFs.createFilesSync({
|
||||
'libs/ui/tsconfig.json': '{}',
|
||||
});
|
||||
|
||||
mockReadTsConfig.mockReturnValue({
|
||||
options: { module: ts.ModuleKind.ES2020 },
|
||||
} as any);
|
||||
|
||||
const result = determineModuleFormatFromTsConfig(
|
||||
`${workspaceRoot}/libs/ui/tsconfig.json`
|
||||
);
|
||||
|
||||
expect(result).toBe('esm');
|
||||
});
|
||||
|
||||
it('should return esm for ES2022 module', () => {
|
||||
tempFs.createFilesSync({
|
||||
'libs/ui/tsconfig.json': '{}',
|
||||
});
|
||||
|
||||
mockReadTsConfig.mockReturnValue({
|
||||
options: { module: ts.ModuleKind.ES2022 },
|
||||
} as any);
|
||||
|
||||
const result = determineModuleFormatFromTsConfig(
|
||||
`${workspaceRoot}/libs/ui/tsconfig.json`
|
||||
);
|
||||
|
||||
expect(result).toBe('esm');
|
||||
});
|
||||
|
||||
it('should return esm for ESNext module', () => {
|
||||
tempFs.createFilesSync({
|
||||
'libs/ui/tsconfig.json': '{}',
|
||||
});
|
||||
|
||||
mockReadTsConfig.mockReturnValue({
|
||||
options: { module: ts.ModuleKind.ESNext },
|
||||
} as any);
|
||||
|
||||
const result = determineModuleFormatFromTsConfig(
|
||||
`${workspaceRoot}/libs/ui/tsconfig.json`
|
||||
);
|
||||
|
||||
expect(result).toBe('esm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CJS module kinds', () => {
|
||||
it('should return cjs for CommonJS module', () => {
|
||||
tempFs.createFilesSync({
|
||||
'libs/ui/tsconfig.json': '{}',
|
||||
});
|
||||
|
||||
mockReadTsConfig.mockReturnValue({
|
||||
options: { module: ts.ModuleKind.CommonJS },
|
||||
} as any);
|
||||
|
||||
const result = determineModuleFormatFromTsConfig(
|
||||
`${workspaceRoot}/libs/ui/tsconfig.json`
|
||||
);
|
||||
|
||||
expect(result).toBe('cjs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NodeNext module kind', () => {
|
||||
it('should return esm for NodeNext when package.json has type: module', () => {
|
||||
tempFs.createFilesSync({
|
||||
'libs/ui/tsconfig.json': '{}',
|
||||
'libs/ui/package.json': JSON.stringify({ type: 'module' }),
|
||||
});
|
||||
|
||||
mockReadTsConfig.mockReturnValue({
|
||||
options: { module: ts.ModuleKind.NodeNext },
|
||||
} as any);
|
||||
|
||||
const result = determineModuleFormatFromTsConfig(
|
||||
`${workspaceRoot}/libs/ui/tsconfig.json`,
|
||||
'libs/ui',
|
||||
workspaceRoot
|
||||
);
|
||||
|
||||
expect(result).toBe('esm');
|
||||
});
|
||||
|
||||
it('should return cjs for NodeNext when package.json has type: commonjs', () => {
|
||||
tempFs.createFilesSync({
|
||||
'libs/ui/tsconfig.json': '{}',
|
||||
'libs/ui/package.json': JSON.stringify({ type: 'commonjs' }),
|
||||
});
|
||||
|
||||
mockReadTsConfig.mockReturnValue({
|
||||
options: { module: ts.ModuleKind.NodeNext },
|
||||
} as any);
|
||||
|
||||
const result = determineModuleFormatFromTsConfig(
|
||||
`${workspaceRoot}/libs/ui/tsconfig.json`,
|
||||
'libs/ui',
|
||||
workspaceRoot
|
||||
);
|
||||
|
||||
expect(result).toBe('cjs');
|
||||
});
|
||||
|
||||
it('should return cjs for NodeNext when package.json has no type field', () => {
|
||||
tempFs.createFilesSync({
|
||||
'libs/ui/tsconfig.json': '{}',
|
||||
'libs/ui/package.json': JSON.stringify({ name: 'test' }),
|
||||
});
|
||||
|
||||
mockReadTsConfig.mockReturnValue({
|
||||
options: { module: ts.ModuleKind.NodeNext },
|
||||
} as any);
|
||||
|
||||
const result = determineModuleFormatFromTsConfig(
|
||||
`${workspaceRoot}/libs/ui/tsconfig.json`,
|
||||
'libs/ui',
|
||||
workspaceRoot
|
||||
);
|
||||
|
||||
expect(result).toBe('cjs');
|
||||
});
|
||||
|
||||
it('should return cjs for NodeNext when package.json does not exist', () => {
|
||||
tempFs.createFilesSync({
|
||||
'libs/ui/tsconfig.json': '{}',
|
||||
});
|
||||
|
||||
mockReadTsConfig.mockReturnValue({
|
||||
options: { module: ts.ModuleKind.NodeNext },
|
||||
} as any);
|
||||
|
||||
const result = determineModuleFormatFromTsConfig(
|
||||
`${workspaceRoot}/libs/ui/tsconfig.json`,
|
||||
'libs/ui',
|
||||
workspaceRoot
|
||||
);
|
||||
|
||||
expect(result).toBe('cjs');
|
||||
});
|
||||
|
||||
it('should return cjs for NodeNext when projectRoot/workspaceRoot not provided (backward compatible)', () => {
|
||||
tempFs.createFilesSync({
|
||||
'libs/ui/tsconfig.json': '{}',
|
||||
'libs/ui/package.json': JSON.stringify({ type: 'module' }),
|
||||
});
|
||||
|
||||
mockReadTsConfig.mockReturnValue({
|
||||
options: { module: ts.ModuleKind.NodeNext },
|
||||
} as any);
|
||||
|
||||
// Without projectRoot and workspaceRoot, should default to cjs
|
||||
const result = determineModuleFormatFromTsConfig(
|
||||
`${workspaceRoot}/libs/ui/tsconfig.json`
|
||||
);
|
||||
|
||||
expect(result).toBe('cjs');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import * as ts from 'typescript';
|
||||
import {
|
||||
ExecutorContext,
|
||||
isDaemonEnabled,
|
||||
joinPathFragments,
|
||||
output,
|
||||
readJsonFile,
|
||||
} from '@nx/devkit';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { TypeScriptCompilationOptions } from '../../utils/typescript/compilation';
|
||||
import { CopyAssetsHandler } from '../../utils/assets/copy-assets-handler';
|
||||
import { checkDependencies } from '../../utils/check-dependencies';
|
||||
import {
|
||||
getHelperDependency,
|
||||
HelperDependency,
|
||||
} from '../../utils/compiler-helper-dependency';
|
||||
import { updatePackageJson } from '../../utils/package-json/update-package-json';
|
||||
import { ExecutorOptions, NormalizedExecutorOptions } from '../../utils/schema';
|
||||
import { compileTypeScriptFiles } from '../../utils/typescript/compile-typescript-files';
|
||||
import { watchForSingleFileChanges } from '../../utils/watch-for-single-file-changes';
|
||||
import { getCustomTrasformersFactory, normalizeOptions } from './lib';
|
||||
import { readTsConfig } from '../../utils/typescript/ts-config';
|
||||
import { createEntryPoints } from '../../utils/package-json/create-entry-points';
|
||||
|
||||
export function determineModuleFormatFromTsConfig(
|
||||
absolutePathToTsConfig: string,
|
||||
projectRoot?: string,
|
||||
workspaceRoot?: string
|
||||
): 'cjs' | 'esm' {
|
||||
const tsConfig = readTsConfig(absolutePathToTsConfig);
|
||||
|
||||
// NodeNext is context-dependent - check package.json type field
|
||||
// NodeNext outputs ESM only when package.json has "type": "module"
|
||||
// Otherwise it outputs CJS (when "type": "commonjs" or no type field)
|
||||
if (tsConfig.options.module === ts.ModuleKind.NodeNext) {
|
||||
if (projectRoot && workspaceRoot) {
|
||||
const packageJsonPath = join(workspaceRoot, projectRoot, 'package.json');
|
||||
if (existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const packageJson = readJsonFile(packageJsonPath);
|
||||
if (packageJson.type === 'module') {
|
||||
return 'esm';
|
||||
}
|
||||
} catch {
|
||||
// Fall through to default CJS
|
||||
}
|
||||
}
|
||||
}
|
||||
// NodeNext defaults to CJS when no type field or when we can't check
|
||||
return 'cjs';
|
||||
}
|
||||
|
||||
if (
|
||||
tsConfig.options.module === ts.ModuleKind.ES2015 ||
|
||||
tsConfig.options.module === ts.ModuleKind.ES2020 ||
|
||||
tsConfig.options.module === ts.ModuleKind.ES2022 ||
|
||||
tsConfig.options.module === ts.ModuleKind.ESNext
|
||||
) {
|
||||
return 'esm';
|
||||
} else {
|
||||
return 'cjs';
|
||||
}
|
||||
}
|
||||
|
||||
export function createTypeScriptCompilationOptions(
|
||||
normalizedOptions: NormalizedExecutorOptions,
|
||||
context: ExecutorContext
|
||||
): TypeScriptCompilationOptions {
|
||||
return {
|
||||
outputPath: joinPathFragments(normalizedOptions.outputPath),
|
||||
projectName: context.projectName,
|
||||
projectRoot: normalizedOptions.projectRoot,
|
||||
// Keep the Windows drive letter on rootDir/tsConfig (only forward-slash them).
|
||||
// joinPathFragments strips it via normalizePath, leaving them drive-less while
|
||||
// the generated path mappings stay absolute drive-full, so alias-resolved files
|
||||
// fail TypeScript's rootDir containment check (TS6059) on Windows.
|
||||
rootDir: normalizedOptions.rootDir.replace(/\\/g, '/'),
|
||||
tsConfig: normalizedOptions.tsConfig.replace(/\\/g, '/'),
|
||||
watch: normalizedOptions.watch,
|
||||
deleteOutputPath: normalizedOptions.clean,
|
||||
getCustomTransformers: getCustomTrasformersFactory(
|
||||
normalizedOptions.transformers
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function* tscExecutor(
|
||||
_options: ExecutorOptions,
|
||||
context: ExecutorContext
|
||||
) {
|
||||
const { sourceRoot, root } =
|
||||
context.projectsConfigurations.projects[context.projectName];
|
||||
const options = normalizeOptions(_options, context.root, sourceRoot, root);
|
||||
|
||||
const { projectRoot, tmpTsConfig, target, dependencies } = checkDependencies(
|
||||
context,
|
||||
options.tsConfig
|
||||
);
|
||||
|
||||
if (tmpTsConfig) {
|
||||
options.tsConfig = tmpTsConfig;
|
||||
}
|
||||
|
||||
const tsLibDependency = getHelperDependency(
|
||||
HelperDependency.tsc,
|
||||
options.tsConfig,
|
||||
dependencies,
|
||||
context.projectGraph
|
||||
);
|
||||
|
||||
if (tsLibDependency) {
|
||||
dependencies.push(tsLibDependency);
|
||||
}
|
||||
|
||||
const assetHandler = new CopyAssetsHandler({
|
||||
projectDir: projectRoot,
|
||||
rootDir: context.root,
|
||||
outputDir: _options.outputPath,
|
||||
assets: _options.assets,
|
||||
includeIgnoredFiles: _options.includeIgnoredAssetFiles,
|
||||
});
|
||||
|
||||
const tsCompilationOptions = createTypeScriptCompilationOptions(
|
||||
options,
|
||||
context
|
||||
);
|
||||
|
||||
const typescriptCompilation = compileTypeScriptFiles(
|
||||
options,
|
||||
tsCompilationOptions,
|
||||
async () => {
|
||||
await assetHandler.processAllAssetsOnce();
|
||||
if (options.generatePackageJson) {
|
||||
updatePackageJson(
|
||||
{
|
||||
...options,
|
||||
additionalEntryPoints: createEntryPoints(
|
||||
options.additionalEntryPoints,
|
||||
context.root
|
||||
),
|
||||
format: [
|
||||
determineModuleFormatFromTsConfig(
|
||||
options.tsConfig,
|
||||
options.projectRoot,
|
||||
context.root
|
||||
),
|
||||
],
|
||||
},
|
||||
context,
|
||||
target,
|
||||
dependencies
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!isDaemonEnabled() && options.watch) {
|
||||
output.warn({
|
||||
title:
|
||||
'Nx Daemon is not enabled. Assets and package.json files will not be updated when files change.',
|
||||
});
|
||||
}
|
||||
|
||||
if (isDaemonEnabled() && options.watch) {
|
||||
const disposeWatchAssetChanges =
|
||||
await assetHandler.watchAndProcessOnAssetChange();
|
||||
let disposePackageJsonChanges: undefined | (() => void);
|
||||
if (options.generatePackageJson) {
|
||||
disposePackageJsonChanges = await watchForSingleFileChanges(
|
||||
context.projectName,
|
||||
options.projectRoot,
|
||||
'package.json',
|
||||
() =>
|
||||
updatePackageJson(
|
||||
{
|
||||
...options,
|
||||
additionalEntryPoints: createEntryPoints(
|
||||
options.additionalEntryPoints,
|
||||
context.root
|
||||
),
|
||||
format: [
|
||||
determineModuleFormatFromTsConfig(
|
||||
options.tsConfig,
|
||||
options.projectRoot,
|
||||
context.root
|
||||
),
|
||||
],
|
||||
},
|
||||
context,
|
||||
target,
|
||||
dependencies
|
||||
)
|
||||
);
|
||||
}
|
||||
const handleTermination = async (exitCode: number) => {
|
||||
await typescriptCompilation.close();
|
||||
disposeWatchAssetChanges();
|
||||
disposePackageJsonChanges?.();
|
||||
process.exit(exitCode);
|
||||
};
|
||||
process.on('SIGINT', () => handleTermination(128 + 2));
|
||||
process.on('SIGTERM', () => handleTermination(128 + 15));
|
||||
}
|
||||
|
||||
return yield* typescriptCompilation.iterator;
|
||||
}
|
||||
|
||||
export default tscExecutor;
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface VerdaccioExecutorSchema {
|
||||
location: 'global' | 'user' | 'project' | 'none';
|
||||
storage?: string;
|
||||
port?: number;
|
||||
listenAddress: string; // default is 'localhost'
|
||||
config?: string;
|
||||
clear?: boolean;
|
||||
scopes?: string[];
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"version": 2,
|
||||
"title": "Verdaccio Local Registry",
|
||||
"description": "Start a local registry with Verdaccio.",
|
||||
"continuous": true,
|
||||
"cli": "nx",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "Location option for npm config",
|
||||
"default": "user",
|
||||
"enum": ["global", "user", "project", "none"]
|
||||
},
|
||||
"storage": {
|
||||
"type": "string",
|
||||
"description": "Path to the custom storage directory for Verdaccio"
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"description": "Port of local registry that Verdaccio should listen to"
|
||||
},
|
||||
"listenAddress": {
|
||||
"type": "string",
|
||||
"description": "Listen address that Verdaccio should listen to",
|
||||
"default": "localhost"
|
||||
},
|
||||
"config": {
|
||||
"type": "string",
|
||||
"description": "Path to the custom Verdaccio config file"
|
||||
},
|
||||
"clear": {
|
||||
"type": "boolean",
|
||||
"description": "Clear local registry storage before starting Verdaccio",
|
||||
"default": true
|
||||
},
|
||||
"scopes": {
|
||||
"type": "array",
|
||||
"description": "Scopes to be added to the Verdaccio config",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["port"]
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { ExecutorContext, logger } from '@nx/devkit';
|
||||
import { signalToCode } from '@nx/devkit/internal';
|
||||
import { ChildProcess, execSync, fork } from 'child_process';
|
||||
import detectPort from 'detect-port';
|
||||
import { existsSync, rmSync } from 'node:fs';
|
||||
import { join, resolve } from 'path';
|
||||
|
||||
import { VerdaccioExecutorSchema } from './schema';
|
||||
import { major } from 'semver';
|
||||
|
||||
let childProcess: ChildProcess;
|
||||
|
||||
let env: NodeJS.ProcessEnv = {
|
||||
SKIP_YARN_COREPACK_CHECK: 'true',
|
||||
...process.env,
|
||||
};
|
||||
|
||||
/**
|
||||
* - set npm and yarn to use local registry
|
||||
* - start verdaccio
|
||||
* - stop local registry when done
|
||||
*/
|
||||
export async function verdaccioExecutor(
|
||||
options: VerdaccioExecutorSchema,
|
||||
context: ExecutorContext
|
||||
) {
|
||||
try {
|
||||
require.resolve('verdaccio');
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
'Verdaccio is not installed. Please run `npm install verdaccio` or `yarn add verdaccio`'
|
||||
);
|
||||
}
|
||||
|
||||
if (options.storage) {
|
||||
options.storage = resolve(context.root, options.storage);
|
||||
if (options.clear && existsSync(options.storage)) {
|
||||
rmSync(options.storage, { recursive: true, force: true });
|
||||
console.log(`Cleared local registry storage folder ${options.storage}`);
|
||||
}
|
||||
}
|
||||
|
||||
const port = await detectPort(options.port);
|
||||
if (port !== options.port) {
|
||||
logger.info(`Port ${options.port} was occupied. Using port ${port}.`);
|
||||
options.port = port;
|
||||
}
|
||||
|
||||
const cleanupFunctions =
|
||||
options.location === 'none' ? [] : [setupNpm(options), setupYarn(options)];
|
||||
|
||||
const processExitListener = (signal?: number | NodeJS.Signals) => {
|
||||
for (const fn of cleanupFunctions) {
|
||||
fn();
|
||||
}
|
||||
if (childProcess) {
|
||||
childProcess.kill(signal);
|
||||
}
|
||||
};
|
||||
process.on('exit', processExitListener);
|
||||
process.on('SIGTERM', processExitListener);
|
||||
process.on('SIGINT', processExitListener);
|
||||
process.on('SIGHUP', processExitListener);
|
||||
|
||||
try {
|
||||
await startVerdaccio(options, context.root);
|
||||
} catch (e) {
|
||||
logger.error('Failed to start verdaccio: ' + e?.toString());
|
||||
return {
|
||||
success: false,
|
||||
port: options.port,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
port: options.port,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork the verdaccio process: https://verdaccio.org/docs/verdaccio-programmatically/#using-fork-from-child_process-module
|
||||
*/
|
||||
function startVerdaccio(
|
||||
options: VerdaccioExecutorSchema,
|
||||
workspaceRoot: string
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
childProcess = fork(
|
||||
require.resolve('verdaccio/bin/verdaccio'),
|
||||
createVerdaccioOptions(options, workspaceRoot),
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
VERDACCIO_HANDLE_KILL_SIGNALS: 'true',
|
||||
...(options.storage
|
||||
? { VERDACCIO_STORAGE_PATH: options.storage }
|
||||
: {}),
|
||||
},
|
||||
stdio: 'inherit',
|
||||
}
|
||||
);
|
||||
|
||||
childProcess.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
childProcess.on('disconnect', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
childProcess.on('exit', (code, signal) => {
|
||||
if (code === null) code = signalToCode(signal);
|
||||
if (code === 0) {
|
||||
resolve(code);
|
||||
} else {
|
||||
reject(code);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createVerdaccioOptions(
|
||||
options: VerdaccioExecutorSchema,
|
||||
workspaceRoot: string
|
||||
) {
|
||||
const verdaccioArgs: string[] = [];
|
||||
if (options.config) {
|
||||
verdaccioArgs.push('--config', join(workspaceRoot, options.config));
|
||||
} else {
|
||||
options.port ??= 4873; // set default port if config is not provided
|
||||
}
|
||||
|
||||
if (options.port) {
|
||||
const listenAddress = options.listenAddress
|
||||
? `${options.listenAddress}:${options.port.toString()}`
|
||||
: options.port.toString();
|
||||
verdaccioArgs.push('--listen', listenAddress);
|
||||
}
|
||||
return verdaccioArgs;
|
||||
}
|
||||
|
||||
function setupNpm(options: VerdaccioExecutorSchema) {
|
||||
try {
|
||||
execSync('npm --version', { env, windowsHide: true });
|
||||
} catch (e) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let npmRegistryPaths: string[] = [];
|
||||
const scopes: string[] = ['', ...(options.scopes || [])];
|
||||
|
||||
try {
|
||||
scopes.forEach((scope) => {
|
||||
const registryName = scope ? `${scope}:registry` : 'registry';
|
||||
try {
|
||||
npmRegistryPaths.push(
|
||||
execSync(
|
||||
`npm config get ${registryName} --location ${options.location}`,
|
||||
{ env, windowsHide: true }
|
||||
)
|
||||
?.toString()
|
||||
?.trim()
|
||||
?.replace('\u001b[2K\u001b[1G', '') // strip out ansi codes
|
||||
);
|
||||
execSync(
|
||||
`npm config set ${registryName} http://${options.listenAddress}:${options.port}/ --location ${options.location}`,
|
||||
{ env, windowsHide: true }
|
||||
);
|
||||
|
||||
execSync(
|
||||
`npm config set //${options.listenAddress}:${options.port}/:_authToken="secretVerdaccioToken" --location ${options.location}`,
|
||||
{ env, windowsHide: true }
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Set npm ${registryName} to http://${options.listenAddress}:${options.port}/`
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to set npm ${registryName} to http://${options.listenAddress}:${options.port}/: ${e.message}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
try {
|
||||
const currentNpmRegistryPath = execSync(
|
||||
`npm config get registry --location ${options.location}`,
|
||||
{ env, windowsHide: true }
|
||||
)
|
||||
?.toString()
|
||||
?.trim()
|
||||
?.replace('\u001b[2K\u001b[1G', ''); // strip out ansi codes
|
||||
scopes.forEach((scope, index) => {
|
||||
const registryName = scope ? `${scope}:registry` : 'registry';
|
||||
if (
|
||||
npmRegistryPaths[index] &&
|
||||
currentNpmRegistryPath.includes(options.listenAddress)
|
||||
) {
|
||||
execSync(
|
||||
`npm config set ${registryName} ${npmRegistryPaths[index]} --location ${options.location}`,
|
||||
{ env, windowsHide: true }
|
||||
);
|
||||
logger.info(
|
||||
`Reset npm ${registryName} to ${npmRegistryPaths[index]}`
|
||||
);
|
||||
} else {
|
||||
execSync(
|
||||
`npm config delete ${registryName} --location ${options.location}`,
|
||||
{
|
||||
env,
|
||||
windowsHide: true,
|
||||
}
|
||||
);
|
||||
logger.info('Cleared custom npm registry');
|
||||
}
|
||||
});
|
||||
execSync(
|
||||
`npm config delete //${options.listenAddress}:${options.port}/:_authToken --location ${options.location}`,
|
||||
{ env, windowsHide: true }
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to reset npm registry: ${e.message}`);
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to set npm registry to http://${options.listenAddress}:${options.port}/: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getYarnUnsafeHttpWhitelist(isYarnV1: boolean) {
|
||||
return !isYarnV1
|
||||
? new Set<string>(
|
||||
JSON.parse(
|
||||
execSync(`yarn config get unsafeHttpWhitelist --json`, {
|
||||
env,
|
||||
windowsHide: true,
|
||||
}).toString()
|
||||
)
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
function setYarnUnsafeHttpWhitelist(
|
||||
currentWhitelist: Set<string>,
|
||||
options: VerdaccioExecutorSchema
|
||||
) {
|
||||
if (currentWhitelist.size > 0) {
|
||||
execSync(
|
||||
`yarn config set unsafeHttpWhitelist --json '${JSON.stringify(
|
||||
Array.from(currentWhitelist)
|
||||
)}'` + (options.location === 'user' ? ' --home' : ''),
|
||||
{ env, windowsHide: true }
|
||||
);
|
||||
} else {
|
||||
execSync(
|
||||
`yarn config unset unsafeHttpWhitelist` +
|
||||
(options.location === 'user' ? ' --home' : ''),
|
||||
{ env, windowsHide: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function setupYarn(options: VerdaccioExecutorSchema) {
|
||||
let isYarnV1;
|
||||
let yarnRegistryPaths: string[] = [];
|
||||
const scopes: string[] = ['', ...(options.scopes || [])];
|
||||
|
||||
try {
|
||||
isYarnV1 =
|
||||
major(
|
||||
execSync('yarn --version', { env, windowsHide: true }).toString().trim()
|
||||
) === 1;
|
||||
} catch {
|
||||
// This would fail if yarn is not installed which is okay
|
||||
return () => {};
|
||||
}
|
||||
try {
|
||||
const registryConfigName = isYarnV1 ? 'registry' : 'npmRegistryServer';
|
||||
|
||||
scopes.forEach((scope) => {
|
||||
const scopeName = scope ? `${scope}:` : '';
|
||||
|
||||
yarnRegistryPaths.push(
|
||||
execSync(`yarn config get ${scopeName}${registryConfigName}`, {
|
||||
env,
|
||||
windowsHide: true,
|
||||
})
|
||||
?.toString()
|
||||
?.trim()
|
||||
?.replace('\u001b[2K\u001b[1G', '') // strip out ansi codes
|
||||
);
|
||||
|
||||
execSync(
|
||||
`yarn config set ${scopeName}${registryConfigName} http://${options.listenAddress}:${options.port}/` +
|
||||
(options.location === 'user' ? ' --home' : ''),
|
||||
{ env, windowsHide: true }
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Set yarn ${scopeName}registry to http://${options.listenAddress}:${options.port}/`
|
||||
);
|
||||
});
|
||||
|
||||
const currentWhitelist: Set<string> | null =
|
||||
getYarnUnsafeHttpWhitelist(isYarnV1);
|
||||
|
||||
let whitelistedLocalhost = false;
|
||||
|
||||
if (!isYarnV1 && !currentWhitelist.has(options.listenAddress)) {
|
||||
whitelistedLocalhost = true;
|
||||
currentWhitelist.add(options.listenAddress);
|
||||
|
||||
setYarnUnsafeHttpWhitelist(currentWhitelist, options);
|
||||
logger.info(
|
||||
`Whitelisted http://${options.listenAddress}:${options.port}/ as an unsafe http server`
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
try {
|
||||
const currentYarnRegistryPath = execSync(
|
||||
`yarn config get ${registryConfigName}`,
|
||||
{ env, windowsHide: true }
|
||||
)
|
||||
?.toString()
|
||||
?.trim()
|
||||
?.replace('\u001b[2K\u001b[1G', ''); // strip out ansi codes
|
||||
|
||||
scopes.forEach((scope, index) => {
|
||||
const registryName = scope
|
||||
? `${scope}:${registryConfigName}`
|
||||
: registryConfigName;
|
||||
|
||||
if (
|
||||
yarnRegistryPaths[index] &&
|
||||
currentYarnRegistryPath.includes(options.listenAddress)
|
||||
) {
|
||||
execSync(
|
||||
`yarn config set ${registryName} ${yarnRegistryPaths[index]}` +
|
||||
(options.location === 'user' ? ' --home' : ''),
|
||||
{
|
||||
env,
|
||||
|
||||
windowsHide: true,
|
||||
}
|
||||
);
|
||||
logger.info(
|
||||
`Reset yarn ${registryName} to ${yarnRegistryPaths[index]}`
|
||||
);
|
||||
} else {
|
||||
execSync(
|
||||
`yarn config ${isYarnV1 ? 'delete' : 'unset'} ${registryName}` +
|
||||
(options.location === 'user' ? ' --home' : ''),
|
||||
{ env, windowsHide: true }
|
||||
);
|
||||
logger.info(`Cleared custom yarn ${registryConfigName}`);
|
||||
}
|
||||
});
|
||||
if (whitelistedLocalhost) {
|
||||
const currentWhitelist: Set<string> =
|
||||
getYarnUnsafeHttpWhitelist(isYarnV1);
|
||||
|
||||
if (currentWhitelist.has(options.listenAddress)) {
|
||||
currentWhitelist.delete(options.listenAddress);
|
||||
|
||||
setYarnUnsafeHttpWhitelist(currentWhitelist, options);
|
||||
logger.info(
|
||||
`Removed http://${options.listenAddress}:${options.port}/ as an unsafe http server`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to reset yarn registry: ${e.message}`);
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to set yarn registry to http://${options.listenAddress}:${options.port}/: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default verdaccioExecutor;
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'nx/src/internal-testing-utils/mock-project-graph';
|
||||
|
||||
import {
|
||||
addProjectConfiguration,
|
||||
readJson,
|
||||
readProjectConfiguration,
|
||||
Tree,
|
||||
writeJson,
|
||||
} from '@nx/devkit';
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import { join } from 'path';
|
||||
import { LibraryGeneratorSchema } from '../library/schema';
|
||||
import { libraryGenerator as jsLibraryGenerator } from '../library/library';
|
||||
import { convertToSwcGenerator } from './convert-to-swc';
|
||||
|
||||
describe('convert to swc', () => {
|
||||
let tree: Tree;
|
||||
|
||||
const defaultLibGenerationOptions: Omit<LibraryGeneratorSchema, 'directory'> =
|
||||
{
|
||||
skipTsConfig: false,
|
||||
unitTestRunner: 'jest',
|
||||
skipFormat: false,
|
||||
linter: 'eslint',
|
||||
testEnvironment: 'jsdom',
|
||||
js: false,
|
||||
strict: true,
|
||||
config: 'project',
|
||||
bundler: 'tsc',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
});
|
||||
|
||||
it('should convert tsc to swc', async () => {
|
||||
await jsLibraryGenerator(tree, {
|
||||
...defaultLibGenerationOptions,
|
||||
directory: 'tsc-lib',
|
||||
bundler: 'tsc',
|
||||
});
|
||||
|
||||
expect(
|
||||
readProjectConfiguration(tree, 'tsc-lib').targets['build']['executor']
|
||||
).toEqual('@nx/js:tsc');
|
||||
|
||||
await convertToSwcGenerator(tree, { project: 'tsc-lib' });
|
||||
|
||||
expect(
|
||||
readProjectConfiguration(tree, 'tsc-lib').targets['build']['executor']
|
||||
).toEqual('@nx/js:swc');
|
||||
expect(
|
||||
tree.exists(
|
||||
join(readProjectConfiguration(tree, 'tsc-lib').root, '.swcrc')
|
||||
)
|
||||
).toEqual(true);
|
||||
expect(
|
||||
readJson(tree, 'package.json').devDependencies['@swc/core']
|
||||
).toBeDefined();
|
||||
expect(
|
||||
readJson(tree, 'tsc-lib/package.json').dependencies['@swc/helpers']
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle project configuration without targets', async () => {
|
||||
addProjectConfiguration(tree, 'lib1', { root: 'lib1' });
|
||||
|
||||
await expect(
|
||||
convertToSwcGenerator(tree, { project: 'lib1' })
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should not add swc dependencies when no target was updated', async () => {
|
||||
addProjectConfiguration(tree, 'lib1', { root: 'lib1' });
|
||||
writeJson(tree, 'lib1/package.json', { dependencies: {} });
|
||||
|
||||
await convertToSwcGenerator(tree, { project: 'lib1' });
|
||||
|
||||
expect(
|
||||
readJson(tree, 'package.json').devDependencies['@swc/core']
|
||||
).not.toBeDefined();
|
||||
expect(
|
||||
readJson(tree, 'lib1/package.json').dependencies['@swc/helpers']
|
||||
).not.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
addDependenciesToPackageJson,
|
||||
installPackagesTask,
|
||||
ProjectConfiguration,
|
||||
readJson,
|
||||
readProjectConfiguration,
|
||||
Tree,
|
||||
updateProjectConfiguration,
|
||||
} from '@nx/devkit';
|
||||
import { join } from 'path';
|
||||
import { assertSupportedTypescriptVersion } from '../../utils/assert-supported-typescript-version';
|
||||
import { addSwcConfig } from '../../utils/swc/add-swc-config';
|
||||
import { addSwcDependencies } from '../../utils/swc/add-swc-dependencies';
|
||||
import { swcHelpersVersion } from '../../utils/versions';
|
||||
import { ConvertToSwcGeneratorSchema } from './schema';
|
||||
|
||||
export async function convertToSwcGenerator(
|
||||
tree: Tree,
|
||||
schema: ConvertToSwcGeneratorSchema
|
||||
) {
|
||||
assertSupportedTypescriptVersion(tree);
|
||||
|
||||
const options = normalizeOptions(schema);
|
||||
const projectConfiguration = readProjectConfiguration(tree, options.project);
|
||||
|
||||
const updated = updateProjectBuildTargets(
|
||||
tree,
|
||||
projectConfiguration,
|
||||
options.project,
|
||||
options.targets
|
||||
);
|
||||
|
||||
return updated ? checkSwcDependencies(tree, projectConfiguration) : () => {};
|
||||
}
|
||||
|
||||
function normalizeOptions(
|
||||
schema: ConvertToSwcGeneratorSchema
|
||||
): ConvertToSwcGeneratorSchema {
|
||||
const options = { ...schema };
|
||||
|
||||
if (!options.targets) {
|
||||
options.targets = ['build'];
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function updateProjectBuildTargets(
|
||||
tree: Tree,
|
||||
projectConfiguration: ProjectConfiguration,
|
||||
projectName: string,
|
||||
projectTargets: string[]
|
||||
) {
|
||||
let updated = false;
|
||||
for (const target of projectTargets) {
|
||||
const targetConfiguration = projectConfiguration.targets?.[target];
|
||||
if (!targetConfiguration || targetConfiguration.executor !== '@nx/js:tsc')
|
||||
continue;
|
||||
targetConfiguration.executor = '@nx/js:swc';
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
updateProjectConfiguration(tree, projectName, projectConfiguration);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
function checkSwcDependencies(
|
||||
tree: Tree,
|
||||
projectConfiguration: ProjectConfiguration
|
||||
) {
|
||||
const isSwcrcPresent = tree.exists(join(projectConfiguration.root, '.swcrc'));
|
||||
|
||||
const packageJson = readJson(tree, 'package.json');
|
||||
const projectPackageJsonPath = join(
|
||||
projectConfiguration.root,
|
||||
'package.json'
|
||||
);
|
||||
const projectPackageJson = readJson(tree, projectPackageJsonPath);
|
||||
|
||||
const hasSwcDependency =
|
||||
packageJson.dependencies && packageJson.dependencies['@swc/core'];
|
||||
|
||||
const hasSwcHelpers =
|
||||
projectPackageJson.dependencies &&
|
||||
projectPackageJson.dependencies['@swc/helpers'];
|
||||
|
||||
if (isSwcrcPresent && hasSwcDependency && hasSwcHelpers) return;
|
||||
|
||||
if (!isSwcrcPresent) {
|
||||
addSwcConfig(tree, projectConfiguration.root);
|
||||
}
|
||||
|
||||
if (!hasSwcDependency) {
|
||||
addSwcDependencies(tree);
|
||||
}
|
||||
|
||||
if (!hasSwcHelpers) {
|
||||
addDependenciesToPackageJson(
|
||||
tree,
|
||||
{ '@swc/helpers': swcHelpersVersion },
|
||||
{},
|
||||
projectPackageJsonPath,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (!hasSwcDependency) {
|
||||
installPackagesTask(tree);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default convertToSwcGenerator;
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface ConvertToSwcGeneratorSchema {
|
||||
project: string;
|
||||
targets?: string[];
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"$id": "NxTypescriptLibrary",
|
||||
"cli": "nx",
|
||||
"title": "Convert a TSC library to SWC",
|
||||
"description": "Convert a TSC library to SWC.",
|
||||
"type": "object",
|
||||
"examples": [
|
||||
{
|
||||
"command": "nx g swc mylib",
|
||||
"description": "Convert `libs/myapp/mylib` to SWC."
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Library name.",
|
||||
"$default": {
|
||||
"$source": "argv",
|
||||
"index": 0
|
||||
},
|
||||
"x-prompt": "What name would you like to use for the library?",
|
||||
"pattern": "^[a-zA-Z].*$"
|
||||
},
|
||||
"targets": {
|
||||
"type": "array",
|
||||
"description": "List of targets to convert.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "Target to convert."
|
||||
},
|
||||
"default": ["build"]
|
||||
}
|
||||
},
|
||||
"required": ["project"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"moduleResolution": "<%= moduleResolution %>",
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"target": "es2015",
|
||||
"module": "esnext",
|
||||
"lib": ["es2020", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"strict": false,
|
||||
"paths": {}
|
||||
},
|
||||
"exclude": ["node_modules", "tmp"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"lib": ["es2022"],<% if (platform === 'node') { %>
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",<% } else { %>
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",<% } %>
|
||||
"noEmitOnError": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "es2022",
|
||||
"customConditions": ["<%= customCondition %>"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compileOnSave": false,
|
||||
"files": [],
|
||||
"references": []
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { writeJson, readJson, Tree, updateJson, readNxJson } from '@nx/devkit';
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import init from './init';
|
||||
import { typescriptVersion } from '../../utils/versions';
|
||||
|
||||
describe('js init generator', () => {
|
||||
let tree: Tree;
|
||||
|
||||
beforeEach(() => {
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
// Remove files that should be part of the init generator
|
||||
tree.delete('tsconfig.base.json');
|
||||
tree.delete('.prettierrc');
|
||||
});
|
||||
|
||||
it('should install prettier package', async () => {
|
||||
await init(tree, {});
|
||||
|
||||
const packageJson = readJson(tree, 'package.json');
|
||||
expect(packageJson.devDependencies['prettier']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create .prettierrc and .prettierignore files', async () => {
|
||||
await init(tree, {});
|
||||
|
||||
const prettierrc = readJson(tree, '.prettierrc');
|
||||
expect(prettierrc).toEqual({ singleQuote: true });
|
||||
|
||||
const prettierignore = tree.read('.prettierignore', 'utf-8');
|
||||
expect(prettierignore).toMatch(/\n\/coverage/);
|
||||
expect(prettierignore).toMatch(/\n\/dist/);
|
||||
expect(prettierignore).toMatch(/\n\/\.nx\/cache/);
|
||||
});
|
||||
|
||||
it('should not overwrite existing .prettierrc and .prettierignore files', async () => {
|
||||
writeJson(tree, '.prettierrc', { singleQuote: false });
|
||||
tree.write('.prettierignore', `# custom ignore file`);
|
||||
|
||||
await init(tree, {});
|
||||
|
||||
const prettierrc = readJson(tree, '.prettierrc');
|
||||
expect(prettierrc).toEqual({ singleQuote: false });
|
||||
|
||||
const prettierignore = tree.read('.prettierignore', 'utf-8');
|
||||
expect(prettierignore).toContain('# custom ignore file');
|
||||
});
|
||||
|
||||
it('should not overwrite prettier configuration specified in other formats', async () => {
|
||||
tree.delete('.prettierrc');
|
||||
tree.delete('.prettierignore');
|
||||
tree.write('.prettierrc.js', `module.exports = { singleQuote: true };`);
|
||||
|
||||
await init(tree, {});
|
||||
|
||||
expect(tree.exists('.prettierrc')).toBeFalsy();
|
||||
expect(tree.exists('.prettierignore')).toBeTruthy();
|
||||
expect(tree.read('.prettierrc.js', 'utf-8')).toContain(
|
||||
`module.exports = { singleQuote: true };`
|
||||
);
|
||||
});
|
||||
|
||||
it('should add prettier vscode extension if .vscode/extensions.json file exists', async () => {
|
||||
// No existing recommendations
|
||||
writeJson(tree, '.vscode/extensions.json', {});
|
||||
|
||||
await init(tree, {});
|
||||
|
||||
let json = readJson(tree, '.vscode/extensions.json');
|
||||
expect(json).toEqual({
|
||||
recommendations: ['esbenp.prettier-vscode'],
|
||||
});
|
||||
|
||||
// Existing recommendations
|
||||
writeJson(tree, '.vscode/extensions.json', { recommendations: ['foo'] });
|
||||
|
||||
await init(tree, {});
|
||||
|
||||
json = readJson(tree, '.vscode/extensions.json');
|
||||
expect(json).toEqual({
|
||||
recommendations: ['foo', 'esbenp.prettier-vscode'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip adding prettier extension if .vscode/extensions.json file does not exist', async () => {
|
||||
await init(tree, {});
|
||||
|
||||
expect(tree.exists('.vscode/extensions.json')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should install typescript package when it is not already installed', async () => {
|
||||
await init(tree, {});
|
||||
|
||||
const packageJson = readJson(tree, 'package.json');
|
||||
expect(packageJson.devDependencies['typescript']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw when the installed typescript version is below the supported floor', async () => {
|
||||
updateJson(tree, 'package.json', (json) => {
|
||||
json.devDependencies = { ...json.devDependencies, typescript: '~4.5.0' };
|
||||
return json;
|
||||
});
|
||||
|
||||
await expect(init(tree, {})).rejects.toThrow(
|
||||
'Unsupported version of `typescript` detected'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not overwrite installed typescript version when is a supported version', async () => {
|
||||
updateJson(tree, 'package.json', (json) => {
|
||||
json.devDependencies = { ...json.devDependencies, typescript: '~5.8.3' };
|
||||
return json;
|
||||
});
|
||||
|
||||
await init(tree, {});
|
||||
|
||||
const packageJson = readJson(tree, 'package.json');
|
||||
expect(packageJson.devDependencies['typescript']).toBe('~5.8.3');
|
||||
expect(packageJson.devDependencies['typescript']).not.toBe(
|
||||
typescriptVersion
|
||||
);
|
||||
});
|
||||
|
||||
it('should support skipping base tsconfig file', async () => {
|
||||
await init(tree, {
|
||||
addTsConfigBase: false,
|
||||
});
|
||||
|
||||
expect(tree.exists('tsconfig.base.json')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should support skipping prettier setup', async () => {
|
||||
await init(tree, {
|
||||
formatter: 'none',
|
||||
});
|
||||
|
||||
const packageJson = readJson(tree, 'package.json');
|
||||
expect(packageJson.devDependencies['prettier']).toBeUndefined();
|
||||
expect(tree.exists('.prettierignore')).toBeFalsy();
|
||||
expect(tree.exists('.prettierrc')).toBeFalsy();
|
||||
});
|
||||
|
||||
it.each`
|
||||
fileName | importHelpers | shouldAdd
|
||||
${'tsconfig.json'} | ${true} | ${true}
|
||||
${'tsconfig.base.json'} | ${true} | ${true}
|
||||
${'tsconfig.json'} | ${false} | ${false}
|
||||
${'tsconfig.base.json'} | ${false} | ${false}
|
||||
${null} | ${false} | ${false}
|
||||
`(
|
||||
'should add tslib if importHelpers is true in base tsconfig',
|
||||
async ({ fileName, importHelpers, shouldAdd }) => {
|
||||
if (fileName) {
|
||||
writeJson(tree, fileName, {
|
||||
compilerOptions: {
|
||||
importHelpers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await init(tree, {
|
||||
addTsConfigBase: false,
|
||||
});
|
||||
|
||||
const packageJson = readJson(tree, 'package.json');
|
||||
expect(!!packageJson.devDependencies?.['tslib']).toBe(shouldAdd);
|
||||
}
|
||||
);
|
||||
|
||||
it('should register the @nx/js/typescript plugin when addTsPlugin is true', async () => {
|
||||
await init(tree, { addTsPlugin: true });
|
||||
|
||||
const nxJson = readNxJson(tree);
|
||||
const typescriptPlugin = nxJson.plugins.find(
|
||||
(plugin) =>
|
||||
typeof plugin === 'object' && plugin.plugin === '@nx/js/typescript'
|
||||
);
|
||||
expect(typescriptPlugin).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create tsconfig.json and tsconfig.base.json files when addTsPlugin is true', async () => {
|
||||
await init(tree, { addTsPlugin: true });
|
||||
|
||||
expect(tree.read('tsconfig.json', 'utf-8')).toMatchInlineSnapshot(`
|
||||
"{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compileOnSave": false,
|
||||
"files": [],
|
||||
"references": []
|
||||
}
|
||||
"
|
||||
`);
|
||||
expect(tree.read('tsconfig.base.json', 'utf-8')).toMatchInlineSnapshot(`
|
||||
"{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"lib": ["es2022"],
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"noEmitOnError": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "es2022",
|
||||
"customConditions": ["@proj/source"]
|
||||
}
|
||||
}
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
it.each`
|
||||
platform | module | moduleResolution
|
||||
${'web'} | ${'esnext'} | ${'bundler'}
|
||||
${'node'} | ${'nodenext'} | ${'nodenext'}
|
||||
`(
|
||||
'should set module: $module and moduleResolution: $moduleResolution in tsconfig.base.json for platform: $platform',
|
||||
async ({ platform, module, moduleResolution }) => {
|
||||
await init(tree, { addTsPlugin: true, platform });
|
||||
|
||||
const tsconfigBaseJson = readJson(tree, 'tsconfig.base.json');
|
||||
expect(tsconfigBaseJson.compilerOptions.module).toBe(module);
|
||||
expect(tsconfigBaseJson.compilerOptions.moduleResolution).toBe(
|
||||
moduleResolution
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { addPlugin } from '@nx/devkit/internal';
|
||||
import {
|
||||
addDependenciesToPackageJson,
|
||||
createProjectGraphAsync,
|
||||
ensurePackage,
|
||||
formatFiles,
|
||||
generateFiles,
|
||||
GeneratorCallback,
|
||||
readJson,
|
||||
readNxJson,
|
||||
runTasksInSerial,
|
||||
Tree,
|
||||
} from '@nx/devkit';
|
||||
import { join } from 'path';
|
||||
import { createNodesV2 } from '../../plugins/typescript/plugin';
|
||||
import { assertSupportedTypescriptVersion } from '../../utils/assert-supported-typescript-version';
|
||||
import { generatePrettierSetup } from '../../utils/prettier';
|
||||
import { getTsConfigBaseOptions } from '../../utils/typescript/create-ts-config';
|
||||
import { getRootTsConfigFileName } from '../../utils/typescript/ts-config';
|
||||
import {
|
||||
getCustomConditionName,
|
||||
isUsingTsSolutionSetup,
|
||||
} from '../../utils/typescript/ts-solution-setup';
|
||||
import {
|
||||
nxVersion,
|
||||
prettierVersion,
|
||||
swcHelpersVersion,
|
||||
tsLibVersion,
|
||||
typescriptVersion,
|
||||
} from '../../utils/versions';
|
||||
import { InitSchema } from './schema';
|
||||
|
||||
export async function initGenerator(
|
||||
tree: Tree,
|
||||
schema: InitSchema
|
||||
): Promise<GeneratorCallback> {
|
||||
schema.addTsPlugin ??= false;
|
||||
const isUsingNewTsSetup = schema.addTsPlugin || isUsingTsSolutionSetup(tree);
|
||||
schema.formatter ??= isUsingNewTsSetup ? 'none' : 'prettier';
|
||||
|
||||
return initGeneratorInternal(tree, {
|
||||
addTsConfigBase: true,
|
||||
...schema,
|
||||
});
|
||||
}
|
||||
|
||||
export async function initGeneratorInternal(
|
||||
tree: Tree,
|
||||
schema: InitSchema
|
||||
): Promise<GeneratorCallback> {
|
||||
assertSupportedTypescriptVersion(tree);
|
||||
|
||||
const tasks: GeneratorCallback[] = [];
|
||||
|
||||
const nxJson = readNxJson(tree);
|
||||
schema.addPlugin ??=
|
||||
process.env.NX_ADD_PLUGINS !== 'false' &&
|
||||
nxJson.useInferencePlugins !== false;
|
||||
schema.addTsPlugin ??= schema.addPlugin;
|
||||
|
||||
if (schema.addTsPlugin) {
|
||||
await addPlugin(
|
||||
tree,
|
||||
await createProjectGraphAsync(),
|
||||
'@nx/js/typescript',
|
||||
createNodesV2,
|
||||
{
|
||||
typecheck: [
|
||||
{ targetName: 'typecheck' },
|
||||
{ targetName: 'tsc:typecheck' },
|
||||
{ targetName: 'tsc-typecheck' },
|
||||
],
|
||||
build: [
|
||||
{
|
||||
targetName: 'build',
|
||||
configName: 'tsconfig.lib.json',
|
||||
buildDepsName: 'build-deps',
|
||||
watchDepsName: 'watch-deps',
|
||||
},
|
||||
{
|
||||
targetName: 'tsc:build',
|
||||
configName: 'tsconfig.lib.json',
|
||||
buildDepsName: 'tsc:build-deps',
|
||||
watchDepsName: 'tsc:watch-deps',
|
||||
},
|
||||
{
|
||||
targetName: 'tsc-build',
|
||||
configName: 'tsconfig.lib.json',
|
||||
buildDepsName: 'tsc-build-deps',
|
||||
watchDepsName: 'tsc-watch-deps',
|
||||
},
|
||||
],
|
||||
},
|
||||
schema.updatePackageScripts
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.addTsConfigBase && !getRootTsConfigFileName(tree)) {
|
||||
if (schema.addTsPlugin) {
|
||||
const platform = schema.platform ?? 'node';
|
||||
const customCondition = getCustomConditionName(tree);
|
||||
generateFiles(tree, join(__dirname, './files/ts-solution'), '.', {
|
||||
platform,
|
||||
customCondition,
|
||||
tmpl: '',
|
||||
});
|
||||
} else {
|
||||
generateFiles(tree, join(__dirname, './files/non-ts-solution'), '.', {
|
||||
fileName: schema.tsConfigName ?? 'tsconfig.base.json',
|
||||
moduleResolution: getTsConfigBaseOptions(tree).moduleResolution,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const devDependencies: Record<string, string> = {
|
||||
'@nx/js': nxVersion,
|
||||
// Required by SWC-compiled output (decorators -> @swc/helpers/_/_ts_decorate
|
||||
// imports). The default @nx/jest setup transforms with @swc/jest, so any
|
||||
// workspace using decorators (NestJS, Angular, etc.) needs @swc/helpers
|
||||
// resolvable at test time. Cheap to ship and avoids per-generator install.
|
||||
'@swc/helpers': swcHelpersVersion,
|
||||
};
|
||||
// @swc-node/register and @swc/core are no longer installed by init - native
|
||||
// Node.js type stripping handles .ts config loading on Node 23+ (or 22.6+
|
||||
// with --experimental-strip-types). loadTsFile registers swc/ts-node lazily
|
||||
// when a config uses syntax native strip can't handle.
|
||||
|
||||
if (!schema.js) {
|
||||
devDependencies['typescript'] = typescriptVersion;
|
||||
}
|
||||
|
||||
if (schema.formatter === 'prettier') {
|
||||
const prettierTask = generatePrettierSetup(tree, {
|
||||
skipPackageJson: schema.skipPackageJson,
|
||||
});
|
||||
tasks.push(prettierTask);
|
||||
}
|
||||
|
||||
const rootTsConfigFileName = getRootTsConfigFileName(tree);
|
||||
// If the root tsconfig file uses `importHelpers` then we must install tslib
|
||||
// in order to run tsc for build and typecheck.
|
||||
if (rootTsConfigFileName) {
|
||||
const rootTsConfig = readJson(tree, rootTsConfigFileName);
|
||||
if (rootTsConfig.compilerOptions?.importHelpers) {
|
||||
devDependencies['tslib'] = tsLibVersion;
|
||||
}
|
||||
}
|
||||
|
||||
const installTask = !schema.skipPackageJson
|
||||
? addDependenciesToPackageJson(
|
||||
tree,
|
||||
{},
|
||||
devDependencies,
|
||||
undefined,
|
||||
schema.keepExistingVersions ?? true
|
||||
)
|
||||
: () => {};
|
||||
tasks.push(installTask);
|
||||
|
||||
if (
|
||||
!schema.skipPackageJson &&
|
||||
// For `create-nx-workspace` or `nx g @nx/js:init`, we want to make sure users didn't set formatter to none.
|
||||
// For programmatic usage, the formatter is normally undefined, and we want prettier to continue to be ensured, even if not ultimately installed.
|
||||
schema.formatter !== 'none'
|
||||
) {
|
||||
ensurePackage('prettier', prettierVersion);
|
||||
}
|
||||
|
||||
if (!schema.skipFormat) {
|
||||
// even if skipPackageJson === true, we can safely run formatFiles, prettier might
|
||||
// have been installed earlier and if not, the formatFiles function still handles it
|
||||
await formatFiles(tree);
|
||||
}
|
||||
|
||||
return runTasksInSerial(...tasks);
|
||||
}
|
||||
|
||||
export default initGenerator;
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
export interface InitSchema {
|
||||
addTsConfigBase?: boolean;
|
||||
formatter?: 'none' | 'prettier';
|
||||
js?: boolean;
|
||||
keepExistingVersions?: boolean;
|
||||
skipFormat?: boolean;
|
||||
skipPackageJson?: boolean;
|
||||
tsConfigName?: string;
|
||||
addPlugin?: boolean;
|
||||
updatePackageScripts?: boolean;
|
||||
addTsPlugin?: boolean;
|
||||
platform?: 'web' | 'node';
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"$id": "NxTypescriptInit",
|
||||
"cli": "nx",
|
||||
"title": "Init nx/js",
|
||||
"description": "Init generator placeholder for nx/js.",
|
||||
"properties": {
|
||||
"formatter": {
|
||||
"description": "The tool to use for code formatting.",
|
||||
"type": "string",
|
||||
"enum": ["none", "prettier"],
|
||||
"default": "none"
|
||||
},
|
||||
"js": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Use JavaScript instead of TypeScript"
|
||||
},
|
||||
"skipFormat": {
|
||||
"type": "boolean",
|
||||
"aliases": ["skip-format"],
|
||||
"description": "Skip formatting files.",
|
||||
"x-priority": "internal"
|
||||
},
|
||||
"skipPackageJson": {
|
||||
"type": "boolean",
|
||||
"description": "Skip adding package.json dependencies",
|
||||
"x-priority": "internal"
|
||||
},
|
||||
"keepExistingVersions": {
|
||||
"type": "boolean",
|
||||
"x-priority": "internal",
|
||||
"description": "Keep existing dependencies versions",
|
||||
"default": true
|
||||
},
|
||||
"addTsConfigBase": {
|
||||
"type": "boolean",
|
||||
"description": "Add a base tsconfig file to the workspace.",
|
||||
"x-priority": "internal",
|
||||
"default": false
|
||||
},
|
||||
"tsConfigName": {
|
||||
"type": "string",
|
||||
"description": "Customize the generated base tsconfig file name.",
|
||||
"x-priority": "internal"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`lib --unit-test-runner jest should generate test configuration with swc and js 1`] = `
|
||||
"/* eslint-disable */
|
||||
const { readFileSync } = require('fs');
|
||||
|
||||
// Reading the SWC compilation config and remove the "exclude"
|
||||
// for the test files to be compiled by SWC
|
||||
const { exclude: _, ...swcJestConfig } = JSON.parse(
|
||||
readFileSync(\`\${__dirname}/.swcrc\`, 'utf-8'),
|
||||
);
|
||||
|
||||
// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves.
|
||||
// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude"
|
||||
if (swcJestConfig.swcrc === undefined) {
|
||||
swcJestConfig.swcrc = false;
|
||||
}
|
||||
|
||||
// Uncomment if using global setup/teardown files being transformed via swc
|
||||
// https://nx.dev/nx-api/jest/documents/overview#global-setupteardown-with-nx-libraries
|
||||
// jest needs EsModule Interop to find the default exported setup/teardown functions
|
||||
// swcJestConfig.module.noInterop = false;
|
||||
|
||||
module.exports = {
|
||||
displayName: 'my-lib',
|
||||
preset: '../jest.preset.js',
|
||||
transform: {
|
||||
'^.+\\\\.[tj]s$': ['@swc/jest', swcJestConfig],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
testEnvironment: 'jsdom',
|
||||
coverageDirectory: '../coverage/my-lib',
|
||||
};
|
||||
"
|
||||
`;
|
||||
@@ -0,0 +1,30 @@
|
||||
/* eslint-disable */
|
||||
<% if(ext === 'js' || ext === 'cts') {%>const { readFileSync } = require('fs')<% } else { %>import { readFileSync } from 'fs';<% } %>
|
||||
|
||||
// Reading the SWC compilation config and remove the "exclude"
|
||||
// for the test files to be compiled by SWC
|
||||
const { exclude: _, ...swcJestConfig } = JSON.parse(
|
||||
readFileSync(`${__dirname}/.swcrc`, 'utf-8')
|
||||
);
|
||||
|
||||
// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves.
|
||||
// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude"
|
||||
if (swcJestConfig.swcrc === undefined) {
|
||||
swcJestConfig.swcrc = false;
|
||||
}
|
||||
|
||||
// Uncomment if using global setup/teardown files being transformed via swc
|
||||
// https://nx.dev/nx-api/jest/documents/overview#global-setupteardown-with-nx-libraries
|
||||
// jest needs EsModule Interop to find the default exported setup/teardown functions
|
||||
// swcJestConfig.module.noInterop = false;
|
||||
|
||||
<% if(ext === 'js' || ext === 'cts') {%>module.exports =<% } else { %>export default<% } %> {
|
||||
displayName: '<%= project %>',
|
||||
preset: '<%= offsetFromRoot %><%= jestPreset %>',
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['@swc/jest', swcJestConfig],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
testEnvironment: '<%= testEnvironment %>',
|
||||
coverageDirectory: '<%= offsetFromRoot %>coverage/<%= projectRoot %>'
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/<%= fileNameImport %>';
|
||||
@@ -0,0 +1,7 @@
|
||||
import { <%= propertyName %> } from './<%= fileNameImport %>';
|
||||
|
||||
describe('<%= propertyName %>', () => {
|
||||
it('should work', () => {
|
||||
expect(<%= propertyName %>()).toEqual('<%= name %>');
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export function <%= propertyName %>(): string {
|
||||
return '<%= name %>';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# <%= name %>
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).<% if (buildable) { %>
|
||||
|
||||
## Building
|
||||
|
||||
Run `<%= cliCommand %> build <%= name %>` to build the library.<% } %><% if (unitTestRunner !== 'none') { %>
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `<%= cliCommand %> test <%= name %>` to execute the unit tests via <% if(unitTestRunner === 'jest') { %>[Jest](https://jestjs.io)<% } else { %>[Vitest](https://vitest.dev/)<% } %>.<% } %>
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "<%= offsetFromRoot %>dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"<% if (js) { %>, "src/**/*.js"<% } %>]
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "<%= offsetFromRoot %>tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo",
|
||||
"emitDeclarationOnly": <%= emitDeclarationOnly %>,<% if (compilerOptions.length) { %>
|
||||
<%- compilerOptions %>,<% } %>
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"<% if (js) { %>, "src/**/*.js"<% } %>],
|
||||
"references": []
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+49
@@ -0,0 +1,49 @@
|
||||
import type { ProjectNameAndRootOptions } from '@nx/devkit/internal';
|
||||
// nx-ignore-next-line
|
||||
const { Linter, LinterType } = require('@nx/eslint'); // use require to import to avoid circular dependency
|
||||
import type { ProjectPackageManagerWorkspaceState } from '../../utils/package-manager-workspaces';
|
||||
|
||||
export type Compiler = 'tsc' | 'swc';
|
||||
export type Bundler = 'swc' | 'tsc' | 'rollup' | 'vite' | 'esbuild' | 'none';
|
||||
|
||||
export interface LibraryGeneratorSchema {
|
||||
directory: string;
|
||||
name?: string;
|
||||
skipFormat?: boolean;
|
||||
tags?: string;
|
||||
skipTsConfig?: boolean;
|
||||
skipPackageJson?: boolean;
|
||||
includeBabelRc?: boolean;
|
||||
unitTestRunner?: 'jest' | 'vitest' | 'none';
|
||||
linter?: Linter | LinterType;
|
||||
testEnvironment?: 'jsdom' | 'node';
|
||||
importPath?: string;
|
||||
js?: boolean;
|
||||
pascalCaseFiles?: boolean;
|
||||
strict?: boolean;
|
||||
publishable?: boolean;
|
||||
buildable?: boolean;
|
||||
setParserOptionsProject?: boolean;
|
||||
config?: 'workspace' | 'project' | 'npm-scripts';
|
||||
compiler?: Compiler;
|
||||
bundler?: Bundler;
|
||||
skipTypeCheck?: boolean;
|
||||
minimal?: boolean;
|
||||
rootProject?: boolean;
|
||||
addPlugin?: boolean;
|
||||
useProjectJson?: boolean;
|
||||
useTscExecutor?: boolean;
|
||||
}
|
||||
|
||||
export interface NormalizedLibraryGeneratorOptions
|
||||
extends LibraryGeneratorSchema {
|
||||
name: string;
|
||||
projectNames: ProjectNameAndRootOptions['names'];
|
||||
fileName: string;
|
||||
projectRoot: string;
|
||||
parsedTags: string[];
|
||||
importPath?: string;
|
||||
hasPlugin: boolean;
|
||||
isUsingTsSolutionConfig: boolean;
|
||||
shouldUseSwcJest: boolean;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"$id": "NxTypescriptLibrary",
|
||||
"cli": "nx",
|
||||
"title": "Create a TypeScript Library",
|
||||
"description": "Create a TypeScript Library.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string",
|
||||
"description": "A directory where the lib is placed.",
|
||||
"$default": {
|
||||
"$source": "argv",
|
||||
"index": 0
|
||||
},
|
||||
"x-prompt": "Which directory do you want to create the library in?"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Library name.",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"bundler": {
|
||||
"description": "The bundler to use. Choosing 'none' means this library is not buildable.",
|
||||
"type": "string",
|
||||
"enum": ["swc", "tsc", "rollup", "vite", "esbuild", "none"],
|
||||
"default": "tsc",
|
||||
"x-prompt": "Which bundler would you like to use to build the library? Choose 'none' to skip build setup.",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"linter": {
|
||||
"description": "The tool to use for running lint checks.",
|
||||
"type": "string",
|
||||
"enum": ["none", "eslint"],
|
||||
"x-priority": "important"
|
||||
},
|
||||
"unitTestRunner": {
|
||||
"description": "Test runner to use for unit tests.",
|
||||
"type": "string",
|
||||
"enum": ["none", "jest", "vitest"],
|
||||
"x-priority": "important"
|
||||
},
|
||||
"tags": {
|
||||
"type": "string",
|
||||
"description": "Add tags to the library (used for linting)."
|
||||
},
|
||||
"skipFormat": {
|
||||
"description": "Skip formatting files.",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"x-priority": "internal"
|
||||
},
|
||||
"skipPackageJson": {
|
||||
"description": "Do not add dependencies to `package.json`.",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"x-priority": "internal"
|
||||
},
|
||||
"skipTsConfig": {
|
||||
"type": "boolean",
|
||||
"description": "Do not update tsconfig.json for development experience.",
|
||||
"default": false,
|
||||
"x-priority": "internal"
|
||||
},
|
||||
"includeBabelRc": {
|
||||
"type": "boolean",
|
||||
"description": "Include a .babelrc configuration to compile TypeScript files"
|
||||
},
|
||||
"testEnvironment": {
|
||||
"type": "string",
|
||||
"enum": ["jsdom", "node"],
|
||||
"description": "The test environment to use if unitTestRunner is set to jest or vitest.",
|
||||
"default": "node"
|
||||
},
|
||||
"js": {
|
||||
"type": "boolean",
|
||||
"description": "Generate JavaScript files rather than TypeScript files.",
|
||||
"default": false
|
||||
},
|
||||
"strict": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to enable tsconfig strict mode or not.",
|
||||
"default": true
|
||||
},
|
||||
"publishable": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Configure the library ready for use with `nx release` (https://nx.dev/core-features/manage-releases).",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"importPath": {
|
||||
"type": "string",
|
||||
"description": "The library name used to import it, like @myorg/my-awesome-lib. Required for publishable library.",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"buildable": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Generate a buildable library.",
|
||||
"x-deprecated": "Use the `bundler` option for greater control (swc, tsc, rollup, vite, esbuild, none)."
|
||||
},
|
||||
"setParserOptionsProject": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not to configure the ESLint `parserOptions.project` option. We do not do this by default for lint performance reasons.",
|
||||
"default": false
|
||||
},
|
||||
"config": {
|
||||
"type": "string",
|
||||
"enum": ["workspace", "project", "npm-scripts"],
|
||||
"default": "project",
|
||||
"description": "Determines whether the project's executors should be configured in `workspace.json`, `project.json` or as npm scripts.",
|
||||
"x-priority": "internal"
|
||||
},
|
||||
"compiler": {
|
||||
"type": "string",
|
||||
"enum": ["tsc", "swc"],
|
||||
"description": "The compiler used by the build and test targets",
|
||||
"x-deprecated": "Use the `bundler` option for greater control (swc, tsc, rollup, vite, esbuild, none)."
|
||||
},
|
||||
"skipTypeCheck": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to skip TypeScript type checking for SWC compiler.",
|
||||
"default": false
|
||||
},
|
||||
"minimal": {
|
||||
"type": "boolean",
|
||||
"description": "Generate a library with a minimal setup. No README.md generated.",
|
||||
"default": false
|
||||
},
|
||||
"useProjectJson": {
|
||||
"type": "boolean",
|
||||
"description": "Use a `project.json` configuration file instead of inlining the Nx configuration in the `package.json` file."
|
||||
}
|
||||
},
|
||||
"required": ["directory"],
|
||||
"examplesFile": "../../../docs/library-examples.md"
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
import {
|
||||
getPackageManagerCommand,
|
||||
readJson,
|
||||
Tree,
|
||||
updateJson,
|
||||
output,
|
||||
ProjectConfiguration,
|
||||
} from '@nx/devkit';
|
||||
import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports';
|
||||
import {
|
||||
addReleaseConfigForNonTsSolution,
|
||||
addReleaseConfigForTsSolution,
|
||||
} from './add-release-config';
|
||||
|
||||
describe('add release config', () => {
|
||||
let tree: Tree;
|
||||
|
||||
beforeEach(() => {
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
tree.write('/.gitignore', '');
|
||||
});
|
||||
|
||||
describe('addReleaseConfigForNonTsSolution', () => {
|
||||
it('should update the nx-release-publish target to specify dist/{projectRoot} as the package root', async () => {
|
||||
const projectConfig: ProjectConfiguration = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForNonTsSolution(tree, 'my-lib', projectConfig);
|
||||
expect(projectConfig.targets?.['nx-release-publish']).toEqual({
|
||||
options: {
|
||||
packageRoot: 'dist/{projectRoot}',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not change preVersionCommand if it already exists', async () => {
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = {
|
||||
version: {
|
||||
preVersionCommand: 'echo "hello world"',
|
||||
},
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForNonTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
version: {
|
||||
preVersionCommand: 'echo "hello world"',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not add projects if no release config exists', async () => {
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
delete json.release;
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForNonTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
version: {
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not add projects if release config exists but doesn't specify groups or projects", async () => {
|
||||
const existingReleaseConfig = {
|
||||
version: {
|
||||
git: {},
|
||||
},
|
||||
changelog: {
|
||||
projectChangelogs: true,
|
||||
},
|
||||
};
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = existingReleaseConfig;
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForNonTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
...existingReleaseConfig,
|
||||
version: {
|
||||
...existingReleaseConfig.version,
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not change projects if it already exists as a string and matches the new project', async () => {
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = {
|
||||
projects: '*',
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForNonTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
projects: '*',
|
||||
version: {
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not change projects if it already exists as an array and matches the new project by name', async () => {
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = {
|
||||
projects: ['something-else', 'my-lib'],
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForNonTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
projects: ['something-else', 'my-lib'],
|
||||
version: {
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not change projects if it already exists and matches the new project by tag', async () => {
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = {
|
||||
projects: ['tag:one'],
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig: ProjectConfiguration = {
|
||||
root: 'libs/my-lib',
|
||||
tags: ['one', 'two'],
|
||||
};
|
||||
await addReleaseConfigForNonTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
projects: ['tag:one'],
|
||||
version: {
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not change projects if it already exists and matches the new project by root directory', async () => {
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = {
|
||||
projects: ['packages/*'],
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'packages/my-lib' };
|
||||
await addReleaseConfigForNonTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
projects: ['packages/*'],
|
||||
version: {
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should append project to projects if projects exists as an array, but doesn't already match the new project", async () => {
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = {
|
||||
projects: ['something-else'],
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForNonTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
projects: ['something-else', 'my-lib'],
|
||||
version: {
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should convert projects to an array and append the new project to it if projects exists as a string, but doesn't already match the new project", async () => {
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = {
|
||||
projects: 'packages',
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForNonTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
projects: ['packages', 'my-lib'],
|
||||
version: {
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not change projects if it already exists as groups config and matches the new project', async () => {
|
||||
const existingReleaseConfig = {
|
||||
groups: {
|
||||
group1: {
|
||||
projects: ['something-else'],
|
||||
},
|
||||
group2: {
|
||||
projects: ['my-lib'],
|
||||
},
|
||||
},
|
||||
};
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = existingReleaseConfig;
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForNonTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
groups: existingReleaseConfig.groups,
|
||||
version: {
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should warn the user if their defined groups don't match the new project", async () => {
|
||||
const outputSpy = jest
|
||||
.spyOn(output, 'warn')
|
||||
.mockImplementationOnce(() => {
|
||||
return undefined as never;
|
||||
});
|
||||
|
||||
const existingReleaseConfig = {
|
||||
groups: {
|
||||
group1: {
|
||||
projects: ['something-else'],
|
||||
},
|
||||
group2: {
|
||||
projects: ['other-thing'],
|
||||
},
|
||||
},
|
||||
};
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = existingReleaseConfig;
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForNonTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
groups: existingReleaseConfig.groups,
|
||||
version: {
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
expect(outputSpy).toHaveBeenCalledWith({
|
||||
title: `Could not find a release group that includes my-lib`,
|
||||
bodyLines: [
|
||||
`Ensure that my-lib is included in a release group's "projects" list in nx.json so it can be published with "nx release"`,
|
||||
],
|
||||
});
|
||||
|
||||
outputSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addReleaseConfigForTsSolution', () => {
|
||||
it('should not update set nx-release-publish target', async () => {
|
||||
const projectConfig: ProjectConfiguration = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForTsSolution(tree, 'my-lib', projectConfig);
|
||||
expect(projectConfig.targets?.['nx-release-publish']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not change preVersionCommand if it already exists', async () => {
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = {
|
||||
version: {
|
||||
preVersionCommand: 'echo "hello world"',
|
||||
},
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
version: {
|
||||
preVersionCommand: 'echo "hello world"',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not add projects if no release config exists', async () => {
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
delete json.release;
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
version: {
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not add projects if release config exists but doesn't specify groups or projects", async () => {
|
||||
const existingReleaseConfig = {
|
||||
version: {
|
||||
git: {},
|
||||
},
|
||||
changelog: {
|
||||
projectChangelogs: true,
|
||||
},
|
||||
};
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = existingReleaseConfig;
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
...existingReleaseConfig,
|
||||
version: {
|
||||
...existingReleaseConfig.version,
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not change projects if it already exists as a string and matches the new project', async () => {
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = {
|
||||
projects: '*',
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
projects: '*',
|
||||
version: {
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not change projects if it already exists as an array and matches the new project by name', async () => {
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = {
|
||||
projects: ['something-else', 'my-lib'],
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
projects: ['something-else', 'my-lib'],
|
||||
version: {
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not change projects if it already exists and matches the new project by tag', async () => {
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = {
|
||||
projects: ['tag:one'],
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig: ProjectConfiguration = {
|
||||
root: 'libs/my-lib',
|
||||
tags: ['one', 'two'],
|
||||
};
|
||||
await addReleaseConfigForTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
projects: ['tag:one'],
|
||||
version: {
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not change projects if it already exists and matches the new project by root directory', async () => {
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = {
|
||||
projects: ['packages/*'],
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'packages/my-lib' };
|
||||
await addReleaseConfigForTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
projects: ['packages/*'],
|
||||
version: {
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should append project to projects if projects exists as an array, but doesn't already match the new project", async () => {
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = {
|
||||
projects: ['something-else'],
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
projects: ['something-else', 'my-lib'],
|
||||
version: {
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should convert projects to an array and append the new project to it if projects exists as a string, but doesn't already match the new project", async () => {
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = {
|
||||
projects: 'packages',
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
projects: ['packages', 'my-lib'],
|
||||
version: {
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not change projects if it already exists as groups config and matches the new project', async () => {
|
||||
const existingReleaseConfig = {
|
||||
groups: {
|
||||
group1: {
|
||||
projects: ['something-else'],
|
||||
},
|
||||
group2: {
|
||||
projects: ['my-lib'],
|
||||
},
|
||||
},
|
||||
};
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = existingReleaseConfig;
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
groups: existingReleaseConfig.groups,
|
||||
version: {
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should warn the user if their defined groups don't match the new project", async () => {
|
||||
const outputSpy = jest
|
||||
.spyOn(output, 'warn')
|
||||
.mockImplementationOnce(() => {
|
||||
return undefined as never;
|
||||
});
|
||||
|
||||
const existingReleaseConfig = {
|
||||
groups: {
|
||||
group1: {
|
||||
projects: ['something-else'],
|
||||
},
|
||||
group2: {
|
||||
projects: ['other-thing'],
|
||||
},
|
||||
},
|
||||
};
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
json.release = existingReleaseConfig;
|
||||
return json;
|
||||
});
|
||||
|
||||
const projectConfig = { root: 'libs/my-lib' };
|
||||
await addReleaseConfigForTsSolution(tree, 'my-lib', projectConfig);
|
||||
|
||||
const nxJson = readJson(tree, 'nx.json');
|
||||
expect(nxJson.release).toEqual({
|
||||
groups: existingReleaseConfig.groups,
|
||||
version: {
|
||||
preVersionCommand: `${
|
||||
getPackageManagerCommand().dlx
|
||||
} nx run-many -t build`,
|
||||
},
|
||||
});
|
||||
expect(outputSpy).toHaveBeenCalledWith({
|
||||
title: `Could not find a release group that includes my-lib`,
|
||||
bodyLines: [
|
||||
`Ensure that my-lib is included in a release group's "projects" list in nx.json so it can be published with "nx release"`,
|
||||
],
|
||||
});
|
||||
|
||||
outputSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
GeneratorCallback,
|
||||
getPackageManagerCommand,
|
||||
joinPathFragments,
|
||||
output,
|
||||
ProjectConfiguration,
|
||||
ProjectGraphProjectNode,
|
||||
readNxJson,
|
||||
runTasksInSerial,
|
||||
Tree,
|
||||
writeJson,
|
||||
} from '@nx/devkit';
|
||||
import { findMatchingProjects } from 'nx/src/utils/find-matching-projects';
|
||||
import setupVerdaccio from '../../setup-verdaccio/generator';
|
||||
|
||||
/**
|
||||
* Adds release option in nx.json to build the project before versioning
|
||||
*/
|
||||
export async function addReleaseConfigForTsSolution(
|
||||
tree: Tree,
|
||||
projectName: string,
|
||||
projectConfiguration: ProjectConfiguration
|
||||
): Promise<void> {
|
||||
const nxJson = readNxJson(tree);
|
||||
|
||||
const addPreVersionCommand = () => {
|
||||
const pmc = getPackageManagerCommand();
|
||||
|
||||
nxJson.release = {
|
||||
...nxJson.release,
|
||||
version: {
|
||||
preVersionCommand: `${pmc.dlx} nx run-many -t build`,
|
||||
...nxJson.release?.version,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// if the release configuration does not exist, it will be created
|
||||
if (!nxJson.release || (!nxJson.release.projects && !nxJson.release.groups)) {
|
||||
// skip adding any projects configuration since the new project should be
|
||||
// automatically included by nx release's default project detection logic
|
||||
addPreVersionCommand();
|
||||
writeJson(tree, 'nx.json', nxJson);
|
||||
return;
|
||||
}
|
||||
|
||||
const project: ProjectGraphProjectNode = {
|
||||
name: projectName,
|
||||
type: 'lib' as const,
|
||||
data: {
|
||||
root: projectConfiguration.root,
|
||||
tags: projectConfiguration.tags,
|
||||
},
|
||||
};
|
||||
|
||||
// if the project is already included in the release configuration, it will not be added again
|
||||
if (projectsConfigMatchesProject(nxJson.release.projects, project)) {
|
||||
output.log({
|
||||
title: `Project already included in existing release configuration`,
|
||||
});
|
||||
addPreVersionCommand();
|
||||
writeJson(tree, 'nx.json', nxJson);
|
||||
return;
|
||||
}
|
||||
|
||||
// if the release configuration is a string, it will be converted to an array and added to it
|
||||
if (Array.isArray(nxJson.release.projects)) {
|
||||
nxJson.release.projects.push(projectName);
|
||||
addPreVersionCommand();
|
||||
writeJson(tree, 'nx.json', nxJson);
|
||||
output.log({
|
||||
title: `Added project to existing release configuration`,
|
||||
});
|
||||
}
|
||||
|
||||
if (nxJson.release.groups) {
|
||||
const allGroups = Object.entries(nxJson.release.groups);
|
||||
|
||||
for (const [name, group] of allGroups) {
|
||||
if (projectsConfigMatchesProject(group.projects, project)) {
|
||||
addPreVersionCommand();
|
||||
writeJson(tree, 'nx.json', nxJson);
|
||||
output.log({
|
||||
title: `Project already included in existing release configuration for group ${name}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
output.warn({
|
||||
title: `Could not find a release group that includes ${projectName}`,
|
||||
bodyLines: [
|
||||
`Ensure that ${projectName} is included in a release group's "projects" list in nx.json so it can be published with "nx release"`,
|
||||
],
|
||||
});
|
||||
addPreVersionCommand();
|
||||
writeJson(tree, 'nx.json', nxJson);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof nxJson.release.projects === 'string') {
|
||||
nxJson.release.projects = [nxJson.release.projects, projectName];
|
||||
addPreVersionCommand();
|
||||
writeJson(tree, 'nx.json', nxJson);
|
||||
output.log({
|
||||
title: `Added project to existing release configuration`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add release configuration for non-ts solution projects
|
||||
* Add release option in project.json and add packageRoot to nx-release-publish target
|
||||
*/
|
||||
export async function addReleaseConfigForNonTsSolution(
|
||||
tree: Tree,
|
||||
projectName: string,
|
||||
projectConfiguration: ProjectConfiguration,
|
||||
defaultOutputDirectory: string = 'dist'
|
||||
) {
|
||||
const packageRoot = joinPathFragments(
|
||||
defaultOutputDirectory,
|
||||
'{projectRoot}'
|
||||
);
|
||||
|
||||
projectConfiguration.targets ??= {};
|
||||
projectConfiguration.targets['nx-release-publish'] = {
|
||||
options: {
|
||||
packageRoot,
|
||||
},
|
||||
};
|
||||
|
||||
projectConfiguration.release = {
|
||||
version: {
|
||||
manifestRootsToUpdate: [packageRoot],
|
||||
// using git tags to determine the current version is required here because
|
||||
// the version in the package root is overridden with every build
|
||||
currentVersionResolver: 'git-tag',
|
||||
fallbackCurrentVersionResolver: 'disk',
|
||||
},
|
||||
};
|
||||
|
||||
await addReleaseConfigForTsSolution(tree, projectName, projectConfiguration);
|
||||
|
||||
return projectConfiguration;
|
||||
}
|
||||
|
||||
function projectsConfigMatchesProject(
|
||||
projectsConfig: string | string[] | undefined,
|
||||
project: ProjectGraphProjectNode
|
||||
): boolean {
|
||||
if (!projectsConfig) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof projectsConfig === 'string') {
|
||||
projectsConfig = [projectsConfig];
|
||||
}
|
||||
|
||||
const graph: Record<string, ProjectGraphProjectNode> = {
|
||||
[project.name]: project,
|
||||
};
|
||||
|
||||
const matchingProjects = findMatchingProjects(projectsConfig, graph);
|
||||
|
||||
return matchingProjects.includes(project.name);
|
||||
}
|
||||
|
||||
export async function releaseTasks(tree: Tree): Promise<GeneratorCallback> {
|
||||
return runTasksInSerial(
|
||||
await setupVerdaccio(tree, { skipFormat: true }),
|
||||
() => logNxReleaseDocsInfo()
|
||||
);
|
||||
}
|
||||
|
||||
function logNxReleaseDocsInfo() {
|
||||
output.log({
|
||||
title: `📦 To learn how to publish this library, see https://nx.dev/core-features/manage-releases.`,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import 'nx/src/internal-testing-utils/mock-project-graph';
|
||||
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import {
|
||||
addProjectConfiguration,
|
||||
readProjectConfiguration,
|
||||
Tree,
|
||||
writeJson,
|
||||
} from '@nx/devkit';
|
||||
|
||||
import { setupBuildGenerator } from './generator';
|
||||
|
||||
describe('setup-build generator', () => {
|
||||
let tree: Tree;
|
||||
|
||||
beforeEach(() => {
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
addProjectConfiguration(tree, 'mypkg', {
|
||||
root: 'packages/mypkg',
|
||||
sourceRoot: 'packages/mypkg/src',
|
||||
});
|
||||
});
|
||||
|
||||
it('should find main and tsConfig files automatically', async () => {
|
||||
tree.write('packages/mypkg/src/index.ts', 'console.log("hello world");');
|
||||
writeJson(tree, 'packages/mypkg/tsconfig.lib.json', {});
|
||||
|
||||
await setupBuildGenerator(tree, { project: 'mypkg', bundler: 'tsc' });
|
||||
|
||||
const config = readProjectConfiguration(tree, 'mypkg');
|
||||
expect(config).toMatchObject({
|
||||
targets: {
|
||||
build: {
|
||||
executor: '@nx/js:tsc',
|
||||
options: {
|
||||
outputPath: 'dist/packages/mypkg',
|
||||
main: 'packages/mypkg/src/index.ts',
|
||||
tsConfig: 'packages/mypkg/tsconfig.lib.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should support user-defined main and tsConfig files', async () => {
|
||||
tree.write(
|
||||
'packages/mypkg/src/custom-main.ts',
|
||||
'console.log("hello world");'
|
||||
);
|
||||
writeJson(tree, 'packages/mypkg/tsconfig.custom.json', {});
|
||||
|
||||
await setupBuildGenerator(tree, {
|
||||
project: 'mypkg',
|
||||
bundler: 'tsc',
|
||||
main: 'packages/mypkg/src/custom-main.ts',
|
||||
tsConfig: 'packages/mypkg/tsconfig.custom.json',
|
||||
});
|
||||
|
||||
const config = readProjectConfiguration(tree, 'mypkg');
|
||||
expect(config).toMatchObject({
|
||||
targets: {
|
||||
build: {
|
||||
executor: '@nx/js:tsc',
|
||||
options: {
|
||||
outputPath: 'dist/packages/mypkg',
|
||||
main: 'packages/mypkg/src/custom-main.ts',
|
||||
tsConfig: 'packages/mypkg/tsconfig.custom.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should error when eithe main or tsConfig is not found', async () => {
|
||||
expect(
|
||||
setupBuildGenerator(tree, {
|
||||
project: 'mypkg',
|
||||
bundler: 'tsc',
|
||||
})
|
||||
).rejects.toThrow(/Cannot locate a main file for mypkg/);
|
||||
|
||||
tree.write('packages/mypkg/src/main.ts', 'console.log("hello world");');
|
||||
|
||||
expect(
|
||||
setupBuildGenerator(tree, {
|
||||
project: 'mypkg',
|
||||
bundler: 'tsc',
|
||||
})
|
||||
).rejects.toThrow(/Cannot locate a tsConfig file for mypkg/);
|
||||
|
||||
expect(
|
||||
setupBuildGenerator(tree, {
|
||||
project: 'mypkg',
|
||||
bundler: 'tsc',
|
||||
main: 'packages/mypkg/src/custom-main.ts',
|
||||
})
|
||||
).rejects.toThrow(/Cannot locate a main file for mypkg/);
|
||||
|
||||
expect(
|
||||
setupBuildGenerator(tree, {
|
||||
project: 'mypkg',
|
||||
bundler: 'tsc',
|
||||
tsConfig: 'packages/mypkg/tsconfig.custom.json',
|
||||
})
|
||||
).rejects.toThrow(/Cannot locate a tsConfig file for mypkg/);
|
||||
});
|
||||
|
||||
it('should support --bundler=swc', async () => {
|
||||
tree.write('packages/mypkg/src/main.ts', 'console.log("hello world");');
|
||||
writeJson(tree, 'packages/mypkg/tsconfig.lib.json', {});
|
||||
|
||||
await setupBuildGenerator(tree, {
|
||||
project: 'mypkg',
|
||||
bundler: 'swc',
|
||||
});
|
||||
|
||||
const config = readProjectConfiguration(tree, 'mypkg');
|
||||
expect(config).toMatchObject({
|
||||
targets: {
|
||||
build: {
|
||||
executor: '@nx/js:swc',
|
||||
options: {
|
||||
outputPath: 'dist/packages/mypkg',
|
||||
main: 'packages/mypkg/src/main.ts',
|
||||
tsConfig: 'packages/mypkg/tsconfig.lib.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should support --bundler=rollup', async () => {
|
||||
tree.write('packages/mypkg/src/main.ts', 'console.log("hello world");');
|
||||
writeJson(tree, 'packages/mypkg/tsconfig.lib.json', {});
|
||||
|
||||
await setupBuildGenerator(tree, {
|
||||
project: 'mypkg',
|
||||
bundler: 'rollup',
|
||||
});
|
||||
|
||||
expect(tree.exists('packages/mypkg/rollup.config.cjs')).toBe(true);
|
||||
});
|
||||
|
||||
it('should support --bundler=esbuild', async () => {
|
||||
tree.write('packages/mypkg/src/main.ts', 'console.log("hello world");');
|
||||
writeJson(tree, 'packages/mypkg/tsconfig.lib.json', {});
|
||||
|
||||
await setupBuildGenerator(tree, {
|
||||
project: 'mypkg',
|
||||
bundler: 'esbuild',
|
||||
});
|
||||
|
||||
const config = readProjectConfiguration(tree, 'mypkg');
|
||||
expect(config).toMatchObject({
|
||||
targets: {
|
||||
build: {
|
||||
executor: '@nx/esbuild:esbuild',
|
||||
options: {
|
||||
outputPath: 'dist/packages/mypkg',
|
||||
main: 'packages/mypkg/src/main.ts',
|
||||
tsConfig: 'packages/mypkg/tsconfig.lib.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// TODO(@jaysoo): For some reason, there is no vite.config file here. Please re-enable this test
|
||||
xit('should support --bundler=vite', async () => {
|
||||
tree.write('packages/mypkg/src/main.ts', 'console.log("hello world");');
|
||||
writeJson(tree, 'packages/mypkg/tsconfig.lib.json', {});
|
||||
|
||||
await setupBuildGenerator(tree, {
|
||||
project: 'mypkg',
|
||||
bundler: 'vite',
|
||||
});
|
||||
|
||||
expect(tree.exists('packages/mypkg/vite.config.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('should support different --buildTarget', async () => {
|
||||
tree.write('packages/mypkg/src/main.ts', 'console.log("hello world");');
|
||||
writeJson(tree, 'packages/mypkg/tsconfig.lib.json', {});
|
||||
|
||||
await setupBuildGenerator(tree, {
|
||||
project: 'mypkg',
|
||||
bundler: 'tsc',
|
||||
buildTarget: 'custom-build',
|
||||
});
|
||||
|
||||
const config = readProjectConfiguration(tree, 'mypkg');
|
||||
expect(config).toMatchObject({
|
||||
targets: {
|
||||
'custom-build': {
|
||||
executor: '@nx/js:tsc',
|
||||
options: {
|
||||
outputPath: 'dist/packages/mypkg',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
import {
|
||||
addBuildTargetDefaults,
|
||||
readTargetDefaultsForTarget,
|
||||
} from '@nx/devkit/internal';
|
||||
import {
|
||||
ensurePackage,
|
||||
formatFiles,
|
||||
joinPathFragments,
|
||||
readJson,
|
||||
readNxJson,
|
||||
readProjectConfiguration,
|
||||
runTasksInSerial,
|
||||
updateNxJson,
|
||||
updateProjectConfiguration,
|
||||
writeJson,
|
||||
type GeneratorCallback,
|
||||
type ProjectConfiguration,
|
||||
type Tree,
|
||||
} from '@nx/devkit';
|
||||
import { basename, dirname, join } from 'node:path/posix';
|
||||
import { mergeTargetConfigurations } from 'nx/src/devkit-internals';
|
||||
import type { PackageJson } from 'nx/src/utils/package-json';
|
||||
import { assertSupportedTypescriptVersion } from '../../utils/assert-supported-typescript-version';
|
||||
import { getImportPath } from '../../utils/get-import-path';
|
||||
import {
|
||||
getUpdatedPackageJsonContent,
|
||||
type SupportedFormat,
|
||||
} from '../../utils/package-json/update-package-json';
|
||||
import { addSwcConfig } from '../../utils/swc/add-swc-config';
|
||||
import { addSwcDependencies } from '../../utils/swc/add-swc-dependencies';
|
||||
import { ensureTypescript } from '../../utils/typescript/ensure-typescript';
|
||||
import { ensureProjectIsIncludedInPluginRegistrations } from '../../utils/typescript/plugin';
|
||||
import { readTsConfig } from '../../utils/typescript/ts-config';
|
||||
import {
|
||||
getDefinedCustomConditionName,
|
||||
getProjectSourceRoot,
|
||||
isUsingTsSolutionSetup,
|
||||
} from '../../utils/typescript/ts-solution-setup';
|
||||
import { nxVersion } from '../../utils/versions';
|
||||
import { SetupBuildGeneratorSchema } from './schema';
|
||||
|
||||
let ts: typeof import('typescript');
|
||||
|
||||
export async function setupBuildGenerator(
|
||||
tree: Tree,
|
||||
options: SetupBuildGeneratorSchema
|
||||
): Promise<GeneratorCallback> {
|
||||
assertSupportedTypescriptVersion(tree);
|
||||
|
||||
const tasks: GeneratorCallback[] = [];
|
||||
const project = readProjectConfiguration(tree, options.project);
|
||||
options.buildTarget ??= 'build';
|
||||
const prevBuildOptions = project.targets?.[options.buildTarget]?.options;
|
||||
|
||||
project.targets ??= {};
|
||||
|
||||
let mainFile: string;
|
||||
if (prevBuildOptions?.main) {
|
||||
mainFile = prevBuildOptions.main;
|
||||
} else if (options.main) {
|
||||
mainFile = options.main;
|
||||
} else {
|
||||
const root = getProjectSourceRoot(project, tree);
|
||||
for (const f of [
|
||||
joinPathFragments(root, 'main.ts'),
|
||||
joinPathFragments(root, 'main.js'),
|
||||
joinPathFragments(root, 'index.ts'),
|
||||
joinPathFragments(root, 'index.js'),
|
||||
]) {
|
||||
if (tree.exists(f)) {
|
||||
mainFile = f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!mainFile || !tree.exists(mainFile)) {
|
||||
throw new Error(
|
||||
`Cannot locate a main file for ${options.project}. Please specify one using --main=<file-path>.`
|
||||
);
|
||||
}
|
||||
options.main = mainFile;
|
||||
|
||||
let tsConfigFile: string;
|
||||
if (prevBuildOptions?.tsConfig) {
|
||||
tsConfigFile = prevBuildOptions.tsConfig;
|
||||
} else if (options.tsConfig) {
|
||||
tsConfigFile = options.tsConfig;
|
||||
} else {
|
||||
for (const f of [
|
||||
'tsconfig.lib.json',
|
||||
'tsconfig.app.json',
|
||||
'tsconfig.json',
|
||||
]) {
|
||||
const candidate = joinPathFragments(project.root, f);
|
||||
if (tree.exists(candidate)) {
|
||||
tsConfigFile = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!tsConfigFile || !tree.exists(tsConfigFile)) {
|
||||
throw new Error(
|
||||
`Cannot locate a tsConfig file for ${options.project}. Please specify one using --tsConfig=<file-path>.`
|
||||
);
|
||||
}
|
||||
options.tsConfig = tsConfigFile;
|
||||
|
||||
const isTsSolutionSetup = isUsingTsSolutionSetup(tree);
|
||||
const nxJson = readNxJson(tree);
|
||||
const addPlugin =
|
||||
process.env.NX_ADD_PLUGINS !== 'false' &&
|
||||
nxJson.useInferencePlugins !== false;
|
||||
|
||||
switch (options.bundler) {
|
||||
case 'vite': {
|
||||
const { viteConfigurationGenerator } = ensurePackage(
|
||||
'@nx/vite',
|
||||
nxVersion
|
||||
);
|
||||
const task = await viteConfigurationGenerator(tree, {
|
||||
buildTarget: options.buildTarget,
|
||||
project: options.project,
|
||||
newProject: false,
|
||||
uiFramework: 'none',
|
||||
includeVitest: false,
|
||||
includeLib: true,
|
||||
addPlugin,
|
||||
skipFormat: true,
|
||||
});
|
||||
tasks.push(task);
|
||||
break;
|
||||
}
|
||||
case 'esbuild': {
|
||||
const { configurationGenerator } = ensurePackage(
|
||||
'@nx/esbuild',
|
||||
nxVersion
|
||||
);
|
||||
const task = await configurationGenerator(tree, {
|
||||
main: mainFile,
|
||||
buildTarget: options.buildTarget,
|
||||
project: options.project,
|
||||
skipFormat: true,
|
||||
skipValidation: true,
|
||||
format: isTsSolutionSetup ? ['esm'] : ['cjs'],
|
||||
});
|
||||
tasks.push(task);
|
||||
break;
|
||||
}
|
||||
case 'rollup': {
|
||||
const { configurationGenerator } = ensurePackage('@nx/rollup', nxVersion);
|
||||
const task = await configurationGenerator(tree, {
|
||||
buildTarget: options.buildTarget,
|
||||
main: mainFile,
|
||||
tsConfig: tsConfigFile,
|
||||
project: options.project,
|
||||
compiler: 'tsc',
|
||||
format: isTsSolutionSetup ? ['esm'] : ['cjs', 'esm'],
|
||||
addPlugin,
|
||||
skipFormat: true,
|
||||
skipValidation: true,
|
||||
});
|
||||
tasks.push(task);
|
||||
break;
|
||||
}
|
||||
case 'tsc': {
|
||||
if (isTsSolutionSetup) {
|
||||
const nxJson = readNxJson(tree);
|
||||
ensureProjectIsIncludedInPluginRegistrations(
|
||||
nxJson,
|
||||
project.root,
|
||||
options.buildTarget
|
||||
);
|
||||
updateNxJson(tree, nxJson);
|
||||
updatePackageJsonForTsc(tree, options, project);
|
||||
} else {
|
||||
addBuildTargetDefaults(tree, '@nx/js:tsc');
|
||||
|
||||
const outputPath = joinPathFragments('dist', project.root);
|
||||
project.targets[options.buildTarget] = {
|
||||
executor: `@nx/js:tsc`,
|
||||
outputs: ['{options.outputPath}'],
|
||||
options: {
|
||||
outputPath,
|
||||
main: mainFile,
|
||||
tsConfig: tsConfigFile,
|
||||
assets: [],
|
||||
},
|
||||
};
|
||||
updateProjectConfiguration(tree, options.project, project);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'swc': {
|
||||
addBuildTargetDefaults(tree, '@nx/js:swc');
|
||||
|
||||
const outputPath = isTsSolutionSetup
|
||||
? joinPathFragments(project.root, 'dist')
|
||||
: joinPathFragments('dist', project.root);
|
||||
project.targets[options.buildTarget] = {
|
||||
executor: `@nx/js:swc`,
|
||||
outputs: ['{options.outputPath}'],
|
||||
options: {
|
||||
outputPath,
|
||||
main: mainFile,
|
||||
tsConfig: tsConfigFile,
|
||||
},
|
||||
};
|
||||
|
||||
if (isTsSolutionSetup) {
|
||||
project.targets[options.buildTarget].options.stripLeadingPaths = true;
|
||||
} else {
|
||||
project.targets[options.buildTarget].options.assets = [];
|
||||
}
|
||||
|
||||
updateProjectConfiguration(tree, options.project, project);
|
||||
tasks.push(addSwcDependencies(tree));
|
||||
addSwcConfig(tree, project.root, isTsSolutionSetup ? 'es6' : 'commonjs');
|
||||
if (isTsSolutionSetup) {
|
||||
updatePackageJsonForSwc(tree, options, project);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await formatFiles(tree);
|
||||
|
||||
return runTasksInSerial(...tasks);
|
||||
}
|
||||
|
||||
export default setupBuildGenerator;
|
||||
|
||||
function updatePackageJsonForTsc(
|
||||
tree: Tree,
|
||||
options: SetupBuildGeneratorSchema,
|
||||
project: ProjectConfiguration
|
||||
) {
|
||||
if (!ts) {
|
||||
ts = ensureTypescript();
|
||||
}
|
||||
|
||||
const tsconfig = readTsConfig(options.tsConfig, {
|
||||
...ts.sys,
|
||||
readFile: (p) => tree.read(p, 'utf-8'),
|
||||
fileExists: (p) => tree.exists(p),
|
||||
});
|
||||
|
||||
let main: string;
|
||||
let rootDir: string;
|
||||
let outputPath: string;
|
||||
if (project.targets?.[options.buildTarget]) {
|
||||
const mergedTarget = mergeTargetDefaults(
|
||||
tree,
|
||||
project,
|
||||
options.buildTarget
|
||||
);
|
||||
({ main, rootDir, outputPath } = mergedTarget.options);
|
||||
} else {
|
||||
main = options.main;
|
||||
|
||||
({ rootDir = project.root, outDir: outputPath } = tsconfig.options);
|
||||
const tsOutFile = tsconfig.options.outFile;
|
||||
|
||||
if (tsOutFile) {
|
||||
main = join(project.root, basename(tsOutFile));
|
||||
outputPath = dirname(tsOutFile);
|
||||
}
|
||||
|
||||
if (!outputPath) {
|
||||
outputPath = project.root;
|
||||
}
|
||||
}
|
||||
|
||||
const module = Object.keys(ts.ModuleKind).find(
|
||||
(m) => ts.ModuleKind[m] === tsconfig.options.module
|
||||
);
|
||||
const format: SupportedFormat[] = module.toLowerCase().startsWith('es')
|
||||
? ['esm']
|
||||
: ['cjs'];
|
||||
|
||||
updatePackageJson(
|
||||
tree,
|
||||
options.project,
|
||||
project.root,
|
||||
main,
|
||||
outputPath,
|
||||
rootDir,
|
||||
format
|
||||
);
|
||||
}
|
||||
|
||||
function updatePackageJsonForSwc(
|
||||
tree: Tree,
|
||||
options: SetupBuildGeneratorSchema,
|
||||
project: ProjectConfiguration
|
||||
) {
|
||||
const mergedTarget = mergeTargetDefaults(tree, project, options.buildTarget);
|
||||
const {
|
||||
main,
|
||||
outputPath,
|
||||
swcrc: swcrcPath = join(project.root, '.swcrc'),
|
||||
} = mergedTarget.options;
|
||||
|
||||
const swcrc = readJson(tree, swcrcPath);
|
||||
const format: SupportedFormat[] = swcrc.module?.type?.startsWith('es')
|
||||
? ['esm']
|
||||
: ['cjs'];
|
||||
|
||||
updatePackageJson(
|
||||
tree,
|
||||
options.project,
|
||||
project.root,
|
||||
main,
|
||||
outputPath,
|
||||
// we set the `stripLeadingPaths` option, so the rootDir would match the dirname of the entry point
|
||||
dirname(main),
|
||||
format
|
||||
);
|
||||
}
|
||||
|
||||
function updatePackageJson(
|
||||
tree: Tree,
|
||||
projectName: string,
|
||||
projectRoot: string,
|
||||
main: string,
|
||||
outputPath: string,
|
||||
rootDir: string,
|
||||
format?: SupportedFormat[]
|
||||
) {
|
||||
const packageJsonPath = join(projectRoot, 'package.json');
|
||||
let packageJson: PackageJson;
|
||||
if (tree.exists(packageJsonPath)) {
|
||||
packageJson = readJson(tree, packageJsonPath);
|
||||
} else {
|
||||
packageJson = {
|
||||
name: getImportPath(tree, projectName),
|
||||
version: '0.0.1',
|
||||
};
|
||||
}
|
||||
|
||||
packageJson = getUpdatedPackageJsonContent(packageJson, {
|
||||
main,
|
||||
outputPath,
|
||||
projectRoot,
|
||||
generateExportsField: true,
|
||||
packageJsonPath,
|
||||
rootDir,
|
||||
format,
|
||||
developmentConditionName: getDefinedCustomConditionName(tree),
|
||||
});
|
||||
writeJson(tree, packageJsonPath, packageJson);
|
||||
}
|
||||
|
||||
function mergeTargetDefaults(
|
||||
tree: Tree,
|
||||
project: ProjectConfiguration,
|
||||
buildTarget: string
|
||||
) {
|
||||
const nxJson = readNxJson(tree);
|
||||
const projectTarget = project.targets[buildTarget];
|
||||
|
||||
return mergeTargetConfigurations(
|
||||
projectTarget,
|
||||
readTargetDefaultsForTarget(
|
||||
buildTarget,
|
||||
nxJson.targetDefaults,
|
||||
projectTarget.executor
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface SetupBuildGeneratorSchema {
|
||||
project: string;
|
||||
bundler: 'tsc' | 'swc' | 'vite' | 'rollup' | 'esbuild';
|
||||
main?: string;
|
||||
tsConfig?: string;
|
||||
buildTarget?: string;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"$id": "SetupBuild",
|
||||
"title": "Setup Build",
|
||||
"description": "Sets up build target for a project.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Project to add the build target to.",
|
||||
"$default": {
|
||||
"$source": "argv",
|
||||
"index": 0
|
||||
},
|
||||
"x-prompt": "Which project do you want to add build to?",
|
||||
"x-dropdown": "projects",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"bundler": {
|
||||
"description": "The bundler to use to build the project.",
|
||||
"type": "string",
|
||||
"enum": ["tsc", "swc", "rollup", "vite", "esbuild"],
|
||||
"default": "tsc",
|
||||
"x-prompt": "Which bundler would you like to use to build the project?",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"main": {
|
||||
"description": "The path to the main entry file, relative to workspace root. Defaults to <project>/src/index.ts or <project>/src/main.ts.",
|
||||
"type": "string"
|
||||
},
|
||||
"tsConfig": {
|
||||
"description": "The path to the tsConfig file, relative to workspace root. Defaults to <project>/tsconfig.lib.json or <project>/tsconfig.app.json depending on project type.",
|
||||
"type": "string"
|
||||
},
|
||||
"buildTarget": {
|
||||
"description": "The build target to add.",
|
||||
"type": "string",
|
||||
"default": "build"
|
||||
}
|
||||
},
|
||||
"required": ["project", "bundler"]
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { readJson, writeJson, type Tree } from '@nx/devkit';
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import { prettierVersion } from '../../utils/versions';
|
||||
import { setupPrettierGenerator } from './generator';
|
||||
|
||||
describe('setup-prettier generator', () => {
|
||||
let tree: Tree;
|
||||
|
||||
beforeEach(() => {
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
// remove the default generated .prettierrc file
|
||||
tree.delete('.prettierrc');
|
||||
});
|
||||
|
||||
it('should install prettier package', async () => {
|
||||
await setupPrettierGenerator(tree, { skipFormat: true });
|
||||
|
||||
const packageJson = readJson(tree, 'package.json');
|
||||
expect(packageJson.devDependencies['prettier']).toBe(prettierVersion);
|
||||
});
|
||||
|
||||
it('should create .prettierrc and .prettierignore files', async () => {
|
||||
await setupPrettierGenerator(tree, { skipFormat: true });
|
||||
|
||||
const prettierrc = readJson(tree, '.prettierrc');
|
||||
expect(prettierrc).toEqual({ singleQuote: true });
|
||||
const prettierignore = tree.read('.prettierignore', 'utf-8');
|
||||
expect(prettierignore).toMatch(/\n\/coverage/);
|
||||
expect(prettierignore).toMatch(/\n\/dist/);
|
||||
expect(prettierignore).toMatch(/\n\/\.nx\/cache/);
|
||||
});
|
||||
|
||||
it('should not overwrite existing .prettierrc and .prettierignore files', async () => {
|
||||
writeJson(tree, '.prettierrc', { singleQuote: false });
|
||||
tree.write('.prettierignore', `# custom ignore file`);
|
||||
|
||||
await setupPrettierGenerator(tree, { skipFormat: true });
|
||||
|
||||
const prettierrc = readJson(tree, '.prettierrc');
|
||||
expect(prettierrc).toEqual({ singleQuote: false });
|
||||
const prettierignore = tree.read('.prettierignore', 'utf-8');
|
||||
expect(prettierignore).toContain('# custom ignore file');
|
||||
});
|
||||
|
||||
it('should not overwrite prettier configuration specified in other formats', async () => {
|
||||
tree.delete('.prettierrc');
|
||||
tree.delete('.prettierignore');
|
||||
tree.write('.prettierrc.js', `module.exports = { singleQuote: true };`);
|
||||
|
||||
await setupPrettierGenerator(tree, { skipFormat: true });
|
||||
|
||||
expect(tree.exists('.prettierrc')).toBeFalsy();
|
||||
expect(tree.exists('.prettierignore')).toBeTruthy();
|
||||
expect(tree.read('.prettierrc.js', 'utf-8')).toContain(
|
||||
`module.exports = { singleQuote: true };`
|
||||
);
|
||||
});
|
||||
|
||||
it('should add prettier vscode extension if .vscode/extensions.json file exists', async () => {
|
||||
// No existing recommendations
|
||||
writeJson(tree, '.vscode/extensions.json', {});
|
||||
|
||||
await setupPrettierGenerator(tree, { skipFormat: true });
|
||||
|
||||
let json = readJson(tree, '.vscode/extensions.json');
|
||||
expect(json).toEqual({
|
||||
recommendations: ['esbenp.prettier-vscode'],
|
||||
});
|
||||
|
||||
// Existing recommendations
|
||||
writeJson(tree, '.vscode/extensions.json', { recommendations: ['foo'] });
|
||||
|
||||
await setupPrettierGenerator(tree, { skipFormat: true });
|
||||
|
||||
json = readJson(tree, '.vscode/extensions.json');
|
||||
expect(json).toEqual({
|
||||
recommendations: ['foo', 'esbenp.prettier-vscode'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip adding prettier extension if .vscode/extensions.json file does not exist', async () => {
|
||||
await setupPrettierGenerator(tree, { skipFormat: true });
|
||||
|
||||
expect(tree.exists('.vscode/extensions.json')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
ensurePackage,
|
||||
formatFiles,
|
||||
type GeneratorCallback,
|
||||
type Tree,
|
||||
} from '@nx/devkit';
|
||||
import { assertSupportedTypescriptVersion } from '../../utils/assert-supported-typescript-version';
|
||||
import { generatePrettierSetup } from '../../utils/prettier';
|
||||
import { prettierVersion } from '../../utils/versions';
|
||||
import type { GeneratorOptions } from './schema';
|
||||
|
||||
export async function setupPrettierGenerator(
|
||||
tree: Tree,
|
||||
options: GeneratorOptions
|
||||
): Promise<GeneratorCallback> {
|
||||
assertSupportedTypescriptVersion(tree);
|
||||
|
||||
const prettierTask = generatePrettierSetup(tree, {
|
||||
skipPackageJson: options.skipPackageJson,
|
||||
});
|
||||
|
||||
if (!options.skipPackageJson) {
|
||||
ensurePackage('prettier', prettierVersion);
|
||||
}
|
||||
|
||||
if (!options.skipFormat) {
|
||||
// even if skipPackageJson === true, we can safely run formatFiles, prettier might
|
||||
// have been installed earlier and if not, the formatFiles function still handles it
|
||||
await formatFiles(tree);
|
||||
}
|
||||
|
||||
return prettierTask;
|
||||
}
|
||||
|
||||
export default setupPrettierGenerator;
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface GeneratorOptions {
|
||||
skipFormat?: boolean;
|
||||
skipPackageJson?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"$id": "NxJsSetupPrettier",
|
||||
"title": "Setup Prettier",
|
||||
"description": "Setup Prettier as the formatting tool.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skipFormat": {
|
||||
"description": "Skip formatting files.",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"x-priority": "internal"
|
||||
},
|
||||
"skipPackageJson": {
|
||||
"description": "Do not add dependencies to `package.json`.",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"x-priority": "internal"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# path to a directory with all packages
|
||||
storage: ../tmp/local-registry/storage
|
||||
|
||||
# a list of other known repositories we can talk to
|
||||
uplinks:
|
||||
npmjs:
|
||||
url: <%= npmUplinkRegistry %>
|
||||
maxage: 60m
|
||||
|
||||
packages:
|
||||
'**':
|
||||
# give all users (including non-authenticated users) full access
|
||||
# because it is a local registry
|
||||
access: $all
|
||||
publish: $all
|
||||
unpublish: $all
|
||||
|
||||
# if package is not available locally, proxy requests to npm registry
|
||||
proxy: npmjs
|
||||
|
||||
# log settings
|
||||
log:
|
||||
type: stdout
|
||||
format: pretty
|
||||
level: warn
|
||||
|
||||
publish:
|
||||
allow_offline: true # set offline to true to allow publish offline
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'nx/src/internal-testing-utils/mock-project-graph';
|
||||
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import { Tree, readJson, updateJson } from '@nx/devkit';
|
||||
|
||||
import generator from './generator';
|
||||
import { SetupVerdaccioGeneratorSchema } from './schema';
|
||||
import { PackageJson } from 'nx/src/utils/package-json';
|
||||
|
||||
describe('setup-verdaccio generator', () => {
|
||||
let tree: Tree;
|
||||
const options: SetupVerdaccioGeneratorSchema = { skipFormat: false };
|
||||
|
||||
beforeEach(() => {
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
});
|
||||
|
||||
it('should create .verdaccio/config.yml with the correct registry', async () => {
|
||||
await generator(tree, options);
|
||||
const config = tree.read('.verdaccio/config.yml', 'utf-8');
|
||||
expect(config).toContain('https://registry.npmjs.org');
|
||||
});
|
||||
|
||||
it('should create project.json if it does not exist', async () => {
|
||||
await generator(tree, options);
|
||||
const config = readJson(tree, 'project.json');
|
||||
expect(config).toEqual({
|
||||
name: '@proj/source',
|
||||
$schema: 'node_modules/nx/schemas/project-schema.json',
|
||||
targets: {
|
||||
'local-registry': {
|
||||
executor: '@nx/js:verdaccio',
|
||||
options: {
|
||||
port: 4873,
|
||||
config: '.verdaccio/config.yml',
|
||||
storage: 'tmp/local-registry/storage',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const packageJson = readJson<PackageJson>(tree, 'package.json');
|
||||
expect(packageJson.nx).toEqual({
|
||||
includedScripts: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not override existing root project settings from package.json', async () => {
|
||||
updateJson(tree, 'package.json', (json) => {
|
||||
json.nx = {
|
||||
includedScripts: ['test'],
|
||||
targets: {
|
||||
build: {
|
||||
outputs: ['dist'],
|
||||
},
|
||||
},
|
||||
};
|
||||
return json;
|
||||
});
|
||||
await generator(tree, options);
|
||||
const config = readJson(tree, 'project.json');
|
||||
expect(config).toEqual({
|
||||
name: '@proj/source',
|
||||
$schema: 'node_modules/nx/schemas/project-schema.json',
|
||||
targets: {
|
||||
'local-registry': {
|
||||
executor: '@nx/js:verdaccio',
|
||||
options: {
|
||||
port: 4873,
|
||||
config: '.verdaccio/config.yml',
|
||||
storage: 'tmp/local-registry/storage',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const packageJson = readJson<PackageJson>(tree, 'package.json');
|
||||
expect(packageJson.nx).toEqual({
|
||||
includedScripts: ['test'],
|
||||
targets: {
|
||||
build: {
|
||||
outputs: ['dist'],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should add local-registry target to project.json', async () => {
|
||||
tree.write('project.json', JSON.stringify({}));
|
||||
await generator(tree, options);
|
||||
const config = readJson(tree, 'project.json');
|
||||
expect(config).toEqual({
|
||||
targets: {
|
||||
'local-registry': {
|
||||
executor: '@nx/js:verdaccio',
|
||||
options: {
|
||||
port: 4873,
|
||||
config: '.verdaccio/config.yml',
|
||||
storage: 'tmp/local-registry/storage',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not override existing local-registry target to project.json if target already exists', async () => {
|
||||
tree.write(
|
||||
'project.json',
|
||||
JSON.stringify({
|
||||
targets: {
|
||||
'local-registry': {},
|
||||
},
|
||||
})
|
||||
);
|
||||
await generator(tree, options);
|
||||
const config = readJson(tree, 'project.json');
|
||||
expect(config).toEqual({
|
||||
targets: {
|
||||
'local-registry': {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to run setup verdaccio multiple times', async () => {
|
||||
await generator(tree, options);
|
||||
tree.write(
|
||||
'project.json',
|
||||
JSON.stringify({
|
||||
targets: {
|
||||
'local-registry': {},
|
||||
},
|
||||
})
|
||||
);
|
||||
await generator(tree, options);
|
||||
const config = readJson(tree, 'project.json');
|
||||
expect(config).toEqual({
|
||||
targets: {
|
||||
'local-registry': {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should install verdaccio to devDependencies', async () => {
|
||||
await generator(tree, options);
|
||||
const packageJson: PackageJson = readJson(tree, 'package.json');
|
||||
expect(packageJson.devDependencies).toEqual({
|
||||
verdaccio: '^6.3.2',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
addDependenciesToPackageJson,
|
||||
addProjectConfiguration,
|
||||
formatFiles,
|
||||
generateFiles,
|
||||
ProjectConfiguration,
|
||||
readJson,
|
||||
TargetConfiguration,
|
||||
Tree,
|
||||
updateJson,
|
||||
} from '@nx/devkit';
|
||||
import * as path from 'path';
|
||||
import { SetupVerdaccioGeneratorSchema } from './schema';
|
||||
import { assertSupportedTypescriptVersion } from '../../utils/assert-supported-typescript-version';
|
||||
import { isUsingTsSolutionSetup } from '../../utils/typescript/ts-solution-setup';
|
||||
import { verdaccioVersion } from '../../utils/versions';
|
||||
import { getNpmRegistry } from '../../utils/npm-config';
|
||||
|
||||
export async function setupVerdaccio(
|
||||
tree: Tree,
|
||||
options: SetupVerdaccioGeneratorSchema
|
||||
) {
|
||||
assertSupportedTypescriptVersion(tree);
|
||||
|
||||
if (!tree.exists('.verdaccio/config.yml')) {
|
||||
generateFiles(tree, path.join(__dirname, 'files'), '.verdaccio', {
|
||||
npmUplinkRegistry:
|
||||
(await getNpmRegistry(tree.root)) ?? 'https://registry.npmjs.org',
|
||||
});
|
||||
}
|
||||
|
||||
const verdaccioTarget: TargetConfiguration = {
|
||||
executor: '@nx/js:verdaccio',
|
||||
options: {
|
||||
port: 4873,
|
||||
config: '.verdaccio/config.yml',
|
||||
storage: 'tmp/local-registry/storage',
|
||||
},
|
||||
};
|
||||
if (!tree.exists('project.json')) {
|
||||
const isUsingNewTsSetup = isUsingTsSolutionSetup(tree);
|
||||
|
||||
const { name } = readJson(tree, 'package.json');
|
||||
updateJson(tree, 'package.json', (json) => {
|
||||
json.nx ??= { includedScripts: [] };
|
||||
if (isUsingNewTsSetup) {
|
||||
json.nx.targets ??= {};
|
||||
json.nx.targets['local-registry'] ??= verdaccioTarget;
|
||||
}
|
||||
return json;
|
||||
});
|
||||
if (!isUsingNewTsSetup) {
|
||||
addProjectConfiguration(tree, name, {
|
||||
root: '.',
|
||||
targets: {
|
||||
['local-registry']: verdaccioTarget,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// use updateJson instead of updateProjectConfiguration due to unknown project name
|
||||
updateJson(tree, 'project.json', (json: ProjectConfiguration) => {
|
||||
if (!json.targets) {
|
||||
json.targets = {};
|
||||
}
|
||||
json.targets['local-registry'] ??= verdaccioTarget;
|
||||
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
if (!options.skipFormat) {
|
||||
await formatFiles(tree);
|
||||
}
|
||||
|
||||
return addDependenciesToPackageJson(
|
||||
tree,
|
||||
{},
|
||||
{ verdaccio: verdaccioVersion },
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
export default setupVerdaccio;
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface SetupVerdaccioGeneratorSchema {
|
||||
skipFormat: boolean;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"$id": "SetupVerdaccio",
|
||||
"title": "Setup Verdaccio",
|
||||
"description": "Setup Verdaccio local-registry.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skipFormat": {
|
||||
"description": "Skip formatting files.",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"x-priority": "internal"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export interface SyncSchema {}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"$id": "action",
|
||||
"type": "object",
|
||||
"description": "Synchronize TypeScript project references based on the project graph.",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,769 @@
|
||||
import {
|
||||
createProjectGraphAsync,
|
||||
formatFiles,
|
||||
joinPathFragments,
|
||||
logger,
|
||||
parseJson,
|
||||
readNxJson,
|
||||
type ProjectGraph,
|
||||
type ProjectGraphProjectNode,
|
||||
type Tree,
|
||||
} from '@nx/devkit';
|
||||
import ignore from 'ignore';
|
||||
import { applyEdits, modify } from 'jsonc-parser';
|
||||
import { dirname, normalize, relative } from 'node:path/posix';
|
||||
import {
|
||||
SyncError,
|
||||
type SyncGeneratorResult,
|
||||
} from 'nx/src/utils/sync-generators';
|
||||
import * as ts from 'typescript';
|
||||
import { assertSupportedTypescriptVersion } from '../../utils/assert-supported-typescript-version';
|
||||
|
||||
interface Tsconfig {
|
||||
references?: Array<{ path: string }>;
|
||||
compilerOptions?: {
|
||||
paths?: Record<string, string[]>;
|
||||
rootDir?: string;
|
||||
outDir?: string;
|
||||
};
|
||||
nx?: {
|
||||
sync?: {
|
||||
ignoredReferences?: string[];
|
||||
ignoredDependencies?: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const COMMON_RUNTIME_TS_CONFIG_FILE_NAMES = [
|
||||
'tsconfig.app.json',
|
||||
'tsconfig.lib.json',
|
||||
'tsconfig.build.json',
|
||||
'tsconfig.cjs.json',
|
||||
'tsconfig.esm.json',
|
||||
'tsconfig.runtime.json',
|
||||
];
|
||||
|
||||
type GeneratorOptions = {
|
||||
runtimeTsConfigFileNames?: string[];
|
||||
};
|
||||
|
||||
type NormalizedGeneratorOptions = Required<GeneratorOptions>;
|
||||
type TsconfigInfoCaches = {
|
||||
composite: Map<string, boolean>;
|
||||
content: Map<string, string>;
|
||||
exists: Map<string, boolean>;
|
||||
};
|
||||
type ChangedFileDetails = {
|
||||
duplicates: Set<string>;
|
||||
missing: Set<string>;
|
||||
stale: Set<string>;
|
||||
};
|
||||
type ChangeType = keyof ChangedFileDetails;
|
||||
|
||||
export async function syncGenerator(tree: Tree): Promise<SyncGeneratorResult> {
|
||||
assertSupportedTypescriptVersion(tree);
|
||||
|
||||
// Ensure that the plugin has been wired up in nx.json
|
||||
const nxJson = readNxJson(tree);
|
||||
|
||||
const tsconfigInfoCaches: TsconfigInfoCaches = {
|
||||
composite: new Map(),
|
||||
content: new Map(),
|
||||
exists: new Map(),
|
||||
};
|
||||
// Root tsconfig containing project references for the whole workspace
|
||||
const rootTsconfigPath = 'tsconfig.json';
|
||||
if (!tsconfigExists(tree, tsconfigInfoCaches, rootTsconfigPath)) {
|
||||
throw new SyncError('Missing root "tsconfig.json"', [
|
||||
`A "tsconfig.json" file must exist in the workspace root in order to sync the project graph information to the TypeScript configuration files.`,
|
||||
]);
|
||||
}
|
||||
|
||||
const stringifiedRootJsonContents = readRawTsconfigContents(
|
||||
tree,
|
||||
tsconfigInfoCaches,
|
||||
rootTsconfigPath
|
||||
);
|
||||
const rootTsconfig = parseJson<Tsconfig>(stringifiedRootJsonContents);
|
||||
const projectGraph = await createProjectGraphAsync();
|
||||
const projectRoots = new Set<string>();
|
||||
|
||||
const tsconfigProjectNodeValues = Object.values(projectGraph.nodes).filter(
|
||||
(node) => {
|
||||
projectRoots.add(node.data.root);
|
||||
const projectTsconfigPath = joinPathFragments(
|
||||
node.data.root,
|
||||
'tsconfig.json'
|
||||
);
|
||||
return tsconfigExists(tree, tsconfigInfoCaches, projectTsconfigPath);
|
||||
}
|
||||
);
|
||||
|
||||
const tsSysFromTree: ts.System = {
|
||||
...ts.sys,
|
||||
fileExists(path) {
|
||||
// Given ts.System.resolve resolve full path for tsconfig within node_modules
|
||||
// We need to remove the workspace root to ensure we don't have double workspace root within the Tree
|
||||
const correctPath = path.startsWith(tree.root)
|
||||
? relative(tree.root, path)
|
||||
: path;
|
||||
return tsconfigExists(tree, tsconfigInfoCaches, correctPath);
|
||||
},
|
||||
readFile(path) {
|
||||
// Given ts.System.resolve resolve full path for tsconfig within node_modules
|
||||
// We need to remove the workspace root to ensure we don't have double workspace root within the Tree
|
||||
const correctPath = path.startsWith(tree.root)
|
||||
? relative(tree.root, path)
|
||||
: path;
|
||||
return readRawTsconfigContents(tree, tsconfigInfoCaches, correctPath);
|
||||
},
|
||||
};
|
||||
|
||||
// Track if any changes were made to the tsconfig files. We check the changes
|
||||
// made by this generator to know if the TS config is out of sync with the
|
||||
// project graph. Therefore, we don't format the files if there were no changes
|
||||
// to avoid potential format-only changes that can lead to false positives.
|
||||
const changedFiles = new Map<string, ChangedFileDetails>();
|
||||
|
||||
if (tsconfigProjectNodeValues.length > 0) {
|
||||
const referencesSet = new Set<string>();
|
||||
const rootPathCounts = new Map<string, number>();
|
||||
for (const ref of rootTsconfig.references ?? []) {
|
||||
// reference path is relative to the tsconfig file
|
||||
const resolvedRefPath = getTsConfigPathFromReferencePath(
|
||||
rootTsconfigPath,
|
||||
ref.path
|
||||
);
|
||||
const normalizedPath = normalizeReferencePath(ref.path);
|
||||
|
||||
// Track duplicates
|
||||
const currentCount = (rootPathCounts.get(normalizedPath) || 0) + 1;
|
||||
rootPathCounts.set(normalizedPath, currentCount);
|
||||
if (currentCount === 2) {
|
||||
addChangedFile(
|
||||
changedFiles,
|
||||
rootTsconfigPath,
|
||||
resolvedRefPath,
|
||||
'duplicates'
|
||||
);
|
||||
}
|
||||
|
||||
if (currentCount > 1) {
|
||||
// Skip duplicate processing - only process first occurrence
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tsconfigExists(tree, tsconfigInfoCaches, resolvedRefPath)) {
|
||||
// we only keep the references that still exist
|
||||
referencesSet.add(normalizedPath);
|
||||
} else {
|
||||
addChangedFile(
|
||||
changedFiles,
|
||||
rootTsconfigPath,
|
||||
resolvedRefPath,
|
||||
'stale'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of tsconfigProjectNodeValues) {
|
||||
const normalizedPath = normalizeReferencePath(node.data.root);
|
||||
// Skip the root tsconfig itself
|
||||
if (node.data.root !== '.' && !referencesSet.has(normalizedPath)) {
|
||||
referencesSet.add(normalizedPath);
|
||||
addChangedFile(
|
||||
changedFiles,
|
||||
rootTsconfigPath,
|
||||
toFullProjectReferencePath(node.data.root),
|
||||
'missing'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (changedFiles.size > 0) {
|
||||
const updatedReferences = Array.from(referencesSet)
|
||||
// Check composite is true in the internal reference before proceeding
|
||||
.filter((ref) =>
|
||||
hasCompositeEnabled(
|
||||
tsSysFromTree,
|
||||
tsconfigInfoCaches,
|
||||
joinPathFragments(ref, 'tsconfig.json')
|
||||
)
|
||||
)
|
||||
.map((ref) => ({
|
||||
path: `./${ref}`,
|
||||
}));
|
||||
patchTsconfigJsonReferences(
|
||||
tree,
|
||||
tsconfigInfoCaches,
|
||||
rootTsconfigPath,
|
||||
updatedReferences
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userOptions = nxJson.sync?.generatorOptions?.[
|
||||
'@nx/js:typescript-sync'
|
||||
] as GeneratorOptions | undefined;
|
||||
const { runtimeTsConfigFileNames }: NormalizedGeneratorOptions = {
|
||||
runtimeTsConfigFileNames:
|
||||
userOptions?.runtimeTsConfigFileNames ??
|
||||
COMMON_RUNTIME_TS_CONFIG_FILE_NAMES,
|
||||
};
|
||||
|
||||
const collectedDependencies = new Map<string, ProjectGraphProjectNode[]>();
|
||||
for (const projectName of Object.keys(projectGraph.dependencies)) {
|
||||
if (
|
||||
!projectGraph.nodes[projectName] ||
|
||||
projectGraph.nodes[projectName].data.root === '.'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the source project nodes for the source and target
|
||||
const sourceProjectNode = projectGraph.nodes[projectName];
|
||||
|
||||
// Find the relevant tsconfig file for the source project
|
||||
const sourceProjectTsconfigPath = joinPathFragments(
|
||||
sourceProjectNode.data.root,
|
||||
'tsconfig.json'
|
||||
);
|
||||
if (!tsconfigExists(tree, tsconfigInfoCaches, sourceProjectTsconfigPath)) {
|
||||
if (process.env.NX_VERBOSE_LOGGING === 'true') {
|
||||
logger.warn(
|
||||
`Skipping project "${projectName}" as there is no tsconfig.json file found in the project root "${sourceProjectNode.data.root}".`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect the dependencies of the source project
|
||||
const dependencies = collectProjectDependencies(
|
||||
tree,
|
||||
projectName,
|
||||
projectGraph,
|
||||
collectedDependencies
|
||||
);
|
||||
|
||||
let foundRuntimeTsConfig = false;
|
||||
for (const runtimeTsConfigFileName of runtimeTsConfigFileNames) {
|
||||
const runtimeTsConfigPath = joinPathFragments(
|
||||
sourceProjectNode.data.root,
|
||||
runtimeTsConfigFileName
|
||||
);
|
||||
if (!tsconfigExists(tree, tsconfigInfoCaches, runtimeTsConfigPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foundRuntimeTsConfig = true;
|
||||
|
||||
// Update project references for the runtime tsconfig
|
||||
updateTsConfigReferences(
|
||||
tree,
|
||||
tsSysFromTree,
|
||||
tsconfigInfoCaches,
|
||||
runtimeTsConfigPath,
|
||||
dependencies,
|
||||
sourceProjectNode.data.root,
|
||||
projectRoots,
|
||||
changedFiles,
|
||||
runtimeTsConfigFileName,
|
||||
runtimeTsConfigFileNames
|
||||
);
|
||||
}
|
||||
|
||||
// We keep the project references in the tsconfig.json file if it has files
|
||||
// or if we don't find a runtime tsconfig file, otherwise we don't need to
|
||||
// duplicate the project references in the tsconfig.json file
|
||||
let keepReferencesInTsconfigJson = true;
|
||||
if (foundRuntimeTsConfig) {
|
||||
const sourceProjectTsconfig = parseTsconfig(
|
||||
sourceProjectTsconfigPath,
|
||||
tsSysFromTree
|
||||
);
|
||||
keepReferencesInTsconfigJson = sourceProjectTsconfig.fileNames.length > 0;
|
||||
}
|
||||
|
||||
updateTsConfigReferences(
|
||||
tree,
|
||||
tsSysFromTree,
|
||||
tsconfigInfoCaches,
|
||||
sourceProjectTsconfigPath,
|
||||
keepReferencesInTsconfigJson ? dependencies : [],
|
||||
sourceProjectNode.data.root,
|
||||
projectRoots,
|
||||
changedFiles
|
||||
);
|
||||
}
|
||||
|
||||
if (changedFiles.size > 0) {
|
||||
await formatFiles(tree);
|
||||
|
||||
const outOfSyncDetails: string[] = [];
|
||||
for (const [filePath, details] of changedFiles) {
|
||||
outOfSyncDetails.push(`${filePath}:`);
|
||||
if (details.missing.size > 0) {
|
||||
outOfSyncDetails.push(
|
||||
` - Missing references: ${Array.from(details.missing).join(', ')}`
|
||||
);
|
||||
}
|
||||
if (details.stale.size > 0) {
|
||||
outOfSyncDetails.push(
|
||||
` - Stale references: ${Array.from(details.stale).join(', ')}`
|
||||
);
|
||||
}
|
||||
if (details.duplicates.size > 0) {
|
||||
outOfSyncDetails.push(
|
||||
` - Duplicate references: ${Array.from(details.duplicates).join(
|
||||
', '
|
||||
)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
outOfSyncMessage:
|
||||
'Some TypeScript configuration files are missing project references to the projects they depend on, contain stale project references, or have duplicate project references.',
|
||||
outOfSyncDetails,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default syncGenerator;
|
||||
|
||||
/**
|
||||
* Within the context of a sync generator, performance is a key concern,
|
||||
* so avoid FS interactions whenever possible.
|
||||
*/
|
||||
function readRawTsconfigContents(
|
||||
tree: Tree,
|
||||
tsconfigInfoCaches: TsconfigInfoCaches,
|
||||
tsconfigPath: string
|
||||
): string {
|
||||
if (!tsconfigInfoCaches.content.has(tsconfigPath)) {
|
||||
tsconfigInfoCaches.content.set(
|
||||
tsconfigPath,
|
||||
tree.read(tsconfigPath, 'utf-8')
|
||||
);
|
||||
}
|
||||
|
||||
return tsconfigInfoCaches.content.get(tsconfigPath);
|
||||
}
|
||||
|
||||
function parseTsconfig(
|
||||
tsconfigPath: string,
|
||||
sys: ts.System
|
||||
): ts.ParsedCommandLine {
|
||||
return ts.parseJsonConfigFileContent(
|
||||
ts.readConfigFile(tsconfigPath, sys.readFile).config,
|
||||
sys,
|
||||
dirname(tsconfigPath)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Within the context of a sync generator, performance is a key concern,
|
||||
* so avoid FS interactions whenever possible.
|
||||
*/
|
||||
function tsconfigExists(
|
||||
tree: Tree,
|
||||
tsconfigInfoCaches: TsconfigInfoCaches,
|
||||
tsconfigPath: string
|
||||
): boolean {
|
||||
if (!tsconfigInfoCaches.exists.has(tsconfigPath)) {
|
||||
tsconfigInfoCaches.exists.set(tsconfigPath, tree.exists(tsconfigPath));
|
||||
}
|
||||
|
||||
return tsconfigInfoCaches.exists.get(tsconfigPath);
|
||||
}
|
||||
|
||||
function updateTsConfigReferences(
|
||||
tree: Tree,
|
||||
tsSysFromTree: ts.System,
|
||||
tsconfigInfoCaches: TsconfigInfoCaches,
|
||||
tsConfigPath: string,
|
||||
dependencies: ProjectGraphProjectNode[],
|
||||
projectRoot: string,
|
||||
projectRoots: Set<string>,
|
||||
changedFiles: Map<string, ChangedFileDetails>,
|
||||
runtimeTsConfigFileName?: string,
|
||||
possibleRuntimeTsConfigFileNames?: string[]
|
||||
): void {
|
||||
const stringifiedJsonContents = readRawTsconfigContents(
|
||||
tree,
|
||||
tsconfigInfoCaches,
|
||||
tsConfigPath
|
||||
);
|
||||
const tsConfig = parseJson<Tsconfig>(stringifiedJsonContents);
|
||||
const ignoredReferences = new Set(tsConfig.nx?.sync?.ignoredReferences ?? []);
|
||||
const ignoredDependencies = new Set(
|
||||
tsConfig.nx?.sync?.ignoredDependencies ?? []
|
||||
);
|
||||
|
||||
// We have at least one dependency so we can safely set it to an empty array if not already set
|
||||
const references = [];
|
||||
const originalReferencesSet = new Set<string>();
|
||||
const newReferencesSet = new Set<string>();
|
||||
const pathCounts = new Map<string, number>();
|
||||
let hasChanges = false;
|
||||
|
||||
for (const ref of tsConfig.references ?? []) {
|
||||
const normalizedPath = normalizeReferencePath(ref.path);
|
||||
originalReferencesSet.add(normalizedPath);
|
||||
|
||||
// Track duplicates
|
||||
const currentCount = (pathCounts.get(normalizedPath) || 0) + 1;
|
||||
pathCounts.set(normalizedPath, currentCount);
|
||||
if (currentCount === 2) {
|
||||
const resolvedRefPath = getTsConfigPathFromReferencePath(
|
||||
tsConfigPath,
|
||||
normalizedPath
|
||||
);
|
||||
addChangedFile(changedFiles, tsConfigPath, resolvedRefPath, 'duplicates');
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (currentCount > 1) {
|
||||
// Skip duplicate processing - only process first occurrence
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignoredReferences.has(ref.path)) {
|
||||
// we keep the user-defined ignored references
|
||||
references.push(ref);
|
||||
newReferencesSet.add(normalizedPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
// reference path is relative to the tsconfig file
|
||||
const resolvedRefPath = getTsConfigPathFromReferencePath(
|
||||
tsConfigPath,
|
||||
ref.path
|
||||
);
|
||||
if (
|
||||
isProjectReferenceWithinNxProject(
|
||||
resolvedRefPath,
|
||||
projectRoot,
|
||||
projectRoots
|
||||
) ||
|
||||
isProjectReferenceIgnored(tree, resolvedRefPath)
|
||||
) {
|
||||
// we keep all references within the current Nx project or that are ignored
|
||||
references.push(ref);
|
||||
newReferencesSet.add(normalizedPath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const dep of dependencies) {
|
||||
if (ignoredDependencies.has(dep.name)) {
|
||||
// The user has explicitly opted out of this dependency edge, typically
|
||||
// to break a circular project reference graph that the project graph
|
||||
// intentionally allows.
|
||||
continue;
|
||||
}
|
||||
// Ensure the project reference for the target is set if we can find the
|
||||
// relevant tsconfig file
|
||||
let referencePath: string;
|
||||
if (runtimeTsConfigFileName) {
|
||||
const runtimeTsConfigPath = joinPathFragments(
|
||||
dep.data.root,
|
||||
runtimeTsConfigFileName
|
||||
);
|
||||
if (tsconfigExists(tree, tsconfigInfoCaches, runtimeTsConfigPath)) {
|
||||
// Check composite is true in the dependency runtime tsconfig file before proceeding
|
||||
if (
|
||||
!hasCompositeEnabled(
|
||||
tsSysFromTree,
|
||||
tsconfigInfoCaches,
|
||||
runtimeTsConfigPath
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
referencePath = runtimeTsConfigPath;
|
||||
} else {
|
||||
// Check for other possible runtime tsconfig file names
|
||||
// TODO(leo): should we check if there are more than one runtime tsconfig files and throw an error?
|
||||
for (const possibleRuntimeTsConfigFileName of possibleRuntimeTsConfigFileNames ??
|
||||
[]) {
|
||||
const possibleRuntimeTsConfigPath = joinPathFragments(
|
||||
dep.data.root,
|
||||
possibleRuntimeTsConfigFileName
|
||||
);
|
||||
if (
|
||||
tsconfigExists(
|
||||
tree,
|
||||
tsconfigInfoCaches,
|
||||
possibleRuntimeTsConfigPath
|
||||
)
|
||||
) {
|
||||
// Check composite is true in the dependency runtime tsconfig file before proceeding
|
||||
if (
|
||||
!hasCompositeEnabled(
|
||||
tsSysFromTree,
|
||||
tsconfigInfoCaches,
|
||||
possibleRuntimeTsConfigPath
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
referencePath = possibleRuntimeTsConfigPath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check composite is true in the dependency tsconfig.json file before proceeding
|
||||
if (
|
||||
!hasCompositeEnabled(
|
||||
tsSysFromTree,
|
||||
tsconfigInfoCaches,
|
||||
joinPathFragments(dep.data.root, 'tsconfig.json')
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!referencePath) {
|
||||
if (
|
||||
tsconfigExists(
|
||||
tree,
|
||||
tsconfigInfoCaches,
|
||||
joinPathFragments(dep.data.root, 'tsconfig.json')
|
||||
)
|
||||
) {
|
||||
referencePath = dep.data.root;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const relativePathToTargetRoot = relative(projectRoot, referencePath);
|
||||
if (!newReferencesSet.has(relativePathToTargetRoot)) {
|
||||
newReferencesSet.add(relativePathToTargetRoot);
|
||||
// Make sure we unshift rather than push so that dependencies are built in the right order by TypeScript when it is run directly from the root of the workspace
|
||||
references.unshift({ path: relativePathToTargetRoot });
|
||||
}
|
||||
if (!originalReferencesSet.has(relativePathToTargetRoot)) {
|
||||
hasChanges = true;
|
||||
addChangedFile(
|
||||
changedFiles,
|
||||
tsConfigPath,
|
||||
toFullProjectReferencePath(referencePath),
|
||||
'missing'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const ref of originalReferencesSet) {
|
||||
if (!newReferencesSet.has(ref)) {
|
||||
addChangedFile(
|
||||
changedFiles,
|
||||
tsConfigPath,
|
||||
toFullProjectReferencePath(joinPathFragments(projectRoot, ref)),
|
||||
'stale'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
hasChanges ||= newReferencesSet.size !== originalReferencesSet.size;
|
||||
|
||||
if (hasChanges) {
|
||||
patchTsconfigJsonReferences(
|
||||
tree,
|
||||
tsconfigInfoCaches,
|
||||
tsConfigPath,
|
||||
references
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(leo): follow up with the TypeScript team to confirm if we really need
|
||||
// to reference transitive dependencies.
|
||||
// Collect the dependencies of a project recursively sorted from root to leaf
|
||||
function collectProjectDependencies(
|
||||
tree: Tree,
|
||||
projectName: string,
|
||||
projectGraph: ProjectGraph,
|
||||
collectedDependencies: Map<string, ProjectGraphProjectNode[]>
|
||||
): ProjectGraphProjectNode[] {
|
||||
if (collectedDependencies.has(projectName)) {
|
||||
// We've already collected the dependencies for this project
|
||||
return collectedDependencies.get(projectName);
|
||||
}
|
||||
|
||||
collectedDependencies.set(projectName, []);
|
||||
|
||||
for (const dep of projectGraph.dependencies[projectName]) {
|
||||
const targetProjectNode = projectGraph.nodes[dep.target];
|
||||
if (!targetProjectNode || dep.type === 'implicit') {
|
||||
// It's an npm or an implicit dependency
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add the target project node to the list of dependencies for the current project
|
||||
if (
|
||||
!collectedDependencies
|
||||
.get(projectName)
|
||||
.some((d) => d.name === targetProjectNode.name)
|
||||
) {
|
||||
collectedDependencies.get(projectName).push(targetProjectNode);
|
||||
}
|
||||
|
||||
if (process.env.NX_ENABLE_TS_SYNC_TRANSITIVE_DEPENDENCIES !== 'true') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recursively get the dependencies of the target project
|
||||
const transitiveDependencies = collectProjectDependencies(
|
||||
tree,
|
||||
dep.target,
|
||||
projectGraph,
|
||||
collectedDependencies
|
||||
);
|
||||
for (const transitiveDep of transitiveDependencies) {
|
||||
if (
|
||||
!collectedDependencies
|
||||
.get(projectName)
|
||||
.some((d) => d.name === transitiveDep.name)
|
||||
) {
|
||||
collectedDependencies.get(projectName).push(transitiveDep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collectedDependencies.get(projectName);
|
||||
}
|
||||
|
||||
// Normalize the paths to strip leading `./` and trailing `/tsconfig.json`
|
||||
function normalizeReferencePath(path: string): string {
|
||||
return normalize(path)
|
||||
.replace(/\/tsconfig.json$/, '')
|
||||
.replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
function toFullProjectReferencePath(path: string): string {
|
||||
const normalizedPath = normalizeReferencePath(path);
|
||||
|
||||
return normalizedPath.endsWith('.json')
|
||||
? normalizedPath
|
||||
: joinPathFragments(normalizedPath, 'tsconfig.json');
|
||||
}
|
||||
|
||||
function isProjectReferenceWithinNxProject(
|
||||
refTsConfigPath: string,
|
||||
projectRoot: string,
|
||||
projectRoots: Set<string>
|
||||
): boolean {
|
||||
let currentPath = getTsConfigDirName(refTsConfigPath);
|
||||
|
||||
if (relative(projectRoot, currentPath).startsWith('..')) {
|
||||
// it's outside of the project root, so it's an external project reference
|
||||
return false;
|
||||
}
|
||||
|
||||
while (currentPath !== projectRoot) {
|
||||
if (projectRoots.has(currentPath)) {
|
||||
// it's inside a nested project root, so it's and external project reference
|
||||
return false;
|
||||
}
|
||||
currentPath = dirname(currentPath);
|
||||
}
|
||||
|
||||
// it's inside the project root, so it's an internal project reference
|
||||
return true;
|
||||
}
|
||||
|
||||
function isProjectReferenceIgnored(
|
||||
tree: Tree,
|
||||
refTsConfigPath: string
|
||||
): boolean {
|
||||
const ig = ignore();
|
||||
if (tree.exists('.gitignore')) {
|
||||
ig.add('.git');
|
||||
ig.add(tree.read('.gitignore', 'utf-8'));
|
||||
}
|
||||
if (tree.exists('.nxignore')) {
|
||||
ig.add(tree.read('.nxignore', 'utf-8'));
|
||||
}
|
||||
|
||||
return ig.ignores(refTsConfigPath);
|
||||
}
|
||||
|
||||
function getTsConfigDirName(tsConfigPath: string): string {
|
||||
return tsConfigPath.endsWith('.json')
|
||||
? dirname(tsConfigPath)
|
||||
: normalize(tsConfigPath);
|
||||
}
|
||||
|
||||
function getTsConfigPathFromReferencePath(
|
||||
ownerTsConfigPath: string,
|
||||
referencePath: string
|
||||
): string {
|
||||
const resolvedRefPath = joinPathFragments(
|
||||
dirname(ownerTsConfigPath),
|
||||
referencePath
|
||||
);
|
||||
|
||||
return resolvedRefPath.endsWith('.json')
|
||||
? resolvedRefPath
|
||||
: joinPathFragments(resolvedRefPath, 'tsconfig.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimally patch just the "references" property within the tsconfig file at a given path.
|
||||
* This allows comments in other sections of the file to remain intact when syncing is run.
|
||||
*/
|
||||
function patchTsconfigJsonReferences(
|
||||
tree: Tree,
|
||||
tsconfigInfoCaches: TsconfigInfoCaches,
|
||||
tsconfigPath: string,
|
||||
updatedReferences: { path: string }[]
|
||||
) {
|
||||
const stringifiedJsonContents = readRawTsconfigContents(
|
||||
tree,
|
||||
tsconfigInfoCaches,
|
||||
tsconfigPath
|
||||
);
|
||||
const edits = modify(
|
||||
stringifiedJsonContents,
|
||||
['references'],
|
||||
updatedReferences,
|
||||
{ formattingOptions: { keepLines: true, insertSpaces: true, tabSize: 2 } }
|
||||
);
|
||||
const updatedJsonContents = applyEdits(stringifiedJsonContents, edits);
|
||||
// The final contents will be formatted by formatFiles() later
|
||||
tree.write(tsconfigPath, updatedJsonContents);
|
||||
}
|
||||
|
||||
function hasCompositeEnabled(
|
||||
tsSysFromTree: ts.System,
|
||||
tsconfigInfoCaches: TsconfigInfoCaches,
|
||||
tsconfigPath: string
|
||||
): boolean {
|
||||
if (!tsconfigInfoCaches.composite.has(tsconfigPath)) {
|
||||
const parsed = parseTsconfig(tsconfigPath, tsSysFromTree);
|
||||
tsconfigInfoCaches.composite.set(
|
||||
tsconfigPath,
|
||||
parsed.options.composite === true
|
||||
);
|
||||
}
|
||||
|
||||
return tsconfigInfoCaches.composite.get(tsconfigPath);
|
||||
}
|
||||
|
||||
function addChangedFile(
|
||||
changedFiles: Map<string, ChangedFileDetails>,
|
||||
filePath: string,
|
||||
referencePath: string,
|
||||
type: ChangeType
|
||||
) {
|
||||
if (!changedFiles.has(filePath)) {
|
||||
changedFiles.set(filePath, {
|
||||
duplicates: new Set(),
|
||||
missing: new Set(),
|
||||
stale: new Set(),
|
||||
});
|
||||
}
|
||||
|
||||
changedFiles.get(filePath)[type].add(referencePath);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export * from './utils/typescript/add-tslib-dependencies';
|
||||
export * from './utils/typescript/load-ts-transformers';
|
||||
export * from './utils/typescript/print-diagnostics';
|
||||
export * from './utils/typescript/run-type-check';
|
||||
export * from './utils/typescript/get-source-nodes';
|
||||
export * from './utils/compiler-helper-dependency';
|
||||
export * from './utils/typescript/ts-config';
|
||||
export * from './utils/typescript/create-ts-config';
|
||||
export * from './utils/typescript/ast-utils';
|
||||
export * from './utils/package-json';
|
||||
export * from './utils/assets';
|
||||
export * from './utils/package-json/update-package-json';
|
||||
export * from './utils/package-json/create-entry-points';
|
||||
export { libraryGenerator } from './generators/library/library';
|
||||
export { initGenerator } from './generators/init/init';
|
||||
export { setupPrettierGenerator } from './generators/setup-prettier/generator';
|
||||
export { setupVerdaccio } from './generators/setup-verdaccio/generator';
|
||||
export { isValidVariable } from './utils/is-valid-variable';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
export {
|
||||
createLockFile,
|
||||
getLockFileName,
|
||||
} from 'nx/src/plugins/js/lock-file/lock-file';
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
export { createPackageJson } from 'nx/src/plugins/js/package-json/create-package-json';
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @deprecated Backwards-compatibility shim for `@nx/js/src/internal`.
|
||||
*
|
||||
* The `./src/internal` subpath was removed in nx 23 — the canonical path is
|
||||
* now `@nx/js/internal`. Additionally, `getRootTsConfigPath` moved to the
|
||||
* main `@nx/js` entry.
|
||||
*
|
||||
* This shim keeps `@nx/conformance@4` / `@nx/conformance@5.0.x` working on
|
||||
* nx 23. Both packages do:
|
||||
* `const { getRootTsConfigPath } = await import('@nx/js/src/internal')`
|
||||
* `const { registerTsProject } = await import('@nx/js/src/internal')`
|
||||
*
|
||||
* Do NOT add new exports here. Consumers should migrate to:
|
||||
* - `@nx/js/internal` for `registerTsProject` (and other internal helpers)
|
||||
* - `@nx/js` for `getRootTsConfigPath`
|
||||
*/
|
||||
|
||||
// registerTsProject lives in the new ./internal entry
|
||||
export { registerTsProject } from '../internal';
|
||||
|
||||
// getRootTsConfigPath moved to the main @nx/js entry in nx 23
|
||||
export { getRootTsConfigPath } from './utils/typescript/ts-config';
|
||||
@@ -0,0 +1,87 @@
|
||||
#### Migrate `development` custom condition to unique workspace-specific name
|
||||
|
||||
Replace the TypeScript `development` custom condition with a unique workspace-specific name to avoid conflicts when consuming packages in other workspaces.
|
||||
|
||||
#### Examples
|
||||
|
||||
The migration will update the custom condition name in both `tsconfig.base.json` and all workspace package.json files that use the `development` custom condition:
|
||||
|
||||
##### Before
|
||||
|
||||
```json title="tsconfig.base.json" {3}
|
||||
{
|
||||
"compilerOptions": {
|
||||
"customConditions": ["development"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### After
|
||||
|
||||
```json title="tsconfig.base.json" {3}
|
||||
{
|
||||
"compilerOptions": {
|
||||
"customConditions": ["@my-org/source"] // assuming the root package.json name is `@my-org/source`
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The migration also updates `package.json` files that use the `development` condition in their `exports` field and point to TypeScript files:
|
||||
|
||||
##### Before
|
||||
|
||||
```json title="libs/my-lib/package.json" {5}
|
||||
{
|
||||
"name": "@myorg/my-lib",
|
||||
"exports": {
|
||||
".": {
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### After
|
||||
|
||||
```json title="libs/my-lib/package.json" {5}
|
||||
{
|
||||
"name": "@myorg/my-lib",
|
||||
"exports": {
|
||||
".": {
|
||||
"@my-org/source": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the custom condition is not set to `["development"]` or the `package.json`'s `exports` field doesn't point to TypeScript files, the migration will not modify the configuration:
|
||||
|
||||
##### Before
|
||||
|
||||
```json title="libs/my-lib/package.json" {5}
|
||||
{
|
||||
"name": "@myorg/my-lib",
|
||||
"exports": {
|
||||
".": {
|
||||
"development": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### After
|
||||
|
||||
```json title="libs/my-lib/package.json" {5}
|
||||
{
|
||||
"name": "@myorg/my-lib",
|
||||
"exports": {
|
||||
".": {
|
||||
"development": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
import { type Tree, readJson, writeJson } from '@nx/devkit';
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import migration from './migrate-development-custom-condition';
|
||||
|
||||
describe('migrate-development-custom-condition migration', () => {
|
||||
let tree: Tree;
|
||||
|
||||
beforeEach(() => {
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
});
|
||||
|
||||
describe('when conditions are met', () => {
|
||||
beforeEach(() => {
|
||||
// Set up workspace with required conditions
|
||||
writeJson(tree, 'package.json', {
|
||||
name: '@my-org/workspace',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
writeJson(tree, 'tsconfig.base.json', {
|
||||
compilerOptions: {
|
||||
customConditions: ['development'],
|
||||
},
|
||||
});
|
||||
|
||||
writeJson(tree, 'tsconfig.json', {
|
||||
extends: './tsconfig.base.json',
|
||||
});
|
||||
});
|
||||
|
||||
it('should update tsconfig.base.json custom condition', async () => {
|
||||
await migration(tree);
|
||||
|
||||
const tsconfigBase = readJson(tree, 'tsconfig.base.json');
|
||||
expect(tsconfigBase.compilerOptions.customConditions).toEqual([
|
||||
'@my-org/workspace',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should update package.json exports with development condition', async () => {
|
||||
// Create a library package.json with development exports
|
||||
writeJson(tree, 'libs/my-lib/package.json', {
|
||||
name: '@my-org/my-lib',
|
||||
exports: {
|
||||
'.': {
|
||||
development: './src/index.ts',
|
||||
default: './dist/index.js',
|
||||
types: './dist/index.d.ts',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const packageJson = readJson(tree, 'libs/my-lib/package.json');
|
||||
expect(packageJson.exports).toEqual({
|
||||
'.': {
|
||||
'@my-org/workspace': './src/index.ts',
|
||||
default: './dist/index.js',
|
||||
types: './dist/index.d.ts',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should update nested exports with development condition', async () => {
|
||||
writeJson(tree, 'libs/my-lib/package.json', {
|
||||
name: '@my-org/my-lib',
|
||||
exports: {
|
||||
'.': {
|
||||
development: './src/index.ts',
|
||||
default: './dist/index.js',
|
||||
},
|
||||
'./sub': {
|
||||
development: './src/sub/index.ts',
|
||||
default: './dist/sub/index.js',
|
||||
},
|
||||
'./feature': {
|
||||
development: './src/feature/index.ts',
|
||||
import: './dist/feature/index.esm.js',
|
||||
default: './dist/feature/index.cjs',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const packageJson = readJson(tree, 'libs/my-lib/package.json');
|
||||
expect(packageJson.exports).toEqual({
|
||||
'.': {
|
||||
'@my-org/workspace': './src/index.ts',
|
||||
default: './dist/index.js',
|
||||
},
|
||||
'./sub': {
|
||||
'@my-org/workspace': './src/sub/index.ts',
|
||||
default: './dist/sub/index.js',
|
||||
},
|
||||
'./feature': {
|
||||
'@my-org/workspace': './src/feature/index.ts',
|
||||
import: './dist/feature/index.esm.js',
|
||||
default: './dist/feature/index.cjs',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should update multiple package.json files', async () => {
|
||||
writeJson(tree, 'libs/lib-a/package.json', {
|
||||
name: '@my-org/lib-a',
|
||||
exports: {
|
||||
'.': {
|
||||
development: './src/index.ts',
|
||||
default: './dist/index.js',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
writeJson(tree, 'libs/lib-b/package.json', {
|
||||
name: '@my-org/lib-b',
|
||||
exports: {
|
||||
'.': {
|
||||
development: './src/index.ts',
|
||||
import: './dist/index.esm.js',
|
||||
default: './dist/index.cjs',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const libA = readJson(tree, 'libs/lib-a/package.json');
|
||||
const libB = readJson(tree, 'libs/lib-b/package.json');
|
||||
|
||||
expect(libA.exports['.']).toHaveProperty('@my-org/workspace');
|
||||
expect(libA.exports['.']).not.toHaveProperty('development');
|
||||
|
||||
expect(libB.exports['.']).toHaveProperty('@my-org/workspace');
|
||||
expect(libB.exports['.']).not.toHaveProperty('development');
|
||||
});
|
||||
|
||||
it('should work with various TypeScript file extensions', async () => {
|
||||
writeJson(tree, 'libs/lib-ts/package.json', {
|
||||
name: '@my-org/lib-ts',
|
||||
exports: {
|
||||
'.': {
|
||||
development: './src/index.ts',
|
||||
default: './dist/index.js',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
writeJson(tree, 'libs/lib-tsx/package.json', {
|
||||
name: '@my-org/lib-tsx',
|
||||
exports: {
|
||||
'.': {
|
||||
development: './src/index.tsx',
|
||||
default: './dist/index.js',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
writeJson(tree, 'libs/lib-mts/package.json', {
|
||||
name: '@my-org/lib-mts',
|
||||
exports: {
|
||||
'.': {
|
||||
development: './src/index.mts',
|
||||
default: './dist/index.js',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
writeJson(tree, 'libs/lib-cts/package.json', {
|
||||
name: '@my-org/lib-cts',
|
||||
exports: {
|
||||
'.': {
|
||||
development: './src/index.cts',
|
||||
default: './dist/index.js',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const libTs = readJson(tree, 'libs/lib-ts/package.json');
|
||||
const libTsx = readJson(tree, 'libs/lib-tsx/package.json');
|
||||
const libMts = readJson(tree, 'libs/lib-mts/package.json');
|
||||
const libCts = readJson(tree, 'libs/lib-cts/package.json');
|
||||
|
||||
expect(libTs.exports['.']).toHaveProperty('@my-org/workspace');
|
||||
expect(libTsx.exports['.']).toHaveProperty('@my-org/workspace');
|
||||
expect(libMts.exports['.']).toHaveProperty('@my-org/workspace');
|
||||
expect(libCts.exports['.']).toHaveProperty('@my-org/workspace');
|
||||
|
||||
expect(libTs.exports['.']).not.toHaveProperty('development');
|
||||
expect(libTsx.exports['.']).not.toHaveProperty('development');
|
||||
expect(libMts.exports['.']).not.toHaveProperty('development');
|
||||
expect(libCts.exports['.']).not.toHaveProperty('development');
|
||||
});
|
||||
|
||||
it('should not update package.json files without development exports', async () => {
|
||||
const originalExports = {
|
||||
'.': {
|
||||
import: './dist/index.esm.js',
|
||||
default: './dist/index.cjs',
|
||||
},
|
||||
};
|
||||
|
||||
writeJson(tree, 'libs/my-lib/package.json', {
|
||||
name: '@my-org/my-lib',
|
||||
exports: originalExports,
|
||||
});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const packageJson = readJson(tree, 'libs/my-lib/package.json');
|
||||
expect(packageJson.exports).toEqual(originalExports);
|
||||
});
|
||||
|
||||
it('should use @nx/source as fallback when workspace has no name', async () => {
|
||||
// Update workspace package.json to have no name
|
||||
writeJson(tree, 'package.json', {
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
writeJson(tree, 'libs/my-lib/package.json', {
|
||||
name: '@my-org/my-lib',
|
||||
exports: {
|
||||
'.': {
|
||||
development: './src/index.ts',
|
||||
default: './dist/index.js',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const tsconfigBase = readJson(tree, 'tsconfig.base.json');
|
||||
expect(tsconfigBase.compilerOptions.customConditions).toEqual([
|
||||
'@nx/source',
|
||||
]);
|
||||
|
||||
const packageJson = readJson(tree, 'libs/my-lib/package.json');
|
||||
expect(packageJson.exports['.']).toHaveProperty('@nx/source');
|
||||
expect(packageJson.exports['.']).not.toHaveProperty('development');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when conditions are not met', () => {
|
||||
it('should not run when tsconfig.base.json does not exist', async () => {
|
||||
writeJson(tree, 'tsconfig.json', {});
|
||||
|
||||
// Remove the tsconfig.base.json that gets created by createTreeWithEmptyWorkspace
|
||||
if (tree.exists('tsconfig.base.json')) {
|
||||
tree.delete('tsconfig.base.json');
|
||||
}
|
||||
|
||||
await migration(tree);
|
||||
|
||||
// Should not have created tsconfig.base.json
|
||||
expect(tree.exists('tsconfig.base.json')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not run when tsconfig.json does not exist', async () => {
|
||||
writeJson(tree, 'tsconfig.base.json', {
|
||||
compilerOptions: {
|
||||
customConditions: ['development'],
|
||||
},
|
||||
});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const tsconfigBase = readJson(tree, 'tsconfig.base.json');
|
||||
expect(tsconfigBase.compilerOptions.customConditions).toEqual([
|
||||
'development',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not run when customConditions is not set', async () => {
|
||||
writeJson(tree, 'tsconfig.base.json', {
|
||||
compilerOptions: {},
|
||||
});
|
||||
writeJson(tree, 'tsconfig.json', {});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const tsconfigBase = readJson(tree, 'tsconfig.base.json');
|
||||
expect(tsconfigBase.compilerOptions.customConditions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not run when customConditions has multiple conditions', async () => {
|
||||
writeJson(tree, 'tsconfig.base.json', {
|
||||
compilerOptions: {
|
||||
customConditions: ['development', 'production'],
|
||||
},
|
||||
});
|
||||
writeJson(tree, 'tsconfig.json', {});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const tsconfigBase = readJson(tree, 'tsconfig.base.json');
|
||||
expect(tsconfigBase.compilerOptions.customConditions).toEqual([
|
||||
'development',
|
||||
'production',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not run when the single custom condition is not development', async () => {
|
||||
writeJson(tree, 'tsconfig.base.json', {
|
||||
compilerOptions: {
|
||||
customConditions: ['@my-org/source'],
|
||||
},
|
||||
});
|
||||
writeJson(tree, 'tsconfig.json', {});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const tsconfigBase = readJson(tree, 'tsconfig.base.json');
|
||||
expect(tsconfigBase.compilerOptions.customConditions).toEqual([
|
||||
'@my-org/source',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not run when customConditions is empty array', async () => {
|
||||
writeJson(tree, 'tsconfig.base.json', {
|
||||
compilerOptions: {
|
||||
customConditions: [],
|
||||
},
|
||||
});
|
||||
writeJson(tree, 'tsconfig.json', {});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const tsconfigBase = readJson(tree, 'tsconfig.base.json');
|
||||
expect(tsconfigBase.compilerOptions.customConditions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not run when development exports point to non-TS files', async () => {
|
||||
writeJson(tree, 'tsconfig.base.json', {
|
||||
compilerOptions: {
|
||||
customConditions: ['development'],
|
||||
},
|
||||
});
|
||||
writeJson(tree, 'tsconfig.json', {});
|
||||
|
||||
// Create a package with development export pointing to JS file
|
||||
writeJson(tree, 'libs/my-lib/package.json', {
|
||||
name: '@my-org/my-lib',
|
||||
exports: {
|
||||
'.': {
|
||||
development: './src/index.js', // Points to JS file, not TS
|
||||
default: './dist/index.js',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
// Should not have run migration
|
||||
const tsconfigBase = readJson(tree, 'tsconfig.base.json');
|
||||
expect(tsconfigBase.compilerOptions.customConditions).toEqual([
|
||||
'development',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not run when some development exports point to non-TS files', async () => {
|
||||
writeJson(tree, 'tsconfig.base.json', {
|
||||
compilerOptions: {
|
||||
customConditions: ['development'],
|
||||
},
|
||||
});
|
||||
writeJson(tree, 'tsconfig.json', {});
|
||||
|
||||
// Create packages with mixed TS and JS development exports
|
||||
writeJson(tree, 'libs/lib-a/package.json', {
|
||||
name: '@my-org/lib-a',
|
||||
exports: {
|
||||
'.': {
|
||||
development: './src/index.ts', // TS file
|
||||
default: './dist/index.js',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
writeJson(tree, 'libs/lib-b/package.json', {
|
||||
name: '@my-org/lib-b',
|
||||
exports: {
|
||||
'.': {
|
||||
development: './src/index.js', // JS file - should prevent migration
|
||||
default: './dist/index.js',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
// Should not have run migration
|
||||
const tsconfigBase = readJson(tree, 'tsconfig.base.json');
|
||||
expect(tsconfigBase.compilerOptions.customConditions).toEqual([
|
||||
'development',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
beforeEach(() => {
|
||||
// Set up valid conditions
|
||||
writeJson(tree, 'package.json', {
|
||||
name: '@test/workspace',
|
||||
version: '1.0.0',
|
||||
});
|
||||
writeJson(tree, 'tsconfig.base.json', {
|
||||
compilerOptions: {
|
||||
customConditions: ['development'],
|
||||
},
|
||||
});
|
||||
writeJson(tree, 'tsconfig.json', {});
|
||||
});
|
||||
|
||||
it('should handle package.json files with no exports', async () => {
|
||||
writeJson(tree, 'libs/my-lib/package.json', {
|
||||
name: '@my-org/my-lib',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const packageJson = readJson(tree, 'libs/my-lib/package.json');
|
||||
expect(packageJson.exports).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle package.json files with string exports', async () => {
|
||||
writeJson(tree, 'libs/my-lib/package.json', {
|
||||
name: '@my-org/my-lib',
|
||||
exports: './dist/index.js',
|
||||
});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const packageJson = readJson(tree, 'libs/my-lib/package.json');
|
||||
expect(packageJson.exports).toBe('./dist/index.js');
|
||||
});
|
||||
|
||||
it('should handle invalid package.json files gracefully', async () => {
|
||||
tree.write('libs/invalid/package.json', 'invalid json{');
|
||||
|
||||
await migration(tree);
|
||||
|
||||
// Should not throw error and should continue processing other files
|
||||
const tsconfigBase = readJson(tree, 'tsconfig.base.json');
|
||||
expect(tsconfigBase.compilerOptions.customConditions).toEqual([
|
||||
'@test/workspace',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip root package.json', async () => {
|
||||
const originalRootPackage = {
|
||||
name: '@test/workspace',
|
||||
exports: {
|
||||
'.': {
|
||||
development: './src/index.ts',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
writeJson(tree, 'package.json', originalRootPackage);
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const rootPackage = readJson(tree, 'package.json');
|
||||
// Should have original exports unchanged (except for name reading)
|
||||
expect(rootPackage.exports).toEqual(originalRootPackage.exports);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
formatFiles,
|
||||
globAsync,
|
||||
logger,
|
||||
readJson,
|
||||
type Tree,
|
||||
updateJson,
|
||||
} from '@nx/devkit';
|
||||
import type { PackageJson } from 'nx/src/utils/package-json';
|
||||
import { getCustomConditionName } from '../../utils/typescript/ts-solution-setup';
|
||||
|
||||
export default async function (tree: Tree) {
|
||||
if (!isDevelopmentCustomConditionDefined(tree)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const packageJsonFiles = await getPackageJsonFiles(tree);
|
||||
if (!(await doDevelopmentExportsPointToTsFiles(tree, packageJsonFiles))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the new custom condition name
|
||||
const newCustomCondition = getCustomConditionName(tree, {
|
||||
skipDevelopmentFallback: true,
|
||||
});
|
||||
updateTsconfigBaseCustomCondition(tree, newCustomCondition);
|
||||
await updatePackageJsonExports(tree, newCustomCondition, packageJsonFiles);
|
||||
|
||||
await formatFiles(tree);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the development custom condition is defined in the repo:
|
||||
* - Both tsconfig.base.json and tsconfig.json exist
|
||||
* - customConditions is set in tsconfig.base.json
|
||||
* - There's a single custom condition and it's named 'development'
|
||||
*/
|
||||
function isDevelopmentCustomConditionDefined(tree: Tree): boolean {
|
||||
if (!tree.exists('tsconfig.base.json') || !tree.exists('tsconfig.json')) {
|
||||
// if any of them don't exist, it means the repo is not using or has deviated
|
||||
// from the TS solution setup, let's leave things as is
|
||||
return false;
|
||||
}
|
||||
|
||||
const tsconfigBase = readJson(tree, 'tsconfig.base.json');
|
||||
const customConditions = tsconfigBase.compilerOptions?.customConditions;
|
||||
|
||||
if (
|
||||
!Array.isArray(customConditions) ||
|
||||
customConditions.length !== 1 ||
|
||||
customConditions[0] !== 'development'
|
||||
) {
|
||||
// no custom conditions, or more than one, or not named 'development', this
|
||||
// means the repo is not using or has deviated from the TS solution setup
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that all 'development' conditional exports in workspace package.json files
|
||||
* point to TypeScript files (.ts, .tsx, .mts, .cts)
|
||||
*/
|
||||
async function doDevelopmentExportsPointToTsFiles(
|
||||
tree: Tree,
|
||||
packageJsonFiles: string[]
|
||||
): Promise<boolean> {
|
||||
for (const filePath of packageJsonFiles) {
|
||||
try {
|
||||
const packageJson = readJson<PackageJson>(tree, filePath);
|
||||
|
||||
if (!packageJson.exports || typeof packageJson.exports !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!checkExportsRecursively(packageJson.exports)) {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the `development` conditional exports in the package.json file
|
||||
* point to TS files.
|
||||
*/
|
||||
function checkExportsRecursively(exports: PackageJson['exports']): boolean {
|
||||
if (
|
||||
typeof exports !== 'object' ||
|
||||
exports === null ||
|
||||
Array.isArray(exports)
|
||||
) {
|
||||
// there's no conditional exports, so we're good
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(exports)) {
|
||||
if (key === 'development') {
|
||||
// check if the development export points to a TS file
|
||||
if (typeof value !== 'string' || !isTypeScriptFile(value)) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// recursively check nested objects
|
||||
if (!checkExportsRecursively(value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isTypeScriptFile(filePath: string): boolean {
|
||||
return /\.m?ts$|\.tsx$|\.cts$/.test(filePath);
|
||||
}
|
||||
|
||||
function updateTsconfigBaseCustomCondition(
|
||||
tree: Tree,
|
||||
newCustomCondition: string
|
||||
): void {
|
||||
updateJson(tree, 'tsconfig.base.json', (json) => {
|
||||
json.compilerOptions.customConditions =
|
||||
json.compilerOptions.customConditions.map((condition: string) =>
|
||||
condition === 'development' ? newCustomCondition : condition
|
||||
);
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
async function updatePackageJsonExports(
|
||||
tree: Tree,
|
||||
newCustomCondition: string,
|
||||
packageJsonFiles: string[]
|
||||
): Promise<void> {
|
||||
for (const filePath of packageJsonFiles) {
|
||||
try {
|
||||
const packageJson = readJson(tree, filePath);
|
||||
|
||||
if (!packageJson.exports || typeof packageJson.exports !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const updatedExports = updateExportsRecursively(
|
||||
packageJson.exports,
|
||||
newCustomCondition
|
||||
);
|
||||
|
||||
if (
|
||||
JSON.stringify(updatedExports) !== JSON.stringify(packageJson.exports)
|
||||
) {
|
||||
updateJson(tree, filePath, (json) => {
|
||||
json.exports = updatedExports;
|
||||
return json;
|
||||
});
|
||||
logger.info(`Updated exports in ${filePath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateExportsRecursively(
|
||||
exports: PackageJson['exports'],
|
||||
newCustomCondition: string
|
||||
): any {
|
||||
if (
|
||||
typeof exports !== 'object' ||
|
||||
exports === null ||
|
||||
Array.isArray(exports)
|
||||
) {
|
||||
// no conditional exports, nothing to do
|
||||
return exports;
|
||||
}
|
||||
|
||||
const updated: PackageJson['exports'] = {};
|
||||
for (const [key, value] of Object.entries(exports)) {
|
||||
if (key === 'development') {
|
||||
// Replace 'development' key with new custom condition
|
||||
updated[newCustomCondition] = value;
|
||||
} else {
|
||||
// Recursively process nested objects
|
||||
updated[key] = updateExportsRecursively(value, newCustomCondition);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function getPackageJsonFiles(tree: Tree): Promise<string[]> {
|
||||
const packageJsonFiles = await globAsync(tree, ['**/package.json']);
|
||||
|
||||
return packageJsonFiles.filter((file) => file !== 'package.json');
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#### Remove the `external` and `externalBuildTargets` Options from the `@nx/js:swc` and `@nx/js:tsc` Executors
|
||||
|
||||
Remove the deprecated `external` and `externalBuildTargets` options from the `@nx/js:swc` and `@nx/js:tsc` executors. These options were used for inlining dependencies, which was an experimental feature and has been deprecated for a long time. The migration only removes the options from the project configuration and target defaults. If you rely on inlining dependencies, you need to make sure they are all buildable or use a different build tool that supports bundling.
|
||||
|
||||
#### Sample Code Changes
|
||||
|
||||
Remove `external` and `externalBuildTargets` from the `@nx/js:swc` or `@nx/js:tsc` executor options in project configuration.
|
||||
|
||||
##### Before
|
||||
|
||||
```json title="libs/my-lib/project.json" {9-10}
|
||||
{
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:swc",
|
||||
"options": {
|
||||
"main": "libs/my-lib/src/index.ts",
|
||||
"outputPath": "dist/libs/my-lib",
|
||||
"tsConfig": "libs/my-lib/tsconfig.lib.json",
|
||||
"external": ["react", "react-dom"],
|
||||
"externalBuildTargets": ["build"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### After
|
||||
|
||||
```json title="libs/my-lib/project.json"
|
||||
{
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:swc",
|
||||
"options": {
|
||||
"main": "libs/my-lib/src/index.ts",
|
||||
"outputPath": "dist/libs/my-lib",
|
||||
"tsConfig": "libs/my-lib/tsconfig.lib.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Remove `external` and `externalBuildTargets` from the `@nx/js:swc` or `@nx/js:tsc` executor target defaults in `nx.json`.
|
||||
|
||||
##### Before
|
||||
|
||||
```json title="nx.json" {8-9}
|
||||
{
|
||||
"targetDefaults": {
|
||||
"@nx/js:swc": {
|
||||
"options": {
|
||||
"main": "{projectRoot}/src/index.ts",
|
||||
"outputPath": "dist/{projectRoot}",
|
||||
"tsConfig": "{projectRoot}/tsconfig.lib.json",
|
||||
"external": "all",
|
||||
"externalBuildTargets": ["build"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### After
|
||||
|
||||
```json title="nx.json"
|
||||
{
|
||||
"targetDefaults": {
|
||||
"@nx/js:swc": {
|
||||
"options": {
|
||||
"main": "{projectRoot}/src/index.ts",
|
||||
"outputPath": "dist/{projectRoot}",
|
||||
"tsConfig": "{projectRoot}/tsconfig.lib.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
import {
|
||||
addProjectConfiguration,
|
||||
readJson,
|
||||
readProjectConfiguration,
|
||||
updateJson,
|
||||
type NxJsonConfiguration,
|
||||
type TargetDefaults,
|
||||
type Tree,
|
||||
} from '@nx/devkit';
|
||||
import * as devkit from '@nx/devkit';
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import migration, {
|
||||
executors,
|
||||
} from './remove-external-options-from-js-executors';
|
||||
|
||||
// Fixtures use the record-shaped targetDefaults (keyed by target name or
|
||||
// executor) that this migration reads.
|
||||
type LegacyNxJson = Omit<NxJsonConfiguration, 'targetDefaults'> & {
|
||||
targetDefaults?: TargetDefaults;
|
||||
};
|
||||
|
||||
describe('remove-external-options-from-js-executors migration', () => {
|
||||
let tree: Tree;
|
||||
|
||||
beforeEach(() => {
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
jest
|
||||
.spyOn(devkit, 'formatFiles')
|
||||
.mockImplementation(() => Promise.resolve());
|
||||
});
|
||||
|
||||
it.each(executors)(
|
||||
'should remove "external" and "externalBuildTargets" options from target using the "%s" executor',
|
||||
async (executor) => {
|
||||
addProjectConfiguration(tree, 'lib1', {
|
||||
root: 'libs/lib1',
|
||||
targets: {
|
||||
build: {
|
||||
executor,
|
||||
options: {
|
||||
main: 'libs/lib1/src/index.ts',
|
||||
outputPath: 'dist/libs/lib1',
|
||||
tsConfig: 'libs/lib1/tsconfig.lib.json',
|
||||
external: ['react', 'react-dom'],
|
||||
externalBuildTargets: ['build', 'build-base'],
|
||||
},
|
||||
configurations: {
|
||||
development: {
|
||||
tsConfig: 'libs/lib1/tsconfig.lib.json',
|
||||
external: 'all',
|
||||
externalBuildTargets: ['build'],
|
||||
},
|
||||
production: {
|
||||
tsConfig: 'libs/lib1/tsconfig.lib.prod.json',
|
||||
external: 'none',
|
||||
externalBuildTargets: ['build', 'build-prod'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const project = readProjectConfiguration(tree, 'lib1');
|
||||
expect(project.targets.build.options.external).toBeUndefined();
|
||||
expect(
|
||||
project.targets.build.options.externalBuildTargets
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
project.targets.build.configurations.development.external
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
project.targets.build.configurations.development.externalBuildTargets
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
project.targets.build.configurations.production.external
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
project.targets.build.configurations.production.externalBuildTargets
|
||||
).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(executors)(
|
||||
'should remove empty options object but keep empty configurations from target using the "%s" executor',
|
||||
async (executor) => {
|
||||
addProjectConfiguration(tree, 'lib1', {
|
||||
root: 'libs/lib1',
|
||||
targets: {
|
||||
build: {
|
||||
executor,
|
||||
options: {
|
||||
external: ['react'],
|
||||
externalBuildTargets: ['build'],
|
||||
},
|
||||
configurations: {
|
||||
development: {
|
||||
external: 'all',
|
||||
externalBuildTargets: ['build'],
|
||||
},
|
||||
production: {
|
||||
external: 'none',
|
||||
externalBuildTargets: ['build', 'build-prod'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const project = readProjectConfiguration(tree, 'lib1');
|
||||
expect(project.targets.build.options).toBeUndefined();
|
||||
// we keep them because users might rely on them, e.g. in scripts, CI, etc.
|
||||
expect(project.targets.build.configurations).toEqual({
|
||||
development: {},
|
||||
production: {},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('should not delete "external" and "externalBuildTargets" from target not using the relevant executors', async () => {
|
||||
addProjectConfiguration(tree, 'lib1', {
|
||||
root: 'libs/lib1',
|
||||
targets: {
|
||||
build: {
|
||||
executor: '@org/awesome-plugin:executor',
|
||||
options: {
|
||||
external: ['react'],
|
||||
externalBuildTargets: ['build'],
|
||||
},
|
||||
configurations: {
|
||||
development: {
|
||||
external: 'all',
|
||||
externalBuildTargets: ['build'],
|
||||
},
|
||||
production: {
|
||||
external: 'none',
|
||||
externalBuildTargets: ['build', 'build-prod'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const project = readProjectConfiguration(tree, 'lib1');
|
||||
expect(project.targets.build.options.external).toEqual(['react']);
|
||||
expect(project.targets.build.options.externalBuildTargets).toEqual([
|
||||
'build',
|
||||
]);
|
||||
expect(project.targets.build.configurations.development.external).toBe(
|
||||
'all'
|
||||
);
|
||||
expect(
|
||||
project.targets.build.configurations.development.externalBuildTargets
|
||||
).toEqual(['build']);
|
||||
expect(project.targets.build.configurations.production.external).toBe(
|
||||
'none'
|
||||
);
|
||||
expect(
|
||||
project.targets.build.configurations.production.externalBuildTargets
|
||||
).toEqual(['build', 'build-prod']);
|
||||
});
|
||||
|
||||
it.each(executors)(
|
||||
'should delete "external" and "externalBuildTargets" options in nx.json target defaults for a target with the "%s" executor',
|
||||
async (executor) => {
|
||||
updateJson<LegacyNxJson>(tree, 'nx.json', (json) => {
|
||||
json.targetDefaults = {};
|
||||
json.targetDefaults.build = {
|
||||
executor,
|
||||
options: {
|
||||
main: '{projectRoot}/src/index.ts',
|
||||
outputPath: 'dist/{projectRoot}',
|
||||
tsConfig: '{projectRoot}/tsconfig.lib.json',
|
||||
external: ['react', 'react-dom'],
|
||||
externalBuildTargets: ['build'],
|
||||
},
|
||||
configurations: {
|
||||
development: {
|
||||
tsConfig: '{projectRoot}/tsconfig.lib.json',
|
||||
external: 'all',
|
||||
externalBuildTargets: ['build'],
|
||||
},
|
||||
production: {
|
||||
tsConfig: '{projectRoot}/tsconfig.lib.prod.json',
|
||||
external: 'none',
|
||||
externalBuildTargets: ['build', 'build-prod'],
|
||||
},
|
||||
},
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const nxJson = readJson<LegacyNxJson>(tree, 'nx.json');
|
||||
expect(nxJson.targetDefaults.build.options.external).toBeUndefined();
|
||||
expect(
|
||||
nxJson.targetDefaults.build.options.externalBuildTargets
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
nxJson.targetDefaults.build.configurations.development.external
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
nxJson.targetDefaults.build.configurations.development
|
||||
.externalBuildTargets
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
nxJson.targetDefaults.build.configurations.production.external
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
nxJson.targetDefaults.build.configurations.production
|
||||
.externalBuildTargets
|
||||
).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(executors)(
|
||||
'should remove empty options but keep empty configurations for a target with the "%s" executor',
|
||||
async (executor) => {
|
||||
updateJson<LegacyNxJson>(tree, 'nx.json', (json) => {
|
||||
json.targetDefaults = {};
|
||||
json.targetDefaults.build = {
|
||||
executor,
|
||||
options: {
|
||||
external: ['react'],
|
||||
externalBuildTargets: ['build'],
|
||||
},
|
||||
configurations: {
|
||||
development: {
|
||||
external: 'all',
|
||||
externalBuildTargets: ['build'],
|
||||
},
|
||||
production: {
|
||||
external: 'none',
|
||||
externalBuildTargets: ['build', 'build-prod'],
|
||||
},
|
||||
},
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const nxJson = readJson<LegacyNxJson>(tree, 'nx.json');
|
||||
expect(nxJson.targetDefaults.build.options).toBeUndefined();
|
||||
// we keep them because users might rely on them, e.g. in scripts, CI, etc.
|
||||
// they might be applying them from target defaults
|
||||
expect(nxJson.targetDefaults.build.configurations).toEqual({
|
||||
development: {},
|
||||
production: {},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it.each(executors)(
|
||||
'should delete "external" and "externalBuildTargets" options in nx.json target defaults for the "%s" executor',
|
||||
async (executor) => {
|
||||
updateJson<LegacyNxJson>(tree, 'nx.json', (json) => {
|
||||
json.targetDefaults = {};
|
||||
json.targetDefaults[executor] = {
|
||||
options: {
|
||||
main: '{projectRoot}/src/index.ts',
|
||||
outputPath: 'dist/{projectRoot}',
|
||||
tsConfig: '{projectRoot}/tsconfig.lib.json',
|
||||
external: ['react', 'react-dom'],
|
||||
externalBuildTargets: ['build'],
|
||||
},
|
||||
configurations: {
|
||||
development: {
|
||||
tsConfig: '{projectRoot}/tsconfig.lib.json',
|
||||
external: 'all',
|
||||
externalBuildTargets: ['build'],
|
||||
},
|
||||
production: {
|
||||
tsConfig: '{projectRoot}/tsconfig.lib.prod.json',
|
||||
external: 'none',
|
||||
externalBuildTargets: ['build', 'build-prod'],
|
||||
},
|
||||
},
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const nxJson = readJson<LegacyNxJson>(tree, 'nx.json');
|
||||
expect(nxJson.targetDefaults[executor].options.external).toBeUndefined();
|
||||
expect(
|
||||
nxJson.targetDefaults[executor].options.externalBuildTargets
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
nxJson.targetDefaults[executor].configurations.development.external
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
nxJson.targetDefaults[executor].configurations.development
|
||||
.externalBuildTargets
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
nxJson.targetDefaults[executor].configurations.production.external
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
nxJson.targetDefaults[executor].configurations.production
|
||||
.externalBuildTargets
|
||||
).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(executors)(
|
||||
'should only delete target defaults for the "%s" executor when nothing remains after migration',
|
||||
async (executor) => {
|
||||
updateJson<LegacyNxJson>(tree, 'nx.json', (json) => {
|
||||
json.targetDefaults = {
|
||||
build: { cache: true },
|
||||
[executor]: {
|
||||
options: {
|
||||
external: ['react'],
|
||||
externalBuildTargets: ['build'],
|
||||
},
|
||||
},
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
await migration(tree);
|
||||
|
||||
const nxJson = readJson<LegacyNxJson>(tree, 'nx.json');
|
||||
expect(nxJson.targetDefaults[executor]).toBeUndefined();
|
||||
// sibling defaults are untouched
|
||||
expect(nxJson.targetDefaults.build).toEqual({ cache: true });
|
||||
}
|
||||
);
|
||||
});
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import { forEachExecutorOptions } from '@nx/devkit/internal';
|
||||
import {
|
||||
formatFiles,
|
||||
readNxJson,
|
||||
readProjectConfiguration,
|
||||
type TargetConfiguration,
|
||||
type TargetDefaultArrayEntry,
|
||||
updateNxJson,
|
||||
updateProjectConfiguration,
|
||||
type Tree,
|
||||
} from '@nx/devkit';
|
||||
|
||||
export const executors = ['@nx/js:swc', '@nx/js:tsc'];
|
||||
|
||||
export default async function (tree: Tree) {
|
||||
// update options from project configs
|
||||
executors.forEach((executor) => {
|
||||
forEachExecutorOptions<{ external?: any; externalBuildTargets?: string[] }>(
|
||||
tree,
|
||||
executor,
|
||||
(options, project, target, configuration) => {
|
||||
if (
|
||||
options.external === undefined &&
|
||||
options.externalBuildTargets === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectConfiguration = readProjectConfiguration(tree, project);
|
||||
if (configuration) {
|
||||
const config =
|
||||
projectConfiguration.targets[target].configurations[configuration];
|
||||
delete config.external;
|
||||
delete config.externalBuildTargets;
|
||||
} else {
|
||||
const config = projectConfiguration.targets[target].options;
|
||||
delete config.external;
|
||||
delete config.externalBuildTargets;
|
||||
if (!Object.keys(config).length) {
|
||||
delete projectConfiguration.targets[target].options;
|
||||
}
|
||||
}
|
||||
|
||||
updateProjectConfiguration(tree, project, projectConfiguration);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// update options from nx.json target defaults
|
||||
const nxJson = readNxJson(tree);
|
||||
if (!nxJson.targetDefaults) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanEntry = (entry: TargetConfiguration) => {
|
||||
if (entry.options) {
|
||||
delete entry.options.external;
|
||||
delete entry.options.externalBuildTargets;
|
||||
if (!Object.keys(entry.options).length) {
|
||||
delete entry.options;
|
||||
}
|
||||
}
|
||||
Object.values(entry.configurations ?? {}).forEach((config) => {
|
||||
delete config.external;
|
||||
delete config.externalBuildTargets;
|
||||
});
|
||||
};
|
||||
|
||||
const targetDefaults = nxJson.targetDefaults;
|
||||
for (const key of Object.keys(targetDefaults)) {
|
||||
const value = targetDefaults[key];
|
||||
const entries: TargetDefaultArrayEntry[] = Array.isArray(value)
|
||||
? value
|
||||
: [value];
|
||||
|
||||
const usesExecutor = (entry: TargetDefaultArrayEntry) =>
|
||||
executors.includes(key) ||
|
||||
executors.includes(entry.executor) ||
|
||||
executors.includes(entry.filter?.executor);
|
||||
|
||||
const kept = entries.filter((entry) => {
|
||||
if (usesExecutor(entry)) {
|
||||
cleanEntry(entry as TargetConfiguration);
|
||||
}
|
||||
// Drop entries left with nothing but their `filter`/`executor` locator.
|
||||
return Object.keys(entry).some((k) => k !== 'filter' && k !== 'executor');
|
||||
});
|
||||
|
||||
if (kept.length === 0) {
|
||||
delete targetDefaults[key];
|
||||
} else if (kept.length === 1 && kept[0].filter === undefined) {
|
||||
// A lone unfiltered entry is stored as the plain object form (which
|
||||
// omits `filter`).
|
||||
const { filter: _filter, ...config } = kept[0];
|
||||
targetDefaults[key] = config;
|
||||
} else {
|
||||
targetDefaults[key] = kept;
|
||||
}
|
||||
}
|
||||
if (Object.keys(targetDefaults).length === 0) {
|
||||
delete nxJson.targetDefaults;
|
||||
}
|
||||
|
||||
updateNxJson(tree, nxJson);
|
||||
|
||||
await formatFiles(tree);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user