Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 56 additions & 30 deletions packages/angular/cli/src/commands/update/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,16 @@ import {
Options,
} from '../../command-builder/command-module';
import { SchematicEngineHost } from '../../command-builder/utilities/schematic-engine-host';
import { PackageManager, PackageManifest, createPackageManager } from '../../package-managers';
import {
InstalledPackage,
PackageManager,
PackageManifest,
createPackageManager,
} from '../../package-managers';
import { colors } from '../../utilities/color';
import { disableVersionCheck } from '../../utilities/environment-options';
import { assertIsError } from '../../utilities/error';
import {
PackageTreeNode,
findPackageJson,
getProjectDependencies,
readPackageJson,
} from '../../utilities/package-tree';
import { findPackageJson } from '../../utilities/package-tree';
import {
checkCLIVersion,
coerceVersionNumber,
Expand Down Expand Up @@ -242,7 +242,7 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
logger.info(`Using package manager: ${colors.gray(packageManager.name)}`);
logger.info('Collecting installed dependencies...');

const rootDependencies = await getProjectDependencies(this.context.root);
const rootDependencies = await packageManager.getProjectDependencies();
logger.info(`Found ${rootDependencies.size} dependencies.`);

const workflow = new NodeWorkflow(this.context.root, {
Expand Down Expand Up @@ -275,7 +275,13 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
}

return options.migrateOnly
? this.migrateOnly(workflow, (options.packages ?? [])[0], rootDependencies, options)
? this.migrateOnly(
workflow,
(options.packages ?? [])[0],
rootDependencies,
options,
packageManager,
)
: this.updatePackagesAndMigrate(
workflow,
rootDependencies,
Expand All @@ -288,25 +294,35 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
private async migrateOnly(
workflow: NodeWorkflow,
packageName: string,
rootDependencies: Map<string, PackageTreeNode>,
rootDependencies: Map<string, InstalledPackage>,
options: Options<UpdateCommandArgs>,
packageManager: PackageManager,
): Promise<number | void> {
const { logger } = this.context;
const packageDependency = rootDependencies.get(packageName);
let packageDependency = rootDependencies.get(packageName);
let packagePath = packageDependency?.path;
let packageNode = packageDependency?.package;
if (packageDependency && !packageNode) {
logger.error('Package found in package.json but is not installed.');
let packageNode: PackageManifest | undefined;

return 1;
} else if (!packageDependency) {
// Allow running migrations on transitively installed dependencies
// There can technically be nested multiple versions
// TODO: If multiple, this should find all versions and ask which one to use
const packageJson = findPackageJson(this.context.root, packageName);
if (packageJson) {
packagePath = path.dirname(packageJson);
packageNode = await readPackageJson(packageJson);
if (!packageDependency) {
const installed = await packageManager.getInstalledPackage(packageName);
if (installed) {
packageDependency = installed;
packagePath = installed.path;
}
}

if (packagePath) {
packageNode = await readPackageManifest(path.join(packagePath, 'package.json'));
}

if (!packageNode) {
const jsonPath = findPackageJson(this.context.root, packageName);
if (jsonPath) {
packageNode = await readPackageManifest(jsonPath);

if (!packagePath) {
packagePath = path.dirname(jsonPath);
}
}
}

Expand Down Expand Up @@ -399,7 +415,7 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
// eslint-disable-next-line max-lines-per-function
private async updatePackagesAndMigrate(
workflow: NodeWorkflow,
rootDependencies: Map<string, PackageTreeNode>,
rootDependencies: Map<string, InstalledPackage>,
options: Options<UpdateCommandArgs>,
packages: npa.Result[],
packageManager: PackageManager,
Expand All @@ -414,21 +430,21 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs

const requests: {
identifier: npa.Result;
node: PackageTreeNode;
node: InstalledPackage;
}[] = [];

// Validate packages actually are part of the workspace
for (const pkg of packages) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const node = rootDependencies.get(pkg.name!);
if (!node?.package) {
if (!node) {
logger.error(`Package '${pkg.name}' is not a dependency.`);

return 1;
}

// If a specific version is requested and matches the installed version, skip.
if (pkg.type === 'version' && node.package.version === pkg.fetchSpec) {
if (pkg.type === 'version' && node.version === pkg.fetchSpec) {
logger.info(`Package '${pkg.name}' is already at '${pkg.fetchSpec}'.`);
continue;
}
Expand Down Expand Up @@ -464,13 +480,13 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
return 1;
}

if (manifest.version === node.package?.version) {
if (manifest.version === node.version) {
logger.info(`Package '${packageName}' is already up to date.`);
continue;
}

if (node.package && ANGULAR_PACKAGES_REGEXP.test(node.package.name)) {
const { name, version } = node.package;
if (ANGULAR_PACKAGES_REGEXP.test(node.name)) {
const { name, version } = node;
const toBeInstalledMajorVersion = +manifest.version.split('.')[0];
const currentMajorVersion = +version.split('.')[0];

Expand Down Expand Up @@ -668,3 +684,13 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
return success ? 0 : 1;
}
}

async function readPackageManifest(manifestPath: string): Promise<PackageManifest | undefined> {
try {
const content = await fs.readFile(manifestPath, 'utf8');

return JSON.parse(content) as PackageManifest;
} catch {
return undefined;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Logger } from './logger';
import { PackageManifest, PackageMetadata } from './package-metadata';
import { InstalledPackage } from './package-tree';
import {
parseBunDependencies,
parseNpmLikeDependencies,
parseNpmLikeError,
parseNpmLikeManifest,
Expand Down Expand Up @@ -166,7 +167,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
ignoreScriptsFlag: '--ignore-scripts',
getRegistryOptions: (registry: string) => ({ env: { NPM_CONFIG_REGISTRY: registry } }),
versionCommand: ['--version'],
listDependenciesCommand: ['list', '--depth=0', '--json', '--recursive=false'],
listDependenciesCommand: ['info', '--name-only', '--json'],
getManifestCommand: ['npm', 'info', '--json'],
viewCommandFieldArgFormatter: (fields) => ['--fields', fields.join(',')],
outputParsers: {
Expand Down Expand Up @@ -241,10 +242,10 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
ignoreScriptsFlag: '--ignore-scripts',
getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }),
versionCommand: ['--version'],
listDependenciesCommand: ['pm', 'ls', '--json'],
listDependenciesCommand: ['pm', 'ls'],
getManifestCommand: ['pm', 'view', '--json'],
outputParsers: {
listDependencies: parseNpmLikeDependencies,
listDependencies: parseBunDependencies,
getRegistryManifest: parseNpmLikeManifest,
getRegistryMetadata: parseNpmLikeMetadata,
getError: parseNpmLikeError,
Expand Down
184 changes: 114 additions & 70 deletions packages/angular/cli/src/package-managers/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,10 @@ export function parseYarnClassicDependencies(
for (const json of parseJsonLines(stdout, logger)) {
if (json.type === 'tree' && json.data?.trees) {
for (const info of json.data.trees) {
const name = info.name.split('@')[0];
const version = info.name.split('@').pop();
const lastAtIndex = info.name.lastIndexOf('@');
const name = info.name.slice(0, lastAtIndex);
const version = info.name.slice(lastAtIndex + 1);

dependencies.set(name, {
name,
version,
Expand All @@ -170,74 +172,6 @@ export function parseYarnClassicDependencies(
return dependencies;
}

/**
* Parses the output of `yarn list` (modern).
*
* The expected JSON structure is a single object.
* Yarn modern does not provide a path, so the `path` property will be `undefined`.
*
* ```json
* {
* "trees": [
* { "name": "@angular/[email protected]", "children": [] }
* ]
* }
* ```
*
* @param stdout The standard output of the command.
* @param logger An optional logger instance.
* @returns A map of package names to their installed package details.
*/
export function parseYarnModernDependencies(
stdout: string,
logger?: Logger,
): Map<string, InstalledPackage> {
logger?.debug(`Parsing yarn modern dependency list...`);
logStdout(stdout, logger);

const dependencies = new Map<string, InstalledPackage>();
if (!stdout) {
logger?.debug(' stdout is empty. No dependencies found.');

return dependencies;
}

// Modern yarn `list` command outputs a single JSON object with a `trees` property.
// Each line is not a separate JSON object.
try {
const data = JSON.parse(stdout);
for (const info of data.trees) {
const name = info.name.split('@')[0];
const version = info.name.split('@').pop();
dependencies.set(name, {
name,
version,
});
}
} catch (e) {
logger?.debug(
` Failed to parse as single JSON object: ${e}. Falling back to line-by-line parsing.`,
);
// Fallback for older versions of yarn berry that might still output json lines
for (const json of parseJsonLines(stdout, logger)) {
if (json.type === 'tree' && json.data?.trees) {
for (const info of json.data.trees) {
const name = info.name.split('@')[0];
const version = info.name.split('@').pop();
dependencies.set(name, {
name,
version,
});
}
}
}
}

logger?.debug(` Found ${dependencies.size} dependencies.`);

return dependencies;
}

/**
* Parses the output of `npm view` or a compatible command to get a package manifest.
* @param stdout The standard output of the command.
Expand Down Expand Up @@ -519,3 +453,113 @@ export function parseYarnClassicError(output: string, logger?: Logger): ErrorInf

return null;
}

/**
* Parses the output of `bun pm ls`.
*
* Bun does not support JSON output for `pm ls`. The output is a tree structure:
* ```
* /path/to/project node_modules (1084)
* ├── @angular/[email protected]
* ├── rxjs @7.8.2
* └── zone.js @0.15.1
* ```
*
* @param stdout The standard output of the command.
* @param logger An optional logger instance.
* @returns A map of package names to their installed package details.
*/
export function parseBunDependencies(
stdout: string,
logger?: Logger,
): Map<string, InstalledPackage> {
logger?.debug('Parsing Bun dependency list...');
logStdout(stdout, logger);

const dependencies = new Map<string, InstalledPackage>();
if (!stdout) {
return dependencies;
}

const lines = stdout.split('\n');
// Skip the first line (project info)
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) {
continue;
}

// Remove tree structure characters
const cleanLine = line.replace(/^[└├]──\s*/, '');

// Parse name and version
// Scoped: @angular/[email protected]
// Unscoped: rxjs @7.8.2
const match = cleanLine.match(/^(.+?)\s?@([^@\s]+)$/);
if (match) {
const name = match[1];
const version = match[2];
dependencies.set(name, { name, version });
}
}

logger?.debug(` Found ${dependencies.size} dependencies.`);

return dependencies;
}

/**
* Parses the output of `yarn info --name-only --json`.
*
* The expected output is a JSON stream (JSONL) of strings.
* Each string represents a package locator.
*
* ```
* "karma@npm:6.4.4"
* "@angular/core@npm:20.3.15"
* ```
*
* @param stdout The standard output of the command.
* @param logger An optional logger instance.
* @returns A map of package names to their installed package details.
*/
export function parseYarnModernDependencies(
stdout: string,
logger?: Logger,
): Map<string, InstalledPackage> {
logger?.debug('Parsing Yarn Berry dependency list...');
logStdout(stdout, logger);

const dependencies = new Map<string, InstalledPackage>();
if (!stdout) {
return dependencies;
}

for (const json of parseJsonLines(stdout, logger)) {
if (typeof json === 'string') {
const match = json.match(/^(@?[^@]+)@(.+)$/);
if (match) {
const name = match[1];
let version = match[2];

// Handle "npm:" prefix
if (version.startsWith('npm:')) {
version = version.slice(4);
}

// Handle complex locators with embedded version metadata (e.g., "patch:...", "virtual:...")
// Yarn Berry often appends metadata like "::version=x.y.z"
const versionParamMatch = version.match(/::version=([^&]+)/);
if (versionParamMatch) {
version = versionParamMatch[1];
}

dependencies.set(name, { name, version });
}
}
}

logger?.debug(` Found ${dependencies.size} dependencies.`);

return dependencies;
}
Loading