UNPKG

@angular/cli

Version:
484 lines • 22.4 kB
"use strict"; /** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.LIST_PROJECTS_TOOL = void 0; const promises_1 = require("node:fs/promises"); const node_path_1 = __importDefault(require("node:path")); const node_url_1 = require("node:url"); const semver_1 = __importDefault(require("semver")); const zod_1 = __importDefault(require("zod")); const config_1 = require("../../../utilities/config"); const error_1 = require("../../../utilities/error"); const tool_registry_1 = require("./tool-registry"); // Single source of truth for what constitutes a valid style language. const styleLanguageSchema = zod_1.default.enum(['css', 'scss', 'sass', 'less']); const VALID_STYLE_LANGUAGES = styleLanguageSchema.options; // Explicitly ordered for the file system search heuristic. const STYLE_LANGUAGE_SEARCH_ORDER = ['scss', 'sass', 'less', 'css']; function isStyleLanguage(value) { return (typeof value === 'string' && VALID_STYLE_LANGUAGES.includes(value)); } function getStyleLanguageFromExtension(extension) { const style = extension.toLowerCase().substring(1); // remove leading '.' return isStyleLanguage(style) ? style : undefined; } const listProjectsOutputSchema = { workspaces: zod_1.default.array(zod_1.default.object({ path: zod_1.default.string().describe('The path to the `angular.json` file for this workspace.'), frameworkVersion: zod_1.default .string() .optional() .describe('The major version of the Angular framework (`@angular/core`) in this workspace, if found.'), projects: zod_1.default.array(zod_1.default.object({ name: zod_1.default .string() .describe('The name of the project, as defined in the `angular.json` file.'), type: zod_1.default .enum(['application', 'library']) .optional() .describe(`The type of the project, either 'application' or 'library'.`), builder: zod_1.default .string() .optional() .describe('The primary builder for the project, typically from the "build" target.'), root: zod_1.default .string() .describe('The root directory of the project, relative to the workspace root.'), sourceRoot: zod_1.default .string() .describe(`The root directory of the project's source files, relative to the workspace root.`), selectorPrefix: zod_1.default .string() .optional() .describe('The prefix to use for component selectors.' + ` For example, a prefix of 'app' would result in selectors like '<app-my-component>'.`), unitTestFramework: zod_1.default .enum(['jasmine', 'jest', 'vitest', 'unknown']) .optional() .describe('The unit test framework used by the project, such as Jasmine, Jest, or Vitest. ' + 'This field is critical for generating correct and idiomatic unit tests. ' + 'When writing or modifying tests, you MUST use the APIs corresponding to this framework.'), styleLanguage: styleLanguageSchema .optional() .describe('The default style language for the project (e.g., "scss"). ' + 'This determines the file extension for new component styles.'), })), })), parsingErrors: zod_1.default .array(zod_1.default.object({ filePath: zod_1.default.string().describe('The path to the file that could not be parsed.'), message: zod_1.default.string().describe('The error message detailing why parsing failed.'), })) .default([]) .describe('A list of files that looked like workspaces but failed to parse.'), versioningErrors: zod_1.default .array(zod_1.default.object({ filePath: zod_1.default .string() .describe('The path to the workspace `angular.json` for which versioning failed.'), message: zod_1.default.string().describe('The error message detailing why versioning failed.'), })) .default([]) .describe('A list of workspaces for which the framework version could not be determined.'), }; exports.LIST_PROJECTS_TOOL = (0, tool_registry_1.declareTool)({ name: 'list_projects', title: 'List Angular Projects', description: ` <Purpose> Provides a comprehensive overview of all Angular workspaces and projects within the repository. It is essential to use this tool as a first step before performing any project-specific actions to understand the available projects, their types, and their locations. </Purpose> <Use Cases> * Finding the correct project name to use in other commands (e.g., \`ng generate component my-comp --project=my-app\`). * Identifying the \`root\` and \`sourceRoot\` of a project to read, analyze, or modify its files. * Determining a project's unit test framework (\`unitTestFramework\`) before writing or modifying tests. * Identifying the project's style language (\`styleLanguage\`) to use the correct file extension (e.g., \`.scss\`). * Getting the \`selectorPrefix\` for a project before generating a new component to ensure it follows conventions. * Identifying the major version of the Angular framework for each workspace, which is crucial for monorepos. * Determining a project's primary function by inspecting its builder (e.g., '@angular-devkit/build-angular:browser' for an application). </Use Cases> <Operational Notes> * **Working Directory:** Shell commands for a project (like \`ng generate\`) **MUST** be executed from the parent directory of the \`path\` field for the relevant workspace. * **Unit Testing:** The \`unitTestFramework\` field tells you which testing API to use (e.g., Jasmine, Jest). If the value is 'unknown', you **MUST** inspect the project's configuration files (e.g., \`karma.conf.js\`, \`jest.config.js\`, or the 'test' target in \`angular.json\`) to determine the framework before generating tests. * **Disambiguation:** A monorepo may contain multiple workspaces (e.g., for different applications or even in output directories). Use the \`path\` of each workspace to understand its context and choose the correct project. </Operational Notes>`, outputSchema: listProjectsOutputSchema, isReadOnly: true, isLocalOnly: true, factory: createListProjectsHandler, }); const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'out', 'coverage']); /** * Iteratively finds all 'angular.json' files with controlled concurrency and directory exclusions. * This non-recursive implementation is suitable for very large directory trees, * prevents file descriptor exhaustion (`EMFILE` errors), and handles symbolic link loops. * @param rootDir The directory to start the search from. * @returns An async generator that yields the full path of each found 'angular.json' file. */ async function* findAngularJsonFiles(rootDir) { const CONCURRENCY_LIMIT = 50; const queue = [rootDir]; const seenInodes = new Set(); try { const rootStats = await (0, promises_1.stat)(rootDir); seenInodes.add(rootStats.ino); } catch (error) { (0, error_1.assertIsError)(error); if (error.code === 'EACCES' || error.code === 'EPERM' || error.code === 'ENOENT') { return; // Cannot access root, so there's nothing to do. } throw error; } while (queue.length > 0) { const batch = queue.splice(0, CONCURRENCY_LIMIT); const foundFilesInBatch = []; const promises = batch.map(async (dir) => { try { const entries = await (0, promises_1.readdir)(dir, { withFileTypes: true }); const subdirectories = []; for (const entry of entries) { const fullPath = node_path_1.default.join(dir, entry.name); if (entry.isDirectory()) { // Exclude dot-directories, build/cache directories, and node_modules if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) { continue; } // Check for symbolic link loops try { const entryStats = await (0, promises_1.stat)(fullPath); if (seenInodes.has(entryStats.ino)) { continue; // Already visited this directory (symlink loop), skip. } seenInodes.add(entryStats.ino); } catch { // Ignore errors from stat (e.g., broken symlinks) continue; } subdirectories.push(fullPath); } else if (entry.name === 'angular.json') { foundFilesInBatch.push(fullPath); } } return subdirectories; } catch (error) { (0, error_1.assertIsError)(error); if (error.code === 'EACCES' || error.code === 'EPERM') { return []; // Silently ignore permission errors. } throw error; } }); const nestedSubdirs = await Promise.all(promises); queue.push(...nestedSubdirs.flat()); yield* foundFilesInBatch; } } /** * Searches upwards from a starting directory to find the version of '@angular/core'. * It caches results to avoid redundant lookups. * @param startDir The directory to start the search from. * @param cache A map to store cached results. * @param searchRoot The directory at which to stop the search. * @returns The major version of '@angular/core' as a string, otherwise undefined. */ async function findAngularCoreVersion(startDir, cache, searchRoot) { let currentDir = startDir; const dirsToCache = []; while (currentDir) { dirsToCache.push(currentDir); if (cache.has(currentDir)) { const cachedResult = cache.get(currentDir); // Populate cache for all intermediate directories. for (const dir of dirsToCache) { cache.set(dir, cachedResult); } return cachedResult; } const pkgPath = node_path_1.default.join(currentDir, 'package.json'); try { const pkgContent = await (0, promises_1.readFile)(pkgPath, 'utf-8'); const pkg = JSON.parse(pkgContent); const versionSpecifier = pkg.dependencies?.['@angular/core'] ?? pkg.devDependencies?.['@angular/core']; if (versionSpecifier) { const minVersion = semver_1.default.minVersion(versionSpecifier); const result = minVersion ? String(minVersion.major) : undefined; for (const dir of dirsToCache) { cache.set(dir, result); } return result; } } catch (error) { (0, error_1.assertIsError)(error); if (error.code !== 'ENOENT') { // Ignore missing package.json files, but rethrow other errors. throw error; } } // Stop if we are at the search root or the filesystem root. if (currentDir === searchRoot) { break; } const parentDir = node_path_1.default.dirname(currentDir); if (parentDir === currentDir) { break; // Reached the filesystem root. } currentDir = parentDir; } // Cache the failure for all traversed directories. for (const dir of dirsToCache) { cache.set(dir, undefined); } return undefined; } /** * Determines the unit test framework for a project based on its 'test' target configuration. * It handles both the modern `@angular/build:unit-test` builder with its `runner` option * and older builders where the framework is inferred from the builder name. * @param testTarget The 'test' target definition from the workspace configuration. * @returns The name of the unit test framework ('jasmine', 'jest', 'vitest'), 'unknown' if * the framework can't be determined from a known builder, or `undefined` if there is no test target. */ function getUnitTestFramework(testTarget) { if (!testTarget) { return undefined; } // For the new unit-test builder, the runner option directly informs the framework. if (testTarget.builder === '@angular/build:unit-test') { const runner = testTarget.options?.['runner']; if (runner === 'karma') { return 'jasmine'; // Karma is a runner, but the framework is Jasmine. } else { return runner; // For 'vitest', the runner and framework are the same. } } // Fallback for older builders where the framework is inferred from the builder name. if (testTarget.builder) { const testBuilder = testTarget.builder; if (testBuilder.includes('karma') || testBuilder === '@angular-devkit/build-angular:web-test-runner') { return 'jasmine'; } else if (testBuilder.includes('jest')) { return 'jest'; } else if (testBuilder.includes('vitest')) { return 'vitest'; } else { return 'unknown'; } } return undefined; } /** * Determines the style language for a project using a prioritized heuristic. * It checks project-specific schematics, then workspace-level schematics, * and finally infers from the build target's inlineStyleLanguage option. * @param project The project definition from the workspace configuration. * @param workspace The loaded Angular workspace. * @returns The determined style language ('css', 'scss', 'sass', 'less'). */ async function getProjectStyleLanguage(project, workspace, fullSourceRoot) { const projectSchematics = project.extensions.schematics; const workspaceSchematics = workspace.extensions.schematics; // 1. Check for a project-specific schematic setting. let style = projectSchematics?.['@schematics/angular:component']?.['style']; if (isStyleLanguage(style)) { return style; } // 2. Check for a workspace-level schematic setting. style = workspaceSchematics?.['@schematics/angular:component']?.['style']; if (isStyleLanguage(style)) { return style; } const buildTarget = project.targets.get('build'); if (buildTarget?.options) { // 3. Infer from the build target's inlineStyleLanguage option. style = buildTarget.options['inlineStyleLanguage']; if (isStyleLanguage(style)) { return style; } // 4. Infer from the 'styles' array (explicit). const styles = buildTarget.options['styles']; if (Array.isArray(styles)) { for (const stylePath of styles) { const style = getStyleLanguageFromExtension(node_path_1.default.extname(stylePath)); if (style) { return style; } } } } // 5. Infer from implicit default styles file (future-proofing). for (const ext of STYLE_LANGUAGE_SEARCH_ORDER) { try { await (0, promises_1.stat)(node_path_1.default.join(fullSourceRoot, `styles.${ext}`)); return ext; } catch { // Silently ignore all errors (e.g., file not found, permissions). // If we can't read the file, we can't use it for detection. } } // 6. Fallback to 'css'. return 'css'; } /** * Loads, parses, and transforms a single angular.json file into the tool's output format. * It checks a set of seen paths to avoid processing the same workspace multiple times. * @param configFile The path to the angular.json file. * @param seenPaths A Set of absolute paths that have already been processed. * @returns A promise resolving to the workspace data or a parsing error. */ async function loadAndParseWorkspace(configFile, seenPaths) { try { const resolvedPath = node_path_1.default.resolve(configFile); if (seenPaths.has(resolvedPath)) { return { workspace: null, error: null }; // Already processed, skip. } seenPaths.add(resolvedPath); const ws = await config_1.AngularWorkspace.load(configFile); const projects = []; const workspaceRoot = node_path_1.default.dirname(configFile); for (const [name, project] of ws.projects.entries()) { const sourceRoot = node_path_1.default.posix.join(project.root, project.sourceRoot ?? 'src'); const fullSourceRoot = node_path_1.default.join(workspaceRoot, sourceRoot); const unitTestFramework = getUnitTestFramework(project.targets.get('test')); const styleLanguage = await getProjectStyleLanguage(project, ws, fullSourceRoot); projects.push({ name, type: project.extensions['projectType'], builder: project.targets.get('build')?.builder, root: project.root, sourceRoot, selectorPrefix: project.extensions['prefix'], unitTestFramework, styleLanguage, }); } return { workspace: { path: configFile, projects }, error: null }; } catch (error) { let message; if (error instanceof Error) { message = error.message; } else { message = 'An unknown error occurred while parsing the file.'; } return { workspace: null, error: { filePath: configFile, message } }; } } /** * Processes a single `angular.json` file to extract workspace and framework version information. * @param configFile The path to the `angular.json` file. * @param searchRoot The directory at which to stop the upward search for `package.json`. * @param seenPaths A Set of absolute paths that have already been processed to avoid duplicates. * @param versionCache A Map to cache framework version lookups for performance. * @returns A promise resolving to an object containing the processed data and any errors. */ async function processConfigFile(configFile, searchRoot, seenPaths, versionCache) { const { workspace, error } = await loadAndParseWorkspace(configFile, seenPaths); if (error) { return { parsingError: error }; } if (!workspace) { return {}; // Skipped as it was already seen. } try { const workspaceDir = node_path_1.default.dirname(configFile); workspace.frameworkVersion = await findAngularCoreVersion(workspaceDir, versionCache, searchRoot); return { workspace }; } catch (e) { return { workspace, versioningError: { filePath: workspace.path, message: e instanceof Error ? e.message : 'An unknown error occurred.', }, }; } } async function createListProjectsHandler({ server }) { return async () => { const workspaces = []; const parsingErrors = []; const versioningErrors = []; const seenPaths = new Set(); const versionCache = new Map(); let searchRoots; const clientCapabilities = server.server.getClientCapabilities(); if (clientCapabilities?.roots) { const { roots } = await server.server.listRoots(); searchRoots = roots?.map((r) => node_path_1.default.normalize((0, node_url_1.fileURLToPath)(r.uri))) ?? []; } else { // Fallback to the current working directory if client does not support roots searchRoots = [process.cwd()]; } for (const root of searchRoots) { for await (const configFile of findAngularJsonFiles(root)) { const { workspace, parsingError, versioningError } = await processConfigFile(configFile, root, seenPaths, versionCache); if (workspace) { workspaces.push(workspace); } if (parsingError) { parsingErrors.push(parsingError); } if (versioningError) { versioningErrors.push(versioningError); } } } if (workspaces.length === 0 && parsingErrors.length === 0) { return { content: [ { type: 'text', text: 'No Angular workspace found.' + ' An `angular.json` file, which marks the root of a workspace,' + ' could not be located in the current directory or any of its parent directories.', }, ], structuredContent: { workspaces: [] }, }; } let text = `Found ${workspaces.length} workspace(s).\n${JSON.stringify({ workspaces })}`; if (parsingErrors.length > 0) { text += `\n\nWarning: The following ${parsingErrors.length} file(s) could not be parsed and were skipped:\n`; text += parsingErrors.map((e) => `- ${e.filePath}: ${e.message}`).join('\n'); } if (versioningErrors.length > 0) { text += `\n\nWarning: The framework version for the following ${versioningErrors.length} workspace(s) could not be determined:\n`; text += versioningErrors.map((e) => `- ${e.filePath}: ${e.message}`).join('\n'); } return { content: [{ type: 'text', text }], structuredContent: { workspaces, parsingErrors, versioningErrors }, }; }; } //# sourceMappingURL=projects.js.map