Skip to content

Commit 5b3112a

Browse files
feat(schematics): warn during ng add when firebase is installed at more than one version (#3760)
* feat(schematics): shared workspace readers, and name the source of a rejected dependency workspace.ts holds what more than one schematic needs to know about a user's workspace on disk: the lockfile-to-manager table, tolerant JSON readers, the upward walk that finds the directory owning an install, and assertSafeDependencyName, moved from deploy/actions.ts unchanged. Files are parsed with jsonc-parser, as utils.ts already does: Angular tolerates comments in angular.json, and strict parsing silently dropped a commented file's cli.packageManager declaration. The upward walk also stops at bun.lock, bun.lockb and deno.lock, which mark the directory owning an install even though nothing here can query those managers. assertSafeDependencyName gains a required source parameter naming where the value came from. A rejected name is useless to a user who is not told which file to go and edit, and the deploy spec pins that its error still points at angular.json. * feat(schematics): report every installed copy of a package by asking its own package manager A package installed at two versions is two module instances, and they reject each other's objects at runtime with errors naming the caller's code. The reports that reach this repo are diagnosed by telling the reporter to run npm ls firebase. This module runs that question itself. One file per manager: npm, pnpm, yarn 2+ and yarn 1.x each get their command and their reader, since the four output formats share nothing. index.ts identifies the workspace's manager from its own declarations before its lockfiles, and tells the two yarns apart by the lockfile's own header ('# yarn lockfile v1' against an __metadata: block), probing yarn --version only when no lockfile is readable: the binary on PATH and the project disagree in corepack's default state. The query runs through one spawn wrapper (cross-spawn, argument array, no shell) and reports entries, distinct versions and problems without rendering any verdict. Finding nothing is ambiguous, so every path that cannot reach an answer records a problem: silence downstream has to mean checked and fine. Parsing specs run against output captured verbatim from real installs of all four managers. One spec launches npm for real, which is the part captured output cannot cover and the part that fails first on Windows. Known limit, deliberate: in a monorepo the question is answered for the whole workspace while the caller was pointed at one project inside it, so a project resolving one version can be warned about a sibling's. * feat(schematics): warn during ng add when firebase is installed at more than one version Fixes #3754. ng add runs the check after the install task and before the feature prompt, so node_modules is on disk to be asked about and the warning appears whatever the user selects. duplicateWarning.ts owns the verdict the reporter refuses to render. It says one of three things: more than one version found, with each version's dependency chain named so the user can see what pulled the second copy in; the check could not be completed, with the reasons; or nothing, which a reader may take as checked and fine. The whole body is inside one try, because this is a courtesy and an exception here must not abort ng add. Verified end to end against the built tarball: a fresh Angular 21 app with a planted second firebase warns between the install and the prompt, and the same app without the duplicate prints nothing.
1 parent 36bab40 commit 5b3112a

16 files changed

Lines changed: 1789 additions & 20 deletions

src/schematics/deploy/actions.jasmine.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,15 +364,23 @@ describe('deploy input validation (command-injection hardening)', () => {
364364
describe('assertSafeDependencyName', () => {
365365
['rxjs', '@angular/core', '@angular/*', 'some-pkg', 'a.b_c'].forEach((name) => {
366366
it(`allows the valid dependency name "${name}"`, () => {
367-
expect(assertSafeDependencyName(name)).toBe(name);
367+
expect(assertSafeDependencyName(name, 'in a test')).toBe(name);
368368
});
369369
});
370370

371371
['evil; touch /tmp/pwned #', 'a b', '$(id)', '`id`', 'a|b', 'a&b', '-rf', '', 'a>b'].forEach((name) => {
372372
it(`rejects the unsafe dependency name ${JSON.stringify(name)}`, () => {
373-
expect(() => assertSafeDependencyName(name)).toThrowError(/Invalid dependency name/);
373+
expect(() => assertSafeDependencyName(name, 'in a test')).toThrowError(/Invalid dependency name/);
374374
});
375375
});
376+
377+
it('names where the value came from, so the user knows what to go and edit', () => {
378+
/* The context used to be part of the message unconditionally. Now that it is an argument,
379+
* nothing but this asserts that the deploy call site still passes it, and a message reading
380+
* only `Invalid dependency name "--registry=..."` says nothing about angular.json. */
381+
expect(() => findPackageVersion('npm', '--registry=http://example.test'))
382+
.toThrowError(/in angular\.json \(server externalDependencies\)/);
383+
});
376384
});
377385

378386
// These guard the fix at its call sites: the validators above are only useful

src/schematics/deploy/actions.ts

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { satisfies } from 'semver';
1212
import tripleBeam from 'triple-beam';
1313
import * as winston from 'winston';
1414
import { BuildTarget, CloudRunOptions, DeployBuilderSchema, FSHost, FirebaseTools } from '../interfaces';
15+
import { assertSafeDependencyName } from '../workspace.js';
1516
import { DEFAULT_FUNCTION_NAME, defaultFunction, defaultPackage, dockerfile, functionGen2 } from './functions-templates.js';
1617

1718
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -191,23 +192,10 @@ export const assertSupportedPackageManager = (packageManager: string): string =>
191192
return packageManager;
192193
};
193194

194-
// A dependency name comes from `architect.<project>.server.options.externalDependencies`
195-
// in angular.json. Reject anything that is not a plain package specifier so it can
196-
// neither inject shell metacharacters (defence in depth alongside execFileSync) nor
197-
// be parsed as a CLI flag by the package manager (argument injection).
198-
export const assertSafeDependencyName = (name: string): string => {
199-
// Valid npm package names / esbuild external globs never contain whitespace or
200-
// shell metacharacters, and never start with a dash. Reject anything else so the
201-
// value can neither inject a shell command (defence in depth alongside
202-
// execFileSync) nor be parsed as a package-manager flag (argument injection).
203-
if (typeof name !== 'string' || name.length === 0 || name.startsWith('-') ||
204-
/[\s;&|$`(){}<>!\\'"]/.test(name)) {
205-
throw new SchematicsException(
206-
`Invalid dependency name ${JSON.stringify(name)} in angular.json (server externalDependencies).`
207-
);
208-
}
209-
return name;
210-
};
195+
/* Rejects a dependency name that is not a plain package specifier. Here the names come
196+
* from `architect.<project>.server.options.externalDependencies` in angular.json.
197+
* Re-exported so this file's existing importers and specs keep working. */
198+
export { assertSafeDependencyName };
211199

212200
// All shelling out from the deploy builder funnels through this single runner.
213201
// cross-spawn (v7) resolves the platform-appropriate executable and escapes each
@@ -243,7 +231,7 @@ export const findPackageVersion = (packageManager: string, name: string) => {
243231
// unsupported manager or unsafe name throws before anything is ever spawned.
244232
const output = processHost.runPackageBin(assertSupportedPackageManager(packageManager), [
245233
'list',
246-
assertSafeDependencyName(name),
234+
assertSafeDependencyName(name, 'in angular.json (server externalDependencies)'),
247235
]).toString();
248236
const match = output.match(`[^|s]${escapeRegExp(name)}[@| ][^s]+(s.+)?$`);
249237
return match ? match[0].split(new RegExp(`${escapeRegExp(name)}[@| ]`))[1].split(/\s/)[0] : null;

src/schematics/duplicatePackages.jasmine.ts

Lines changed: 691 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/* Turning a report into text. Nothing here decides whether the result is good or bad. */
2+
3+
import { compareBuild as semverCompareBuild, valid as semverValid } from 'semver';
4+
import { executableFor } from './queries.js';
5+
import { maxPrintedEntries } from './types.js';
6+
import type { InstalledCopyReport, InstalledEntry } from './types.js';
7+
8+
/** Orders versions. Anything semver rejects falls back to a string compare. */
9+
const compareVersions = (left: string, right: string): number => {
10+
const leftValid = semverValid(left) !== null;
11+
const rightValid = semverValid(right) !== null;
12+
// Semver-comparable versions must be grouped ahead of the rest before either comparison runs.
13+
if (leftValid !== rightValid) { return leftValid ? -1 : 1; }
14+
if (leftValid) { return semverCompareBuild(left, right); }
15+
// Compare by code unit because localeCompare ordering depends on runtime locale and ICU data.
16+
return left < right ? -1 : left > right ? 1 : 0;
17+
};
18+
19+
/** The distinct versions among a set of entries, ordered numerically. */
20+
export const distinctVersions = (entries: InstalledEntry[]): string[] =>
21+
[...new Set(entries.map(entry => entry.version))].sort(compareVersions);
22+
23+
/**
24+
* Which entries to print when there are more than `maxPrintedEntries` of them.
25+
*
26+
* Keep one entry per distinct version first: cutting the list where it happens to end can leave
27+
* twenty lines all showing the same version and drop the duplicate the reader came for.
28+
*/
29+
const entriesToPrint = (report: InstalledCopyReport): InstalledEntry[] => {
30+
if (report.entries.length <= maxPrintedEntries) { return report.entries; }
31+
const chosen = new Set<InstalledEntry>();
32+
for (const version of report.versions) {
33+
if (chosen.size >= maxPrintedEntries) { break; }
34+
const representative = report.entries.find(entry => entry.version === version);
35+
if (representative) { chosen.add(representative); }
36+
}
37+
for (const entry of report.entries) {
38+
if (chosen.size >= maxPrintedEntries) { break; }
39+
chosen.add(entry);
40+
}
41+
// Printed in the order the manager reported them, not the order they were chosen in.
42+
return report.entries.filter(entry => chosen.has(entry));
43+
};
44+
45+
/**
46+
* Turns a report into printable lines, stating what was found and nothing more. No claim that an
47+
* install is safe or broken, and no advice: a caller decides what, if anything, to say.
48+
*/
49+
export const formatInstalledCopies = (report: InstalledCopyReport): string[] => {
50+
const versionCount = report.versions.length;
51+
/* Don't add a count line when nothing was found. Also, multiple entries aren't necessarily
52+
* multiple installed copies, so don't phrase them as copies. */
53+
const lines = report.entries.length === 0 ? [] : [
54+
`${report.packageName}: ${versionCount} distinct ${versionCount === 1 ? 'version' : 'versions'}, ` +
55+
`reached by ${report.entries.length} dependent ` +
56+
`${report.entries.length === 1 ? 'package' : 'packages'}` +
57+
(report.packageManager ? ` (according to ${executableFor(report.packageManager)})` : ''),
58+
];
59+
const printed = entriesToPrint(report);
60+
for (const entry of printed) {
61+
const via = entry.dependencyPath.length ? entry.dependencyPath.join(' > ') : 'the workspace root';
62+
lines.push(` ${entry.version} via ${via}`);
63+
}
64+
if (printed.length < report.entries.length) {
65+
// With more distinct versions than lines allowed, some versions get no line of their own.
66+
const shown = new Set(printed.map(entry => entry.version));
67+
const unnamed = report.versions.filter(version => !shown.has(version)).length;
68+
lines.push(
69+
` ... and ${report.entries.length - printed.length} more` +
70+
(unnamed ? `, among them ${unnamed} further ${unnamed === 1 ? 'version' : 'versions'} not listed here` : '')
71+
);
72+
}
73+
for (const problem of report.problems) { lines.push(` problem: ${problem}`); }
74+
return lines;
75+
};
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/*
2+
* Asks the workspace's own package manager what it installed for a package, and reports every
3+
* entry it named plus the distinct versions among them.
4+
*
5+
* "Entry" rather than "copy", because a manager lists each place the package was reached from, and
6+
* two entries at the same version may be one directory on disk or two. A package installed at two
7+
* versions is two module instances, and they reject each other's objects at runtime with errors
8+
* naming the caller's code rather than the duplication.
9+
*
10+
* This file owns identifying which manager the workspace uses and running it. Reading its output
11+
* lives in one file per manager beside this one; turning a result into text lives in `format.ts`.
12+
*
13+
* Nothing here renders a verdict. It reports what each manager said. A caller that wants to warn
14+
* writes that rule itself, where it can be read.
15+
*
16+
* Known limit: in a monorepo the question is answered for the whole workspace while `ng add` was
17+
* pointed at one project inside it, so a project resolving exactly one version can be warned about
18+
* a sibling's. Scoping it is per-manager work and neither yarn scopes by directory at all.
19+
*/
20+
21+
import { existsSync, readFileSync } from 'fs';
22+
import { join } from 'path';
23+
import crossSpawn from 'cross-spawn';
24+
import { assertSafeDependencyName, lockfiles, readJson, stringAt, workspaceRootFor } from '../workspace.js';
25+
import { distinctVersions } from './format.js';
26+
import { executableFor, queries } from './queries.js';
27+
import { defaultTimeoutMs, yarnProbeTimeoutMs } from './types.js';
28+
import type { DeclaredManager, InstalledCopyReport, InstalledEntry, PackageManager, QueryOptions, SpawnOutcome } from './types.js';
29+
30+
export type { InstalledCopyReport, InstalledEntry, PackageManager, QueryOptions, SpawnOutcome } from './types.js';
31+
export { distinctVersions, formatInstalledCopies } from './format.js';
32+
export { investigationCommandFor, parseInstalledEntries, queryArgsFor } from './queries.js';
33+
34+
/**
35+
* Decides from `yarn --version` report whether this is yarn 1.x or yarn 2+. Anything unrecognized
36+
* falls back to yarn 2+, whose parser rejects unfamiliar input rather than mis-reading it.
37+
*/
38+
export const yarnFromVersion = (reportedVersion: string): PackageManager => {
39+
/* Handles both `yarn --version` output (always a full version) and a declared specifier, which
40+
* may be `1`, `^1.22.22` or `1.x`. Only the leading major matters, and "10" must not match. */
41+
const major = /^[^\d]*(\d+)/.exec(reportedVersion.trim())?.[1];
42+
return major === '1' ? 'yarn-classic' : 'yarn';
43+
};
44+
45+
/**
46+
* Reads what a project declares about its package manager. The caller only falls back to looking
47+
* for lockfiles when this finds nothing. A declaration is more certain than looking for lockfiles.
48+
*
49+
* Directories must be passed nearest first. An Angular workspace nested inside a monorepo often
50+
* states its manager in its own `angular.json` while the monorepo root never mentions one, so
51+
* reading only the monorepo root would throw away the more specific statement.
52+
*/
53+
const declaredManager = (directories: string[]): DeclaredManager => {
54+
const declarations: string[] = [];
55+
for (const directory of directories) {
56+
declarations.push(stringAt(readJson(join(directory, 'package.json')), 'packageManager'));
57+
declarations.push(stringAt(readJson(join(directory, 'angular.json')), 'cli', 'packageManager'));
58+
}
59+
for (const declaration of declarations) {
60+
// corepack spells it `name@version`, angular.json spells it `name`.
61+
const name = declaration.split('@')[0];
62+
if (!name) { continue; }
63+
if (name === 'npm' || name === 'pnpm') { return { manager: name }; }
64+
if (name === 'yarn') {
65+
const version = declaration.includes('@') ? declaration.split('@')[1] : '';
66+
return version ? { manager: yarnFromVersion(version) } : { yarnOfUnknownVersion: true };
67+
}
68+
// `cli.packageManager` also accepts bun and cnpm, which this module has no query for.
69+
return { unqueryable: name };
70+
}
71+
return {};
72+
};
73+
74+
/** Every spawn this module makes funnels through this one runner. Exported as object for specs. */
75+
export const commandHost = {
76+
/** Runs a command without a shell and returns its output, whether or not it exited cleanly. */
77+
run(command: string, args: string[], cwd: string, timeoutMs: number): SpawnOutcome {
78+
const result = crossSpawn.sync(command, args, {
79+
cwd,
80+
encoding: 'utf8',
81+
// Raised to at least 1: Node reads `timeout: 0` as no timeout at all.
82+
timeout: Math.max(1, timeoutMs),
83+
// The default 1 MiB truncates a large monorepo's listing into an ENOBUFS failure.
84+
maxBuffer: 64 * 1024 * 1024,
85+
// This runs unattended inside `ng add`, where a console window would be a surprise.
86+
windowsHide: true,
87+
});
88+
return {
89+
stdout: result.stdout ?? '',
90+
status: result.status,
91+
failure: result.error?.message,
92+
};
93+
},
94+
};
95+
96+
/** Checks the workspace's declaration first, then its lockfile, and for yarn a version probe. */
97+
export const detectPackageManager = (
98+
workspaceRoot: string,
99+
problems: string[] = [],
100+
): PackageManager | undefined =>
101+
detectFrom(workspaceRoot, workspaceRootFor(workspaceRoot), problems);
102+
103+
/** Detection for a caller that already found the workspace root. */
104+
const detectFrom = (
105+
startDirectory: string,
106+
root: string,
107+
problems: string[],
108+
): PackageManager | undefined => {
109+
/* Declarations are read from the caller's own directory as well as `root`, because a nested
110+
* Angular workspace can name a manager its monorepo root does not. The query itself always
111+
* runs in `root`, where the lockfile and node_modules live. */
112+
const declared = declaredManager(root === startDirectory ? [root] : [startDirectory, root]);
113+
if (declared.manager) { return declared.manager; }
114+
if (declared.unqueryable) {
115+
problems.push(
116+
`the workspace declares ${declared.unqueryable}, and this check was not designed to ` +
117+
`handle ${declared.unqueryable}`
118+
);
119+
return undefined;
120+
}
121+
// A declaration of plain `yarn` names the manager already, leaving only its version to find.
122+
const detected = declared.yarnOfUnknownVersion
123+
? 'yarn'
124+
: lockfiles.find(([, lockfile]) => existsSync(join(root, lockfile)))?.[0];
125+
if (detected !== 'yarn') { return detected; }
126+
127+
/* The lockfile names its own generation: yarn 1 writes "# yarn lockfile v1" in its header,
128+
* yarn 2+ writes an "__metadata:" block. Reading it beats probing the yarn on PATH, which can
129+
* be a different generation than the one that wrote this project. */
130+
try {
131+
const lockfileHead = readFileSync(join(root, 'yarn.lock'), 'utf8').slice(0, 500);
132+
if (lockfileHead.includes('yarn lockfile v1')) { return 'yarn-classic'; }
133+
if (lockfileHead.includes('__metadata:')) { return 'yarn'; }
134+
} catch { /* No readable lockfile, e.g. a bare `yarn` declaration before install: probe. */ }
135+
136+
const assumedYarn2Problem =
137+
'so yarn 2+ was assumed. If this project uses yarn 1.x, nothing would have been found';
138+
const probe = commandHost.run('yarn', ['--version'], root, yarnProbeTimeoutMs);
139+
const reported = probe.stdout.trim();
140+
// Every yarn version contains a digit. Anything else is unreadable however the process exited.
141+
if (!/\d/.test(reported)) {
142+
const cause = probe.failure ?? (probe.status === 0 ? 'it printed no version' : `exit status ${probe.status}`);
143+
problems.push(`could not determine the yarn version (${cause}), ${assumedYarn2Problem}`);
144+
return 'yarn';
145+
}
146+
const whichYarn = yarnFromVersion(reported);
147+
if (probe.failure || probe.status !== 0) {
148+
// Name the yarn actually chosen. A blanket "yarn 2+ was assumed" could contradict it.
149+
problems.push(
150+
`yarn printed version ${reported} but exited with ` +
151+
`${probe.failure ?? `status ${probe.status}`}, so ${whichYarn} was used`
152+
);
153+
}
154+
return whichYarn;
155+
};
156+
157+
/**
158+
* Asks the workspace's package manager where `packageName` is installed and at which versions.
159+
* @param packageName the package to ask about, for example `'firebase'` or `'rxfire'`
160+
* @param workspaceRoot the directory holding the lockfile and `node_modules`
161+
*/
162+
export const findInstalledCopies = (
163+
packageName: string,
164+
workspaceRoot: string,
165+
options: QueryOptions = {},
166+
): InstalledCopyReport => {
167+
const problems: string[] = [];
168+
const timeoutMs = options.timeoutMs ?? defaultTimeoutMs;
169+
assertSafeDependencyName(packageName, 'as the package to report installed copies of');
170+
171+
const root = workspaceRootFor(workspaceRoot);
172+
/* Both directories: a nested Angular workspace can name a package manager its monorepo root
173+
* does not. `root` is passed in because it has already been found. */
174+
const packageManager = options.packageManager
175+
?? detectFrom(workspaceRoot, root, problems);
176+
if (!packageManager) {
177+
if (problems.length === 0) {
178+
problems.push('no package manager could be identified, so nothing was queried');
179+
}
180+
return { packageName, packageManager: undefined, entries: [], versions: [], problems };
181+
}
182+
183+
// run the package manager command to check for duplicate installs
184+
const query = queries[packageManager];
185+
const outcome = commandHost.run(
186+
executableFor(packageManager), query.args(packageName), root, timeoutMs);
187+
188+
if (outcome.failure) {
189+
// A kill on timeout arrives here as an error, not a status.
190+
const timedOut = outcome.failure.includes('ETIMEDOUT');
191+
problems.push(timedOut
192+
? `${executableFor(packageManager)} took longer than the ${timeoutMs / 1000} second timeout`
193+
: `could not run ${executableFor(packageManager)} (${outcome.failure})`);
194+
return { packageName, packageManager, entries: [], versions: [], problems };
195+
}
196+
197+
// The name the user can type. `yarn-classic` is this module's word, not a program.
198+
const executable = executableFor(packageManager);
199+
let entries: InstalledEntry[];
200+
try {
201+
// Parse even on a non-zero exit: these commands report unrelated problems and still answer.
202+
entries = query.parse(outcome.stdout, packageName, problems);
203+
} catch (error) {
204+
const reason = error instanceof Error ? error.message : String(error);
205+
problems.push(`${executable} output could not be parsed (${reason})`);
206+
if (outcome.status !== 0) { problems.push(`${executable} exited with status ${outcome.status}`); }
207+
return { packageName, packageManager, entries: [], versions: [], problems };
208+
}
209+
210+
// Finding nothing is not the same as there being nothing, and silence would imply the latter.
211+
if (entries.length === 0) {
212+
/* The exit status is only reported when it corroborates an empty answer. These commands exit
213+
* non-zero for reasons unrelated to the question, such as an unmet peer elsewhere, and a
214+
* complete answer next to "exited with status 1" reads as a failed check. */
215+
if (outcome.status !== 0) {
216+
problems.push(`${executable} exited with status ${outcome.status}`);
217+
}
218+
if (outcome.stdout.trim().length > 0) {
219+
problems.push(
220+
`${packageName} was not mentioned in ${executable}'s output, so either it is not ` +
221+
'installed or the output is in a shape this cannot read');
222+
} else if (outcome.status !== 0) {
223+
problems.push(
224+
`${executable} exited without printing anything, so nothing could be read about ` +
225+
`${packageName}`);
226+
}
227+
/* Exit 0 with nothing printed is yarn 2+'s well-formed answer for "nothing depends on this
228+
* package". No copies means no duplicate, so nothing is recorded and the caller stays silent. */
229+
}
230+
231+
return { packageName, packageManager, entries, versions: distinctVersions(entries), problems };
232+
};

0 commit comments

Comments
 (0)