Skip to content

Commit e27048a

Browse files
committed
fix(@angular/cli): fallback to local package.json for schematic detection on first run
Private package registries frequently strip out custom non-npm metadata properties such as "schematics" or "ng-add" from their remote API responses. This causes `ng add` to bypass executing schematics on the first run. This fix adds a fallback check immediately after package installation: if the registry did not report `hasSchematics` as `true`, the CLI falls back to resolving and reading the physically installed package's `package.json` on disk as the single source of truth. Additionally, if the local manifest specifies `ng-add.save: false` (but it was persistently installed due to registry omissions), it programmatically prunes the package from `dependencies` or `devDependencies` post-execution, and executes a silent `packageManager.install()` to cleanly remove the physical package files and update the lockfile. Fixes #33060
1 parent d4cc332 commit e27048a

2 files changed

Lines changed: 177 additions & 1 deletion

File tree

packages/angular/cli/src/commands/add/cli.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,22 @@ export default class AddCommandModule
245245
const result = await tasks.run(taskContext);
246246
assert(result.collectionName, 'Collection name should always be available');
247247

248+
let shouldCleanUp = false;
249+
if (!result.hasSchematics && !options.dryRun) {
250+
const packageJsonPath = this.resolvePackageJson(result.collectionName);
251+
if (packageJsonPath && existsSync(packageJsonPath)) {
252+
try {
253+
const localManifest = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'));
254+
if (localManifest.schematics) {
255+
result.hasSchematics = true;
256+
if (localManifest['ng-add']?.save === false) {
257+
shouldCleanUp = true;
258+
}
259+
}
260+
} catch {}
261+
}
262+
}
263+
248264
// Check if the installed package has actual add actions and not just schematic support
249265
if (result.hasSchematics && !options.dryRun) {
250266
const workflow = this.getOrCreateWorkflowForBuilder(result.collectionName);
@@ -299,7 +315,16 @@ export default class AddCommandModule
299315
return;
300316
}
301317

302-
return this.executeSchematic({ ...options, collection: result.collectionName });
318+
const schematicExitCode = await this.executeSchematic({
319+
...options,
320+
collection: result.collectionName,
321+
});
322+
323+
if (shouldCleanUp) {
324+
await this.cleanUpTemporaryDependency(result.collectionName);
325+
}
326+
327+
return schematicExitCode;
303328
} catch (e) {
304329
if (e instanceof CommandError) {
305330
logger.error(e.message);
@@ -560,6 +585,36 @@ export default class AddCommandModule
560585
}
561586
}
562587

