Skip to content
Closed
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
23 changes: 6 additions & 17 deletions packages/angular/cli/src/commands/add/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,29 +224,12 @@ export default class AddCommandModule
{
title: 'Confirming installation',
enabled: !skipConfirmation && !options.dryRun,
skip: (context) => {
if (context.hasSchematics) {
return false;
}

return `The ${color.blue(context.packageIdentifier.toString())} package does not provide \`ng add\` actions.`;
},
task: (context, task) => this.confirmInstallationTask(context, task),
rendererOptions: { persistentOutput: true },
},
{
title: 'Installing package',
skip: (context) => {
if (!context.hasSchematics) {
const builtInSchematic =
BUILT_IN_SCHEMATICS[
context.packageIdentifier.name as keyof typeof BUILT_IN_SCHEMATICS
];
if (builtInSchematic) {
return `Skipping package installation.`;
}
}

if (context.dryRun) {
return `Skipping package installation. Would install package ${color.blue(
context.packageIdentifier.toString(),
Expand Down Expand Up @@ -278,6 +261,9 @@ export default class AddCommandModule
if (localManifest['ng-add']?.save === false) {
shouldCleanUp = true;
}
} else {
await this.cleanUpTemporaryDependency(result.collectionName);
shouldCleanUp = false;
}
} catch {}
}
Expand Down Expand Up @@ -305,6 +291,9 @@ export default class AddCommandModule
const builtInSchematic =
BUILT_IN_SCHEMATICS[packageName as keyof typeof BUILT_IN_SCHEMATICS];
if (builtInSchematic) {
logger.info(
`The ${color.blue(packageName)} package does not provide \`ng add\` actions.`,
);
logger.info('The Angular CLI will use built-in actions to add it to your project.');

return this.executeSchematic({
Expand Down
48 changes: 47 additions & 1 deletion packages/angular/cli/src/commands/update/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,7 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
}
}

const migrations = plan.migrationsToRun;
const migrations = await resolveFallbackMigrations(this.context.root, plan);

if (migrations) {
for (const migration of migrations) {
Expand Down Expand Up @@ -706,3 +706,49 @@ async function readPackageManifest(manifestPath: string): Promise<PackageManifes
return undefined;
}
}

/**
* Resolves migrations from installed package manifests on disk when they were omitted
* from the initial update plan.
*
* This fallback is necessary because private package registries (such as GitHub Packages)
* frequently strip custom non-npm metadata properties (like `ng-update`) from their remote
* registry API responses. By inspecting `node_modules/<package>/package.json` after installation,
* we ensure that any migration collections defined by the package are discovered and queued.
*/
export async function resolveFallbackMigrations(
Comment thread
alan-agius4 marked this conversation as resolved.
workspaceRoot: string,
plan: UpdatePlan,
): Promise<{ package: string; collection: string; from: string; to: string }[]> {
const migrations = [...plan.migrationsToRun];
const existingMigrationPackages = new Set(migrations.map((m) => m.package));

for (const [packageName, targetVersion] of plan.packagesToUpdate) {
if (existingMigrationPackages.has(packageName)) {
continue;
}

const packageJsonPath = findPackageJson(workspaceRoot, packageName);
if (packageJsonPath) {
try {
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
const ngUpdate = packageJson?.['ng-update'];
if (ngUpdate && typeof ngUpdate === 'object' && typeof ngUpdate.migrations === 'string') {
const installedVersion = plan.packageInfoMap.get(packageName)?.installed.version;
if (installedVersion) {
migrations.push({
package: packageName,
collection: ngUpdate.migrations,
from: installedVersion,
to: targetVersion,
});
}
}
} catch {
// Ignore read/parse errors for optional fallback
}
}
}

return migrations;
}
133 changes: 133 additions & 0 deletions packages/angular/cli/src/commands/update/cli_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* @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
*/

import assert from 'node:assert';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import path from 'path';
import { resolveFallbackMigrations } from './cli';
import type { PackageVersionInfo, UpdatePlan } from './update-resolver';

describe('resolveFallbackMigrations', () => {
let tempRoot: string;
let pkgDir: string;
beforeEach(async () => {
const baseTmpDir = process.env['TEST_TMPDIR'];
assert(baseTmpDir, 'TEST_TMPDIR is not set');
tempRoot = await mkdtemp(path.join(baseTmpDir, 'angular-cli-update-cli-test-'));
pkgDir = path.join(tempRoot, 'node_modules/@company/library-name');
await mkdir(pkgDir, { recursive: true });
});

afterEach(async () => {
await rm(tempRoot, { recursive: true, force: true });
});

it('discovers migrations from installed package.json when omitted from plan.migrationsToRun', async () => {
await writeFile(
path.join(pkgDir, 'package.json'),
JSON.stringify({
name: '@company/library-name',
version: '21.2.0-next.1',
'ng-update': {
migrations: './schematics/migration.json',
},
}),
'utf8',
);

const plan: UpdatePlan = {
packagesToUpdate: new Map([['@company/library-name', '21.2.0-next.1']]),
migrationsToRun: [],
packageInfoMap: new Map([
[
'@company/library-name',
{
name: '@company/library-name',
npmPackageJson: {
name: '@company/library-name',
versions: ['21.1.0', '21.2.0-next.1'],
'dist-tags': {},
},
installed: {
version: '21.1.0' as unknown as PackageVersionInfo['version'],
packageJson: { name: '@company/library-name', version: '21.1.0' },
updateMetadata: { packageGroup: {}, requirements: {} },
},
packageJsonRange: '^21.1.0',
},
],
]),
registryClient: undefined as unknown as UpdatePlan['registryClient'],
};

const migrations = await resolveFallbackMigrations(tempRoot, plan);

expect(migrations).toEqual([
{
package: '@company/library-name',
collection: './schematics/migration.json',
from: '21.1.0',
to: '21.2.0-next.1',
},
]);
});

it('does not duplicate migration if package is already in plan.migrationsToRun', async () => {
await writeFile(
path.join(pkgDir, 'package.json'),
JSON.stringify({
name: '@company/library-name',
version: '21.2.0-next.1',
'ng-update': {
migrations: './schematics/migration.json',
},
}),
'utf8',
);

const plan: UpdatePlan = {
packagesToUpdate: new Map([['@company/library-name', '21.2.0-next.1']]),
migrationsToRun: [
{
package: '@company/library-name',
collection: './schematics/migration.json',
from: '21.1.0',
to: '21.2.0-next.1',
},
],
packageInfoMap: new Map(),
registryClient: undefined as unknown as UpdatePlan['registryClient'],
};

const migrations = await resolveFallbackMigrations(tempRoot, plan);

expect(migrations).toHaveSize(1);
});

it('returns unchanged migrations when package has no ng-update field on disk', async () => {
await writeFile(
path.join(pkgDir, 'package.json'),
JSON.stringify({
name: '@company/library-name',
version: '21.2.0-next.1',
}),
'utf8',
);

const plan: UpdatePlan = {
packagesToUpdate: new Map([['@company/library-name', '21.2.0-next.1']]),
migrationsToRun: [],
packageInfoMap: new Map(),
registryClient: undefined as unknown as UpdatePlan['registryClient'],
};

const migrations = await resolveFallbackMigrations(tempRoot, plan);

expect(migrations).toHaveSize(0);
});
});