Skip to content

Commit b4d8924

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 b4d8924

2 files changed

Lines changed: 162 additions & 1 deletion

File tree

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

Lines changed: 45 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,35 @@ 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+
logger.info(`Cleaning up temporary dependency '${result.collectionName}'...`);
325+
326+
// 1. Remove from root package.json
327+
const projectManifest = await this.getProjectManifest();
328+
if (projectManifest) {
329+
if (projectManifest.dependencies) {
330+
delete projectManifest.dependencies[result.collectionName];
331+
}
332+
if (projectManifest.devDependencies) {
333+
delete projectManifest.devDependencies[result.collectionName];
334+
}
335+
336+
await fs.writeFile(
337+
join(this.context.root, 'package.json'),
338+
JSON.stringify(projectManifest, null, 2),
339+
);
340+
}
341+
342+
// 2. Silent install pass to prune files from node_modules and update the lockfile
343+
await this.context.packageManager.install({ ignoreScripts: true });
344+
}
345+
346+
return schematicExitCode;
303347
} catch (e) {
304348
if (e instanceof CommandError) {
305349
logger.error(e.message);
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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 { ng, silentNpm } from '../../../utils/process';
6+
import { mktempd } from '../../../utils/utils';
7+
8+
export default async function () {
9+
const testRegistry = getGlobalVariable('package-registry');
10+
const tmpRoot = getGlobalVariable('tmp-root');
11+
12+
// 1. Create a temp directory for the custom package
13+
const pkgDir = await mktempd('registry-stripped-pkg-', tmpRoot);
14+
15+
try {
16+
// 2. Write the package files
17+
const packageJson = {
18+
name: '@angular-devkit/ng-add-registry-stripped',
19+
version: '1.0.0',
20+
schematics: './collection.json',
21+
'ng-add': {
22+
save: false,
23+
},
24+
};
25+
26+
const collectionJson = {
27+
schematics: {
28+
'ng-add': {
29+
factory: './index.js',
30+
description: 'Add test empty file to your application.',
31+
},
32+
},
33+
};
34+
35+
const indexJs = `
36+
exports.default = function() {
37+
return function(tree) {
38+
tree.create('schematic-executed-successfully.txt', 'Registry Stripped schematic works!');
39+
return tree;
40+
};
41+
};
42+
`;
43+
44+
await fs.writeFile(join(pkgDir, 'package.json'), JSON.stringify(packageJson, null, 2));
45+
await fs.writeFile(join(pkgDir, 'collection.json'), JSON.stringify(collectionJson, null, 2));
46+
await fs.writeFile(join(pkgDir, 'index.js'), indexJs);
47+
48+
// Write a temporary .npmrc with a fake authentication token so that npm publish succeeds
49+
// without needing real credentials or throwing ENEEDAUTH.
50+
const npmrcContent = `
51+
${testRegistry.replace(/^https?:/, '')}/:_authToken=fake-secret
52+
registry=${testRegistry}
53+
`;
54+
await fs.writeFile(join(pkgDir, '.npmrc'), npmrcContent);
55+
56+
// 3. Pack the package
57+
const packResult = await silentNpm(['pack'], { cwd: pkgDir });
58+
const tarballName = packResult.stdout.trim().split('\n').pop() || '';
59+
60+
// 4. Publish the package to the local verdaccio registry
61+
// Verdaccio has publish: $all for @angular-devkit/* so this will succeed
62+
await silentNpm(['publish'], { cwd: pkgDir });
63+
64+
// 5. Strip "schematics" and "ng-add" from Verdaccio's metadata on disk
65+
const verdaccioDbPath = join(
66+
tmpRoot,
67+
'registry',
68+
'storage',
69+
'@angular-devkit',
70+
'ng-add-registry-stripped',
71+
'package.json',
72+
);
73+
74+
const verdaccioDb = JSON.parse(await fs.readFile(verdaccioDbPath, 'utf-8'));
75+
76+
// Strip from the top-level versions list
77+
if (verdaccioDb.versions) {
78+
for (const versionKey of Object.keys(verdaccioDb.versions)) {
79+
delete verdaccioDb.versions[versionKey].schematics;
80+
delete verdaccioDb.versions[versionKey]['ng-add'];
81+
}
82+
}
83+
84+
// Write back the modified metadata
85+
await fs.writeFile(verdaccioDbPath, JSON.stringify(verdaccioDb, null, 2), 'utf-8');
86+
87+
// 6. Execute `ng add` on the registry-stripped package
88+
// Ensure file doesn't already exist
89+
await expectFileNotToExist('schematic-executed-successfully.txt');
90+
91+
await ng('add', '@angular-devkit/ng-add-registry-stripped', '--skip-confirmation');
92+
93+
// 7. Assertions
94+
// A. The schematic executed successfully
95+
await expectFileToExist('schematic-executed-successfully.txt');
96+
97+
// B. The dependency was pruned from package.json since save: false
98+
const rootPackageJson = JSON.parse(await fs.readFile('package.json', 'utf-8'));
99+
const hasDep =
100+
(rootPackageJson.dependencies &&
101+
rootPackageJson.dependencies['@angular-devkit/ng-add-registry-stripped']) ||
102+
(rootPackageJson.devDependencies &&
103+
rootPackageJson.devDependencies['@angular-devkit/ng-add-registry-stripped']);
104+
105+
if (hasDep) {
106+
throw new Error(
107+
'Package @angular-devkit/ng-add-registry-stripped was not cleaned up from package.json dependencies!',
108+
);
109+
}
110+
111+
// C. The dependency was pruned from node_modules physical folder
112+
await expectFileNotToExist('node_modules/@angular-devkit/ng-add-registry-stripped');
113+
} finally {
114+
// Cleanup temp package source folder
115+
await rimraf(pkgDir);
116+
}
117+
}

0 commit comments

Comments
 (0)