588+
private async cleanUpTemporaryDependency(packageName: string): Promise<void> {
589+
try {
590+
this.context.logger.info(`Cleaning up temporary dependency '${packageName}'...`);
591+
592+
// 1. Remove from root package.json
593+
const projectManifest = await this.getProjectManifest();
594+
if (projectManifest) {
595+
if (projectManifest.dependencies) {
596+
delete projectManifest.dependencies[packageName];
597+
}
598+
if (projectManifest.devDependencies) {
599+
delete projectManifest.devDependencies[packageName];
600+
}
601+
602+
await fs.writeFile(
603+
join(this.context.root, 'package.json'),
604+
JSON.stringify(projectManifest, null, 2) + '\n',
605+
);
606+
}
607+
608+
// 2. Silent install pass to prune files from node_modules and update the lockfile
609+
await this.context.packageManager.install({ ignoreScripts: true });
610+
} catch (error) {
611+
this.context.logger.warn(
612+
`Failed to clean up temporary dependency '${packageName}': ` +
613+
`${error instanceof Error ? error.message : error}`,
614+
);
615+
}
616+
}
617+
563618
private async installPackageTask(
564619
context: AddCommandTaskContext,
565620
task: AddCommandTaskWrapper,
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { join } from 'node:path';
2+
import { promises as fs } from 'node:fs';
3+
import { getGlobalVariable } from '../../../utils/env';
4+
import { expectFileToExist, expectFileNotToExist, rimraf } from '../../../utils/fs';
5+
import { getActivePackageManager } from '../../../utils/packages';
6+
import { ng, silentNpm } from '../../../utils/process';
7+
import { mktempd } from '../../../utils/utils';
8+
9+
export default async function () {
10+
const testRegistry = getGlobalVariable('package-registry');
11+
const tmpRoot = getGlobalVariable('tmp-root');
12+
13+
// 1. Create a temp directory for the custom package
14+
const pkgDir = await mktempd('registry-stripped-pkg-', tmpRoot);
15+
16+
try {
17+
// 2. Write the package files
18+
const packageJson = {
19+
name: '@angular-devkit/ng-add-registry-stripped',
20+
version: '1.0.0',
21+
schematics: './collection.json',
22+
'ng-add': {
23+
save: false,
24+
},
25+
};
26+
27+
const collectionJson = {
28+
schematics: {
29+
'ng-add': {
30+
factory: './index.js',
31+
description: 'Add test empty file to your application.',
32+
},
33+
},
34+
};
35+
36+
const indexJs = `
37+
exports.default = function() {
38+
return function(tree) {
39+
tree.create('schematic-executed-successfully.txt', 'Registry Stripped schematic works!');
40+
return tree;
41+
};
42+
};
43+
`;
44+
45+
await fs.writeFile(join(pkgDir, 'package.json'), JSON.stringify(packageJson, null, 2));
46+
await fs.writeFile(join(pkgDir, 'collection.json'), JSON.stringify(collectionJson, null, 2));
47+
await fs.writeFile(join(pkgDir, 'index.js'), indexJs);
48+
49+
// Write a temporary .npmrc with a fake authentication token so that npm publish succeeds
50+
// without needing real credentials or throwing ENEEDAUTH.
51+
const npmrcContent = `
52+
${testRegistry.replace(/^https?:/, '')}/:_authToken=fake-secret
53+
registry=${testRegistry}
54+
`;
55+
await fs.writeFile(join(pkgDir, '.npmrc'), npmrcContent);
56+
57+
// 3. Pack the package
58+
const packResult = await silentNpm(['pack'], { cwd: pkgDir });
59+
const tarballName = packResult.stdout.trim().split('\n').pop() || '';
60+
61+
// 4. Publish the package to the local verdaccio registry
62+
// Verdaccio has publish: $all for @angular-devkit/* so this will succeed
63+
await silentNpm(['publish'], { cwd: pkgDir });
64+
65+
// 5. Strip "schematics" and "ng-add" from Verdaccio's metadata on disk
66+
const verdaccioDbPath = join(
67+
tmpRoot,
68+
'registry',
69+
'storage',
70+
'@angular-devkit',
71+
'ng-add-registry-stripped',
72+
'package.json',
73+
);
74+
75+
const verdaccioDb = JSON.parse(await fs.readFile(verdaccioDbPath, 'utf-8'));
76+
77+
// Strip from the top-level versions list
78+
if (verdaccioDb.versions) {
79+
for (const versionKey of Object.keys(verdaccioDb.versions)) {
80+
delete verdaccioDb.versions[versionKey].schematics;
81+
delete verdaccioDb.versions[versionKey]['ng-add'];
82+
}
83+
}
84+
85+
// Write back the modified metadata
86+
await fs.writeFile(verdaccioDbPath, JSON.stringify(verdaccioDb, null, 2), 'utf-8');
87+
88+
// 6. Execute `ng add` on the registry-stripped package
89+
// Ensure file doesn't already exist
90+
await expectFileNotToExist('schematic-executed-successfully.txt');
91+
92+
await ng('add', '@angular-devkit/ng-add-registry-stripped', '--skip-confirmation');
93+
94+
// 7. Assertions
95+
// A. The schematic executed successfully
96+
await expectFileToExist('schematic-executed-successfully.txt');
97+
98+
// B. The dependency was pruned from package.json since save: false
99+
const rootPackageJson = JSON.parse(await fs.readFile('package.json', 'utf-8'));
100+
const hasDep =
101+
(rootPackageJson.dependencies &&
102+
rootPackageJson.dependencies['@angular-devkit/ng-add-registry-stripped']) ||
103+
(rootPackageJson.devDependencies &&
104+
rootPackageJson.devDependencies['@angular-devkit/ng-add-registry-stripped']);
105+
106+
if (hasDep) {
107+
throw new Error(
108+
'Package @angular-devkit/ng-add-registry-stripped was not cleaned up from package.json dependencies!',
109+
);
110+
}
111+
112+
// C. The dependency was pruned from node_modules physical folder
113+
// Bun intentionally does not prune unreferenced packages from node_modules automatically.
114+
if (getActivePackageManager() !== 'bun') {
115+
await expectFileNotToExist('node_modules/@angular-devkit/ng-add-registry-stripped');
116+
}
117+
} finally {
118+
// Cleanup temp package source folder
119+
await rimraf(pkgDir);
120+
}
121+
}

0 commit comments

Comments
 (0)