diff --git a/src/schematics/deploy/actions.jasmine.ts b/src/schematics/deploy/actions.jasmine.ts index f3ecdd600..e7ee58c0d 100644 --- a/src/schematics/deploy/actions.jasmine.ts +++ b/src/schematics/deploy/actions.jasmine.ts @@ -364,15 +364,23 @@ describe('deploy input validation (command-injection hardening)', () => { describe('assertSafeDependencyName', () => { ['rxjs', '@angular/core', '@angular/*', 'some-pkg', 'a.b_c'].forEach((name) => { it(`allows the valid dependency name "${name}"`, () => { - expect(assertSafeDependencyName(name)).toBe(name); + expect(assertSafeDependencyName(name, 'in a test')).toBe(name); }); }); ['evil; touch /tmp/pwned #', 'a b', '$(id)', '`id`', 'a|b', 'a&b', '-rf', '', 'a>b'].forEach((name) => { it(`rejects the unsafe dependency name ${JSON.stringify(name)}`, () => { - expect(() => assertSafeDependencyName(name)).toThrowError(/Invalid dependency name/); + expect(() => assertSafeDependencyName(name, 'in a test')).toThrowError(/Invalid dependency name/); }); }); + + it('names where the value came from, so the user knows what to go and edit', () => { + /* The context used to be part of the message unconditionally. Now that it is an argument, + * nothing but this asserts that the deploy call site still passes it, and a message reading + * only `Invalid dependency name "--registry=..."` says nothing about angular.json. */ + expect(() => findPackageVersion('npm', '--registry=http://example.test')) + .toThrowError(/in angular\.json \(server externalDependencies\)/); + }); }); // These guard the fix at its call sites: the validators above are only useful diff --git a/src/schematics/deploy/actions.ts b/src/schematics/deploy/actions.ts index b3f71e86a..841c06808 100644 --- a/src/schematics/deploy/actions.ts +++ b/src/schematics/deploy/actions.ts @@ -12,6 +12,7 @@ import { satisfies } from 'semver'; import tripleBeam from 'triple-beam'; import * as winston from 'winston'; import { BuildTarget, CloudRunOptions, DeployBuilderSchema, FSHost, FirebaseTools } from '../interfaces'; +import { assertSafeDependencyName } from '../workspace.js'; import { DEFAULT_FUNCTION_NAME, defaultFunction, defaultPackage, dockerfile, functionGen2 } from './functions-templates.js'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -191,23 +192,10 @@ export const assertSupportedPackageManager = (packageManager: string): string => return packageManager; }; -// A dependency name comes from `architect..server.options.externalDependencies` -// in angular.json. Reject anything that is not a plain package specifier so it can -// neither inject shell metacharacters (defence in depth alongside execFileSync) nor -// be parsed as a CLI flag by the package manager (argument injection). -export const assertSafeDependencyName = (name: string): string => { - // Valid npm package names / esbuild external globs never contain whitespace or - // shell metacharacters, and never start with a dash. Reject anything else so the - // value can neither inject a shell command (defence in depth alongside - // execFileSync) nor be parsed as a package-manager flag (argument injection). - if (typeof name !== 'string' || name.length === 0 || name.startsWith('-') || - /[\s;&|$`(){}<>!\\'"]/.test(name)) { - throw new SchematicsException( - `Invalid dependency name ${JSON.stringify(name)} in angular.json (server externalDependencies).` - ); - } - return name; -}; +/* Rejects a dependency name that is not a plain package specifier. Here the names come + * from `architect..server.options.externalDependencies` in angular.json. + * Re-exported so this file's existing importers and specs keep working. */ +export { assertSafeDependencyName }; // All shelling out from the deploy builder funnels through this single runner. // cross-spawn (v7) resolves the platform-appropriate executable and escapes each @@ -243,7 +231,7 @@ export const findPackageVersion = (packageManager: string, name: string) => { // unsupported manager or unsafe name throws before anything is ever spawned. const output = processHost.runPackageBin(assertSupportedPackageManager(packageManager), [ 'list', - assertSafeDependencyName(name), + assertSafeDependencyName(name, 'in angular.json (server externalDependencies)'), ]).toString(); const match = output.match(`[^|s]${escapeRegExp(name)}[@| ][^s]+(s.+)?$`); return match ? match[0].split(new RegExp(`${escapeRegExp(name)}[@| ]`))[1].split(/\s/)[0] : null; diff --git a/src/schematics/duplicatePackages.jasmine.ts b/src/schematics/duplicatePackages.jasmine.ts new file mode 100644 index 000000000..8dca1e68e --- /dev/null +++ b/src/schematics/duplicatePackages.jasmine.ts @@ -0,0 +1,691 @@ +/* + * Specs for the installed-copy reporter. + * + * Parsing is tested against output captured verbatim from real npm, pnpm, yarn 2+ and yarn + * 1.x installs of workspaces holding two versions of one package. + * + * One spec at the end runs npm for real, because launching a package manager is the part that + * captured output cannot test and the part that fails first on Windows. Every other case parses + * captured output or stubs the spawn. + */ + +import { lstatSync, mkdirSync, mkdtempSync, readdirSync, rmdirSync, unlinkSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + commandHost, + detectPackageManager, + distinctVersions, + findInstalledCopies, + formatInstalledCopies, + parseInstalledEntries, + queryArgsFor, + yarnFromVersion, +} from './duplicatePackages/index.js'; +import type { InstalledCopyReport, InstalledEntry, PackageManager, SpawnOutcome } from './duplicatePackages/index.js'; +import 'jasmine'; + +/** Captured from `npm ls firebase --all --json`. */ +const npmOutput = "{\"version\": \"1.0.0\", \"name\": \"c2\", \"dependencies\": {\"host-lib\": {\"version\": \"21.0.0-rc.1\", \"resolved\": \"file:/ws/host-lib-21.0.0-rc.1.tgz\", \"overridden\": false, \"dependencies\": {\"firebase\": {\"version\": \"12.18.0\", \"resolved\": \"https://registry.npmjs.org/firebase/-/firebase-12.18.0.tgz\", \"overridden\": false}, \"rxfire\": {\"version\": \"6.2.0\", \"resolved\": \"https://registry.npmjs.org/rxfire/-/rxfire-6.2.0.tgz\", \"overridden\": false, \"dependencies\": {\"firebase\": {\"version\": \"12.10.0\"}}}}}, \"firebase\": {\"version\": \"12.10.0\", \"resolved\": \"https://registry.npmjs.org/firebase/-/firebase-12.10.0.tgz\", \"overridden\": false}}}"; + +/** Captured from `pnpm -r ls ms --depth Infinity --json` in a pnpm workspace. */ +const pnpmWorkspaceOutput = "[{\"name\": \"root\", \"version\": \"1.0.0\", \"path\": \"/ws\", \"private\": true, \"dependencies\": {\"ms\": {\"from\": \"ms\", \"version\": \"2.0.0\", \"resolved\": \"https://registry.npmjs.org/ms/-/ms-2.0.0.tgz\", \"path\": \"/ws/node_modules/.pnpm/ms@2.0.0/node_modules/ms\"}}}, {\"name\": \"web\", \"version\": \"1.0.0\", \"path\": \"/ws/packages/web\", \"private\": false, \"dependencies\": {\"ms\": {\"from\": \"ms\", \"version\": \"2.1.3\", \"resolved\": \"https://registry.npmjs.org/ms/-/ms-2.1.3.tgz\", \"path\": \"/ws/node_modules/.pnpm/ms@2.1.3/node_modules/ms\"}}}]"; + +/** + * Captured verbatim from `pnpm -r ls firebase --depth Infinity --json`. Deliberately NOT + * re-serialized: pnpm prints one array per project separated by a blank line, so the whole + * output is several JSON documents rather than one, and `JSON.parse` on it throws. + */ +const pnpmConcatenatedOutput = "[\n {\n \"name\": \"app\",\n \"version\": \"1.0.0\",\n \"path\": \"/ws\",\n \"private\": true,\n \"dependencies\": {\n \"firebase\": {\n \"from\": \"firebase\",\n \"version\": \"12.10.0\",\n \"resolved\": \"https://registry.npmjs.org/firebase/-/firebase-12.10.0.tgz\",\n \"path\": \"/ws/node_modules/.pnpm/firebase@12.10.0/node_modules/firebase\"\n },\n \"lib\": {\n \"from\": \"lib\",\n \"version\": \"file:lib\",\n \"path\": \"/ws/node_modules/.pnpm/lib@file+lib/node_modules/lib\",\n \"dependencies\": {\n \"firebase\": {\n \"from\": \"firebase\",\n \"version\": \"12.18.0\",\n \"resolved\": \"https://registry.npmjs.org/firebase/-/firebase-12.18.0.tgz\",\n \"path\": \"/ws/node_modules/.pnpm/firebase@12.18.0/node_modules/firebase\"\n }\n }\n }\n }\n }\n]\n\n[\n {\n \"name\": \"lib\",\n \"version\": \"1.0.0\",\n \"path\": \"/ws/lib\",\n \"private\": false\n }\n]"; + +/** Captured from `yarn why firebase --json`, which emits one object per line. */ +const yarnOutput = [ + "{\"value\":\"app@workspace:.\",\"children\":{\"firebase@npm:12.10.0\":{\"locator\":\"firebase@npm:12.10.0\",\"descriptor\":\"firebase@npm:12.10.0\"}}}", + "{\"value\":\"lib@portal:./lib::locator=app%40workspace%3A.\",\"children\":{\"firebase@npm:12.18.0\":{\"locator\":\"firebase@npm:12.18.0\",\"descriptor\":\"firebase@npm:12.18.0\"}}}", +].join('\n'); + +/** Captured from yarn 1.x `yarn list --pattern firebase --json --depth=Infinity`. */ +const yarnClassicOutput = [ + "{\"type\":\"tree\",\"data\":{\"type\":\"list\",\"trees\":[{\"name\":\"@firebase/analytics@0.10.20\",\"children\":[{\"name\":\"@firebase/logger@0.5.0\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0}],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/app@0.14.9\",\"children\":[{\"name\":\"@firebase/logger@0.5.0\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0}],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/app-check@0.11.1\",\"children\":[{\"name\":\"@firebase/logger@0.5.0\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0}],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/app-check-interop-types@0.3.3\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/app-types@0.9.3\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/auth@1.12.1\",\"children\":[{\"name\":\"@firebase/logger@0.5.0\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0}],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/auth-interop-types@0.2.4\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/component@0.7.1\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/database@1.1.1\",\"children\":[{\"name\":\"@firebase/logger@0.5.0\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0}],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/firestore@4.12.0\",\"children\":[{\"name\":\"@firebase/logger@0.5.0\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0}],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/functions@0.13.2\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/installations@0.6.20\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/logger@0.5.2\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/messaging@0.12.24\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/messaging-interop-types@0.2.3\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/performance@0.7.10\",\"children\":[{\"name\":\"@firebase/logger@0.5.0\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0}],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/remote-config@0.8.1\",\"children\":[{\"name\":\"@firebase/logger@0.5.0\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0}],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/storage@0.14.1\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/util@1.14.0\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"firebase@12.10.0\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"lib@1.0.0\",\"children\":[{\"name\":\"firebase@12.18.0\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/ai@2.15.0\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/analytics-compat@0.2.30\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/analytics@0.10.24\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/app-check-compat@0.4.7\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/app-check@0.13.1\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/app-compat@0.5.17\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/app-types@0.9.6\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/app@0.16.1\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/auth-compat@0.6.10\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/auth@1.13.5\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/data-connect@0.7.4\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/database-compat@2.1.7\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/database@1.1.5\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/firestore-compat@0.4.13\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/firestore@4.17.1\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/functions-compat@0.5.0\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/functions@0.14.0\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/installations-compat@0.2.24\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/installations@0.6.24\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/messaging-compat@0.2.29\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/messaging@0.13.2\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/performance-compat@0.2.27\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/performance@0.7.14\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/remote-config-compat@0.2.29\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/remote-config@0.9.2\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/storage-compat@0.4.5\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/storage@0.14.5\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/util@1.15.3\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/analytics-types@0.8.5\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/app-check-interop-types@0.3.5\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/app-check-types@0.5.5\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/auth-interop-types@0.2.6\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/auth-types@0.13.2\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/component@0.7.5\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/database-types@1.0.22\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/firestore-types@3.0.5\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/functions-types@0.6.5\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/installations-types@0.5.5\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/messaging-interop-types@0.2.6\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/performance-types@0.2.5\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/remote-config-types@0.5.2\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/storage-types@0.8.5\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/webchannel-wrapper@1.0.7\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0}],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"@firebase/ai@2.9.0\",\"children\":[{\"name\":\"@firebase/logger@0.5.0\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0}],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/analytics-compat@0.2.26\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/app-check-compat@0.4.1\",\"children\":[{\"name\":\"@firebase/logger@0.5.0\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0}],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/app-compat@0.5.9\",\"children\":[{\"name\":\"@firebase/logger@0.5.0\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0}],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/auth-compat@0.6.3\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/data-connect@0.4.0\",\"children\":[{\"name\":\"@firebase/logger@0.5.0\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0}],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/database-compat@2.1.1\",\"children\":[{\"name\":\"@firebase/logger@0.5.0\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0}],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/firestore-compat@0.4.6\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/functions-compat@0.4.2\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/installations-compat@0.2.20\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/messaging-compat@0.2.24\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/performance-compat@0.2.23\",\"children\":[{\"name\":\"@firebase/logger@0.5.0\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0}],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/remote-config-compat@0.2.22\",\"children\":[{\"name\":\"@firebase/logger@0.5.0\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0}],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/storage-compat@0.4.1\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/webchannel-wrapper@1.0.5\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/analytics-types@0.8.3\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/app-check-types@0.5.3\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/auth-types@0.13.0\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/database-types@1.0.17\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/firestore-types@3.0.3\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/functions-types@0.6.3\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/installations-types@0.5.3\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/performance-types@0.2.3\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/remote-config-types@0.5.0\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0},{\"name\":\"@firebase/storage-types@0.8.3\",\"children\":[],\"hint\":null,\"color\":null,\"depth\":0}]}}", +].join('\n'); + +/** + * Captured from `yarn why firebase --json` on a real yarn 4.10.3 workspace where `@angular/fire` + * pulls in an older firebase. Yarn reports `@angular/fire` twice, plainly and in its virtual form, + * because it declares peer dependencies. Both name the same copy on disk. + */ +const yarnVirtualDependentOutput = [ + "{\"value\":\"@angular/fire@npm:20.0.1\",\"children\":{\"firebase@npm:11.10.0\":{\"locator\":\"firebase@npm:11.10.0\",\"descriptor\":\"firebase@npm:^11.8.0\"}}}", + "{\"value\":\"@angular/fire@virtual:3999943a9b754d09cf6c1ae4cf3cf41db165fbe2ffe8559fc545be213907f3c04c623f0835cfa59036634435752ca17aa3c187b7ce8d8452a65112fe13fd7663#npm:20.0.1\",\"children\":{\"firebase@npm:11.10.0\":{\"locator\":\"firebase@npm:11.10.0\",\"descriptor\":\"firebase@npm:^11.8.0\"}}}", + "{\"value\":\"yarn-berry-angularfire-upgraded@workspace:.\",\"children\":{\"firebase@npm:12.18.0\":{\"locator\":\"firebase@npm:12.18.0\",\"descriptor\":\"firebase@npm:^12.0.0\"}}}", +].join('\n'); + +describe('installed copy reporting', () => { + + let workspaceRoot: string; + + /** Removes a directory tree. Recursive because the fixtures nest, and this repo's @types/node predates fs.rmSync. */ + const removeDirectory = (target: string) => { + for (const entry of readdirSync(target)) { + const entryPath = join(target, entry); + /* lstat, not stat: a symlink to a directory must be unlinked, never followed, or teardown + * deletes the target's contents. The module's domain is symlinked stores. */ + const entryStats = lstatSync(entryPath); + if (entryStats.isDirectory()) { removeDirectory(entryPath); } else { unlinkSync(entryPath); } + } + rmdirSync(target); + }; + + const versionsOf = (packageManager: PackageManager, output: string, name: string) => + distinctVersions(parseInstalledEntries(packageManager, output, name)); + + /** A spawn result with everything defaulted, so a case states only the part it is about. */ + const spawnOutcome = (outcome: Partial = {}): SpawnOutcome => + ({ stdout: '', status: 0, failure: undefined, ...outcome }); + + beforeEach(() => { workspaceRoot = mkdtempSync(join(tmpdir(), 'angularfire-entries-')); }); + afterEach(() => { removeDirectory(workspaceRoot); }); + + describe('parseInstalledEntries', () => { + + it('reads both versions from real npm output', () => { + expect(versionsOf('npm', npmOutput, 'firebase')).toEqual(['12.10.0', '12.18.0']); + }); + + it('reads both versions from a real pnpm workspace', () => { + expect(versionsOf('pnpm', pnpmWorkspaceOutput, 'ms')).toEqual(['2.0.0', '2.1.3']); + }); + + it('reads pnpm output that is several JSON documents rather than one', () => { + // `pnpm -r` prints one array per project. Parsing the whole output at once throws. + expect(() => JSON.parse(pnpmConcatenatedOutput)).toThrow(); + expect(versionsOf('pnpm', pnpmConcatenatedOutput, 'firebase')).toEqual(['12.10.0', '12.18.0']); + }); + + it('reads both versions from real yarn 2+ output', () => { + expect(versionsOf('yarn', yarnOutput, 'firebase')).toEqual(['12.10.0', '12.18.0']); + }); + + it('reads both versions from real yarn 1.x output, whose format is unrelated', () => { + expect(versionsOf('yarn-classic', yarnClassicOutput, 'firebase')).toEqual(['12.10.0', '12.18.0']); + }); + + it('does not read either yarn format with the other yarn parser', () => { + // The formats share nothing, and the wrong parser returns silence rather than an error. + expect(parseInstalledEntries('yarn', yarnClassicOutput, 'firebase')).toEqual([]); + expect(parseInstalledEntries('yarn-classic', yarnOutput, 'firebase')).toEqual([]); + }); + + it('reads the version out of a yarn patch locator rather than a URL fragment', () => { + const patched = JSON.stringify({ + value: 'root@workspace:.', + children: { 'ms@patch:ms@npm%3A2.0.0#./p.patch::version=2.0.0&hash=2ff36f': {} }, + }); + expect(versionsOf('yarn', patched, 'ms')).toEqual(['2.0.0']); + }); + + /* Yarn wraps any package declaring peerDependencies as `virtual:#npm:`. + * rxfire declares peers, so without this the module reports nothing for its own example. */ + it('reads a yarn virtual locator, which every peer-declaring package gets', () => { + const virtualized = JSON.stringify({ + value: 'root@workspace:.', + children: { 'rxfire@virtual:36a01d8083315b8a#npm:6.2.0': {} }, + }); + expect(versionsOf('yarn', virtualized, 'rxfire')).toEqual(['6.2.0']); + }); + + it('names the pnpm workspace member an entry came from', () => { + const entries = parseInstalledEntries('pnpm', pnpmWorkspaceOutput, 'ms'); + /* Without the member name every copy reads as "the workspace root", which is what `-r` was + * added to see past. */ + expect(entries.some(entry => entry.dependencyPath.length > 0)).toBeTrue(); + }); + + it('reads the aliased version, not the aliased name', () => { + const alias = JSON.stringify({ + value: 'root@workspace:.', + children: { 'firebase@npm:firebase-alt@1.2.3': {} }, + }); + expect(versionsOf('yarn', alias, 'firebase')).toEqual(['1.2.3']); + }); + + it('keeps a yarn workspace or portal copy, which is a real separate instance', () => { + const linked = JSON.stringify({ + value: 'root@workspace:.', + children: { 'fb@workspace:packages/fb': {} }, + }); + /* Dropping it would hide exactly the duplicate this module looks for. pnpm keeps its + * equivalent (`file:lib`), so the two managers must not disagree about whether it exists. */ + expect(parseInstalledEntries('yarn', linked, 'fb').length).toBe(1); + }); + + it('says so when it walked output but recognized nothing in it', () => { + const problems: string[] = []; + parseInstalledEntries('pnpm', '[{"name":"a"}]\n\n{ not json }', 'firebase', problems); + expect(problems.length).toBeGreaterThan(0); + }); + + it('does not mistake a package whose name merely starts the same', () => { + const yarnOutput2 = JSON.stringify({ value: 'root@workspace:.', children: { 'firebase-tools@npm:14.0.0': {} } }); + expect(parseInstalledEntries('yarn', yarnOutput2, 'firebase')).toEqual([]); + const classic = JSON.stringify({ type: 'tree', data: { trees: [{ name: 'firebase-tools@14.0.0', children: [] }] } }); + expect(parseInstalledEntries('yarn-classic', classic, 'firebase')).toEqual([]); + }); + + it('records how each entry was reached', () => { + const entries = parseInstalledEntries('npm', npmOutput, 'firebase'); + expect(entries.some(entry => entry.dependencyPath.length > 0)).toBeTrue(); + }); + + it('reports nothing for a package the output does not mention', () => { + expect(parseInstalledEntries('npm', npmOutput, 'not-installed')).toEqual([]); + expect(parseInstalledEntries('pnpm', pnpmWorkspaceOutput, 'not-installed')).toEqual([]); + expect(parseInstalledEntries('yarn', yarnOutput, 'not-installed')).toEqual([]); + expect(parseInstalledEntries('yarn-classic', yarnClassicOutput, 'not-installed')).toEqual([]); + }); + + it('walks a deep tree to the bottom rather than stopping partway', () => { + let output = '{"version":"1.0.0","dependencies":{"firebase":{"version":"9.9.9"}}}'; + // 400 levels is far short of reaching the limits of the stack. + for (let level = 0; level < 400; level++) { + output = `{"version":"1.0.0","dependencies":{"level${level}":${output}}}`; + } + expect(versionsOf('npm', output, 'firebase')).toEqual(['9.9.9']); + }); + + it('skips a malformed line rather than failing the whole read', () => { + expect(versionsOf('yarn', `not json\n${yarnOutput}`, 'firebase').length).toBeGreaterThan(0); + }); + + /* A skipped line is invisible in the result. If it was the one carrying the second version, + * the report reads "1 distinct version" and the duplicate is simply gone. */ + it('says how many yarn lines it had to skip', () => { + const problems: string[] = []; + parseInstalledEntries('yarn', `{ broken\n${yarnOutput}`, 'firebase', problems); + expect(problems.join(' ')).toContain('1 line of the output could not be read'); + }); + + it('says how many yarn 1.x lines it had to skip', () => { + const problems: string[] = []; + parseInstalledEntries('yarn-classic', `not json\n${yarnClassicOutput}`, 'firebase', problems); + expect(problems.join(' ')).toContain('1 line of the output could not be read'); + }); + + it('counts one yarn dependent once even when it is reported on two lines', () => { + const line = '{"value":"app@workspace:.","children":{"firebase@npm:12.10.0":{}}}'; + expect(parseInstalledEntries('yarn', `${line}\n${line}`, 'firebase')) + .toEqual([{ version: '12.10.0', dependencyPath: ['app@workspace:.'] }]); + }); + + // One bad entry must not discard the good ones. Input is constructed: no npm emits this. + it('skips a null tree entry rather than failing the whole read', () => { + const withNullEntry = JSON.stringify({ + dependencies: { unresolved: null, firebase: { version: '12.18.0' } }, + }); + expect(parseInstalledEntries('npm', withNullEntry, 'firebase')) + .toEqual([{ version: '12.18.0', dependencyPath: [] }]); + }); + + }); + + describe('queryArgsFor', () => { + + /* Without -r pnpm reports the root project only, and a duplicate living in a workspace + * member is missed with exit 0 and no warning. */ + it('asks pnpm recursively, so workspace members are included', () => { + expect(queryArgsFor('pnpm', 'firebase')).toContain('-r'); + }); + + it('asks each manager for machine-readable output', () => { + expect(queryArgsFor('npm', 'firebase')).toContain('--json'); + expect(queryArgsFor('pnpm', 'firebase')).toContain('--json'); + expect(queryArgsFor('yarn', 'firebase')).toContain('--json'); + expect(queryArgsFor('yarn-classic', 'firebase')).toContain('--json'); + }); + + it('uses yarn 1.x list rather than why, which reports prose', () => { + expect(queryArgsFor('yarn-classic', 'firebase')[0]).toBe('list'); + expect(queryArgsFor('yarn', 'firebase')[0]).toBe('why'); + }); + + }); + + describe('distinctVersions', () => { + + it('orders versions numerically, not as strings', () => { + const entries = ['12.9.0', '12.10.0', '9.0.0'].map(version => ({ version, dependencyPath: [] })); + expect(distinctVersions(entries)).toEqual(['9.0.0', '12.9.0', '12.10.0']); + }); + + it('orders prereleases before their release, as semver requires', () => { + const entries = ['1.0.0', '1.0.0-rc.2', '1.0.0-rc.1', '0.9.0'] + .map(version => ({ version, dependencyPath: [] })); + expect(distinctVersions(entries)).toEqual(['0.9.0', '1.0.0-rc.1', '1.0.0-rc.2', '1.0.0']); + }); + + /* Comparing some pairs by semver and others as text can order a beats b beats c beats a, + * and Array#sort given that returns a different answer for the same set each time. */ + it('orders the same set identically whatever order it arrives in', () => { + const copiesOf = (versions: string[]) => versions.map(version => ({ version, dependencyPath: [] })); + const first = distinctVersions(copiesOf(['9.0.0', '10.0.0', 'file:lib'])); + expect(distinctVersions(copiesOf(['file:lib', '9.0.0', '10.0.0']))).toEqual(first); + expect(distinctVersions(copiesOf(['10.0.0', 'file:lib', '9.0.0']))).toEqual(first); + }); + + /* semver treats build metadata as equal, so the comparator ties and Set insertion order + * would otherwise decide which of the two comes out first. */ + it('orders versions differing only in build metadata the same way every time', () => { + const copiesOf = (versions: string[]) => versions.map(version => ({ version, dependencyPath: [] })); + expect(distinctVersions(copiesOf(['1.0.0+b', '1.0.0+a']))) + .toEqual(distinctVersions(copiesOf(['1.0.0+a', '1.0.0+b']))); + }); + + // pnpm reports things like `file:lib` for linked packages. + it('keeps a stable order for versions semver cannot parse', () => { + const entries = ['file:lib', '2.0.0'].map(version => ({ version, dependencyPath: [] })); + expect(distinctVersions(entries).length).toBe(2); + }); + + it('collapses repeated versions', () => { + const entries = ['2.8.1', '2.8.1', '2.8.1'].map(version => ({ version, dependencyPath: [] })); + expect(distinctVersions(entries)).toEqual(['2.8.1']); + }); + + }); + + describe('yarnFromVersion', () => { + + it('tells yarn 1.x apart from yarn 2+, which share a lockfile name', () => { + expect(yarnFromVersion('1.22.22')).toBe('yarn-classic'); + // A corepack declaration may be a range or a bare major, not just a full version. + expect(yarnFromVersion('1')).toBe('yarn-classic'); + expect(yarnFromVersion('^1.22.22')).toBe('yarn-classic'); + expect(yarnFromVersion('4.5.3')).toBe('yarn'); + // A future major must not read as classic just because it starts with a 1. + expect(yarnFromVersion('10.0.0')).toBe('yarn'); + // An unreadable answer falls back to the parser that refuses unfamiliar input. + expect(yarnFromVersion('')).toBe('yarn'); + }); + + }); + + describe('detectPackageManager', () => { + + // A migrated workspace often keeps a stale lockfile from the manager it left. + it('prefers what the workspace declares over its lockfiles', () => { + writeFileSync(join(workspaceRoot, 'pnpm-lock.yaml'), ''); + writeFileSync(join(workspaceRoot, 'package.json'), JSON.stringify({ packageManager: 'npm@10.9.8' })); + expect(detectPackageManager(workspaceRoot)).toBe('npm'); + }); + + it('reads the manager the Angular CLI was told to use', () => { + writeFileSync(join(workspaceRoot, 'yarn.lock'), ''); + writeFileSync(join(workspaceRoot, 'angular.json'), JSON.stringify({ cli: { packageManager: 'pnpm' } })); + expect(detectPackageManager(workspaceRoot)).toBe('pnpm'); + }); + + it('reads which yarn from a corepack declaration without probing', () => { + writeFileSync(join(workspaceRoot, 'yarn.lock'), ''); + writeFileSync(join(workspaceRoot, 'package.json'), JSON.stringify({ packageManager: 'yarn@1.22.22' })); + expect(detectPackageManager(workspaceRoot)).toBe('yarn-classic'); + }); + + // The Angular workspace is often apps/web while the lockfile is at the repo root. + it('finds the lockfile at a monorepo root from a nested Angular workspace', () => { + const nested = join(workspaceRoot, 'apps', 'web'); + mkdirSync(nested, { recursive: true }); + writeFileSync(join(workspaceRoot, 'package-lock.json'), ''); + expect(detectPackageManager(nested)).toBe('npm'); + }); + + /* apps/web names its manager in its own angular.json and the repo root never mentions it. + * Resolving to the root before reading declarations discards the more specific answer and + * reports the lockfile's manager with no sign that anything was overlooked. */ + it('reads a nested workspace own declaration, not only the monorepo root', () => { + const nested = join(workspaceRoot, 'apps', 'web'); + mkdirSync(nested, { recursive: true }); + writeFileSync(join(workspaceRoot, 'package-lock.json'), ''); + writeFileSync(join(nested, 'angular.json'), JSON.stringify({ cli: { packageManager: 'pnpm' } })); + expect(detectPackageManager(nested)).toBe('pnpm'); + }); + + /* Unbounded, the walk goes to the filesystem root, where a stray package-lock.json in a + * home directory claims ownership of every project beneath it. The query then runs in a + * directory the caller never named and reports a different tree. */ + it('gives up looking for a monorepo root a few levels up', () => { + const deep = join(workspaceRoot, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'); + mkdirSync(deep, { recursive: true }); + writeFileSync(join(workspaceRoot, 'package-lock.json'), ''); + expect(detectPackageManager(deep)).toBeUndefined(); + }); + + /* A bare `yarn` leaves open only which yarn, not whether it is yarn, and the first declaration + * naming anything wins. */ + it('lets a versionless yarn declaration settle yarn and the probe pick which one', () => { + writeFileSync(join(workspaceRoot, 'yarn.lock'), ''); + writeFileSync(join(workspaceRoot, 'package.json'), JSON.stringify({ packageManager: 'yarn' })); + writeFileSync(join(workspaceRoot, 'angular.json'), JSON.stringify({ cli: { packageManager: 'pnpm' } })); + spyOn(commandHost, 'run').and.returnValue(spawnOutcome({ stdout: '4.5.3\n' })); + expect(detectPackageManager(workspaceRoot)).toBe('yarn'); + }); + + /* cli.packageManager is always a bare name, so if a versionless declaration cannot win, + * the module's headline rule never applies to yarn at all. */ + it('lets a bare yarn declaration beat a lockfile from another manager', () => { + const nested = join(workspaceRoot, 'apps', 'web'); + mkdirSync(nested, { recursive: true }); + writeFileSync(join(workspaceRoot, 'pnpm-lock.yaml'), ''); + writeFileSync(join(nested, 'angular.json'), JSON.stringify({ cli: { packageManager: 'yarn' } })); + spyOn(commandHost, 'run').and.returnValue(spawnOutcome({ stdout: '1.22.22\n' })); + expect(detectPackageManager(nested)).toBe('yarn-classic'); + }); + + /* Directories are read nearest-first so the specific answer wins. Skipping past bun to a + * manager it can query hands a bun user an npm answer with nothing said about it. */ + it('lets a nearer unqueryable declaration beat a farther queryable one', () => { + const nested = join(workspaceRoot, 'apps', 'web'); + mkdirSync(nested, { recursive: true }); + writeFileSync(join(workspaceRoot, 'package.json'), JSON.stringify({ packageManager: 'npm@10.0.0' })); + writeFileSync(join(nested, 'angular.json'), JSON.stringify({ cli: { packageManager: 'bun' } })); + const problems: string[] = []; + expect(detectPackageManager(nested, problems)).toBeUndefined(); + expect(problems.join(' ')).toContain('declares bun'); + }); + + it('identifies npm from its lockfile alone', () => { + writeFileSync(join(workspaceRoot, 'package-lock.json'), ''); + expect(detectPackageManager(workspaceRoot)).toBe('npm'); + }); + + it('identifies pnpm from its lockfile alone', () => { + writeFileSync(join(workspaceRoot, 'pnpm-lock.yaml'), ''); + expect(detectPackageManager(workspaceRoot)).toBe('pnpm'); + }); + + /* Nested past the walk bound so the answer cannot turn on what else happens to be in the + * temp directory's ancestry: a lockfile left in /tmp by another job would otherwise decide. */ + it('returns undefined when nothing identifies a manager', () => { + const isolated = join(workspaceRoot, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'); + mkdirSync(isolated, { recursive: true }); + expect(detectPackageManager(isolated)).toBeUndefined(); + }); + + /* cli.packageManager also accepts bun and cnpm, which deploy/actions.ts supports. Falling + * through to the lockfile would answer with whichever manager a migration left behind. */ + it('says so when the declared manager is one it cannot ask', () => { + writeFileSync(join(workspaceRoot, 'package-lock.json'), ''); + writeFileSync(join(workspaceRoot, 'angular.json'), JSON.stringify({ cli: { packageManager: 'bun' } })); + const problems: string[] = []; + expect(detectPackageManager(workspaceRoot, problems)).toBeUndefined(); + expect(problems.join(' ')).toContain('declares bun'); + }); + + /* A corepack prompt goes to stderr and yarn still exits 0. Assuming yarn 2+ silently then + * hands a yarn 1.x workspace the parser that reports nothing for it. */ + it('says so when the yarn probe exits cleanly but prints no version', () => { + writeFileSync(join(workspaceRoot, 'yarn.lock'), ''); + spyOn(commandHost, 'run').and.returnValue(spawnOutcome({ status: 0 })); + const problems: string[] = []; + expect(detectPackageManager(workspaceRoot, problems)).toBe('yarn'); + expect(problems.join(' ')).toContain('printed no version'); + }); + + // The problem must not claim yarn 2+ was assumed while yarn-classic is what gets returned. + it('uses the version yarn printed even when it exited badly, and says which', () => { + writeFileSync(join(workspaceRoot, 'yarn.lock'), ''); + spyOn(commandHost, 'run').and.returnValue( + spawnOutcome({ stdout: '1.22.22\n', status: 1 })); + const problems: string[] = []; + expect(detectPackageManager(workspaceRoot, problems)).toBe('yarn-classic'); + expect(problems.join(' ')).toContain('yarn-classic was used'); + expect(problems.join(' ')).not.toContain('yarn 2+ was assumed'); + }); + + }); + + describe('findInstalledCopies', () => { + + it('rejects a package name that could be read as a command or a flag', () => { + // The name reaches a spawned process argv, so this is the boundary that keeps it inert. + for (const unsafe of ['firebase; echo hi', 'firebase & calc', '--version', 'fire base', '']) { + expect(() => findInstalledCopies(unsafe, workspaceRoot, { packageManager: 'npm' })).toThrow(); + } + }); + + // A rejected name is useless to someone who is not told which value to go and change. + it('says what the rejected name was supplied as', () => { + expect(() => findInstalledCopies('fire base', workspaceRoot, { packageManager: 'npm' })) + .toThrowError(/as the package to report installed copies of/); + }); + + /* Nested past the walk bound for the same reason its detectPackageManager sibling is: a + * stray lockfile in the temp directory's ancestry would otherwise make this really spawn. */ + it('queries nothing and says so when no manager can be identified', () => { + const isolated = join(workspaceRoot, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'); + mkdirSync(isolated, { recursive: true }); + const report = findInstalledCopies('firebase', isolated); + expect(report.packageManager).toBeUndefined(); + expect(report.entries).toEqual([]); + expect(report.problems.length).toBeGreaterThan(0); + }); + + /* spawnSync reports a kill on timeout as an error, not a status, so without a branch here + * every real timeout reads as the package manager being missing or broken. */ + it('says the manager ran out of time rather than that it could not be run', () => { + spyOn(commandHost, 'run').and.returnValue( + spawnOutcome({ status: null, failure: 'spawnSync npm ETIMEDOUT' })); + const report = findInstalledCopies('firebase', workspaceRoot, { packageManager: 'npm' }); + expect(report.problems.join(' ')).toContain('took longer than the 30 second timeout'); + expect(report.problems.join(' ')).not.toContain('could not run npm'); + }); + + // A tree deep enough to use up the call stack must warn, not fall silent. + it('reports a tree too deep to walk instead of falling silent', () => { + let output = '{"version":"1.0.0"}'; + for (let level = 0; level < 20_000; level++) { + output = `{"version":"1.0.0","dependencies":{"d":${output}}}`; + } + spyOn(commandHost, 'run').and.returnValue(spawnOutcome({ stdout: output, status: 0 })); + const report = findInstalledCopies('firebase', workspaceRoot, { packageManager: 'npm' }); + expect(report.problems.join(' ')).toContain('could not be parsed'); + }); + + /* Verified against the real spawnSync: a command killed for exceeding maxBuffer is killed + * exactly as a timeout is, differing only in the message. */ + it('does not call a buffer overflow a timeout', () => { + spyOn(commandHost, 'run').and.returnValue( + spawnOutcome({ status: null, failure: 'spawnSync npm ENOBUFS' })); + const report = findInstalledCopies('firebase', workspaceRoot, { packageManager: 'npm' }); + expect(report.problems.join(' ')).toContain('could not run npm (spawnSync npm ENOBUFS)'); + expect(report.problems.join(' ')).not.toContain('took longer than'); + }); + + it('reports a manager that could not be run', () => { + spyOn(commandHost, 'run').and.returnValue( + spawnOutcome({ status: null, failure: 'spawnSync npm ENOENT' })); + const report = findInstalledCopies('firebase', workspaceRoot, { packageManager: 'npm' }); + expect(report.entries).toEqual([]); + expect(report.problems.join(' ')).toContain('could not run npm (spawnSync npm ENOENT)'); + }); + + + it('separates output it could not read from a package that is absent', () => { + spyOn(commandHost, 'run').and.returnValue( + spawnOutcome({ stdout: 'some format nobody here parses' })); + const report = findInstalledCopies('firebase', workspaceRoot, { packageManager: 'yarn' }); + expect(report.entries).toEqual([]); + expect(report.problems.join(' ')).toContain("was not mentioned in yarn's output"); + expect(report.problems.join(' ')).not.toContain('exited without printing anything'); + }); + + it('reports unparseable output rather than calling the tree clean', () => { + spyOn(commandHost, 'run').and.returnValue( + spawnOutcome({ stdout: '{ this is not json' })); + const report = findInstalledCopies('firebase', workspaceRoot, { packageManager: 'npm' }); + expect(report.entries).toEqual([]); + expect(report.problems.join(' ')).toContain('could not be parsed'); + }); + + it('treats a clean exit with no output as no copies, not as a failed check', () => { + /* Exit 0 with nothing printed is yarn 2+'s well-formed answer for "nothing depends on this + * package". No copies means no duplicate, so recording a problem here made a correct "no" + * read as a failed check. The stale-lockfile case this used to flag now goes silent, a + * tradeoff taken deliberately: the shipping caller asks about firebase right after ng add + * installed it, so its lockfile entry is fresh. */ + spyOn(commandHost, 'run').and.returnValue(spawnOutcome({ stdout: '' })); + const report = findInstalledCopies('firebase', workspaceRoot, { packageManager: 'yarn' }); + expect(report.entries).toEqual([]); + expect(report.problems).toEqual([]); + // And it must not assert absence: "is not installed" may only appear as one of two options. + expect(report.problems.join(' ')).not.toContain('firebase is not installed,'); + }); + + ['npm', 'pnpm', 'yarn', 'yarn-classic'].forEach(manager => { + it(`still flags an empty answer from ${manager} when it exited non-zero`, () => { + /* The rule is about finding nothing, not about which manager found nothing. Narrowing it + * to one manager lets the other two silently go back to reporting all-clear. */ + spyOn(commandHost, 'run').and.returnValue(spawnOutcome({ stdout: '', status: 1 })); + const report = findInstalledCopies('firebase', workspaceRoot, + { packageManager: manager as PackageManager }); + expect(report.entries).toEqual([]); + expect(report.problems.length).toBeGreaterThan(0); + }); + }); + + it('reads a real answer through commandHost, not a stub', () => { + spyOn(commandHost, 'run').and.returnValue( + spawnOutcome({ stdout: npmOutput })); + const report = findInstalledCopies('firebase', workspaceRoot, { packageManager: 'npm' }); + expect(report.versions).toEqual(['12.10.0', '12.18.0']); + expect(report.problems).toEqual([]); + }); + + }); + + describe('formatInstalledCopies', () => { + + const reportFrom = (entries: InstalledEntry[], problems: string[] = []): InstalledCopyReport => ({ + packageName: 'firebase', packageManager: 'npm', entries, + versions: distinctVersions(entries), problems, + }); + + it('counts a peer-declaring dependent once, not once per virtual form', () => { + /* Real yarn reports @angular/fire twice for one copy, plainly and as + * name@virtual:#. Rendered as they arrive, one copy became two lines, the + * second 130 characters of hash, and the header read "3 dependents" for two. */ + const entries = parseInstalledEntries('yarn', yarnVirtualDependentOutput, 'firebase'); + expect(entries).toEqual([ + { version: '11.10.0', dependencyPath: ['@angular/fire@npm:20.0.1'] }, + { version: '12.18.0', dependencyPath: ['yarn-berry-angularfire-upgraded@workspace:.'] }, + ]); + const text = formatInstalledCopies(reportFrom(entries)).join('\n'); + expect(text).toContain('reached by 2 dependent packages'); + expect(text).not.toContain('virtual:'); + }); + + it('leads with distinct versions and calls the rest dependents, not entries', () => { + const entries = parseInstalledEntries('npm', npmOutput, 'firebase'); + const text = formatInstalledCopies(reportFrom(entries)).join('\n'); + expect(text).toContain('distinct versions'); + /* One version reached by many dependent packages is usually one directory. Calling those + * "entries" reads as that many entries. */ + expect(text).toContain('dependent packages'); + expect(text).toContain('12.10.0'); + expect(text).toContain('12.18.0'); + }); + + it('renders no verdict and no advice', () => { + const text = formatInstalledCopies(reportFrom(parseInstalledEntries('npm', npmOutput, 'firebase'))).join('\n'); + expect(text).not.toContain('runtime'); + expect(text).not.toContain('reinstall'); + expect(text).not.toContain('safe'); + }); + + it('caps the rendered list rather than printing one line per dependent', () => { + const entries = Array.from({ length: 40 }, (_, index) => ({ version: '1.0.0', dependencyPath: [`p${index}`] })); + const text = formatInstalledCopies(reportFrom(entries)); + expect(text.length).toBeLessThan(30); + expect(text.join('\n')).toContain('more'); + }); + + it('keeps a line for every distinct version when the list is capped', () => { + /* The second version is the whole point of the report and it can be the last thing the + * manager listed. Cutting the list in arrival order leaves a header reading "2 distinct + * versions" above twenty lines that all show the first one. */ + const entries = [ + ...Array.from({ length: 40 }, (_, index) => ({ version: '1.0.0', dependencyPath: [`p${index}`] })), + { version: '2.0.0', dependencyPath: ['late'] }, + ]; + const text = formatInstalledCopies(reportFrom(entries)); + expect(text.length).toBeLessThan(30); + expect(text.join('\n')).toContain('2.0.0 via late'); + }); + + it('says how many versions it could not name when they outnumber the cap', () => { + /* Past the cap not even one line per version fits, and the header count would otherwise be + * the only trace of the ones left out. */ + const entries = Array.from({ length: 25 }, (_, index) => ({ + version: `1.0.${index}`, dependencyPath: [`p${index}`], + })); + const text = formatInstalledCopies(reportFrom(entries)).join('\n'); + expect(text).toContain('25 distinct versions'); + expect(text).toContain('5 further versions not listed here'); + }); + + it('surfaces problems so a short answer is never mistaken for a complete one', () => { + const text = formatInstalledCopies(reportFrom([], ['npm reported a problem with the tree: invalid'])).join('\n'); + expect(text).toContain('problem: npm reported a problem'); + }); + + }); + + describe('starting a package manager for real', () => { + + /* + * The rest of this file reads captured output, which cannot show whether the spawn itself + * works. On Windows npm, yarn and pnpm are installed as `.cmd` shims that + * `child_process.execFile` cannot launch, which is the entire reason this code uses + * cross-spawn. Only running one can show that it holds, and CI runs this file on + * windows-latest as well as ubuntu and macos. + * + * npm only: it ships with Node, so it is present wherever these specs run. yarn and pnpm are + * not, and a spec needing them would be testing the runner rather than the code. + */ + + let realTree: string; + let realTreeTempRoot: string; + + beforeEach(() => { + /* Nested past the walk bound, like the detection specs above: findInstalledCopies resolves + * the workspace root upward, and a lockfile left in /tmp by another job would otherwise + * relocate the query. */ + realTreeTempRoot = mkdtempSync(join(tmpdir(), 'angularfire-spawn-')); + realTree = join(realTreeTempRoot, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'); + const nested = join(realTree, 'node_modules', 'host', 'node_modules', 'alpha'); + mkdirSync(nested, { recursive: true }); + mkdirSync(join(realTree, 'node_modules', 'alpha'), { recursive: true }); + /* `npm ls` reads node_modules and the package.json files in it, so a tree can be written by + * hand. Installing for real would need a network and would make this the slowest spec here. */ + const write = (directory: string, manifest: Record) => + writeFileSync(join(directory, 'package.json'), JSON.stringify(manifest)); + write(realTree, { name: 'root', version: '1.0.0', dependencies: { alpha: '1.0.0', host: '1.0.0' } }); + write(join(realTree, 'node_modules', 'alpha'), { name: 'alpha', version: '1.0.0' }); + write(join(realTree, 'node_modules', 'host'), { name: 'host', version: '1.0.0', dependencies: { alpha: '2.0.0' } }); + write(nested, { name: 'alpha', version: '2.0.0' }); + }); + + afterEach(() => removeDirectory(realTreeTempRoot)); + + it('finds both versions by actually running npm', () => { + /* `packageManager` is passed rather than detected: identifying it is pure filesystem work + * that the specs above already cover on every operating system. What is untested anywhere + * else, and what fails first on Windows, is launching the manager. */ + const report = findInstalledCopies('alpha', realTree, { packageManager: 'npm' }); + expect(report.problems).toEqual([]); + expect(report.versions).toEqual(['1.0.0', '2.0.0']); + expect(report.entries.length).toBe(2); + }, 60_000); + + }); + +}); diff --git a/src/schematics/duplicatePackages/format.ts b/src/schematics/duplicatePackages/format.ts new file mode 100644 index 000000000..1482969f7 --- /dev/null +++ b/src/schematics/duplicatePackages/format.ts @@ -0,0 +1,75 @@ +/* Turning a report into text. Nothing here decides whether the result is good or bad. */ + +import { compareBuild as semverCompareBuild, valid as semverValid } from 'semver'; +import { executableFor } from './queries.js'; +import { maxPrintedEntries } from './types.js'; +import type { InstalledCopyReport, InstalledEntry } from './types.js'; + +/** Orders versions. Anything semver rejects falls back to a string compare. */ +const compareVersions = (left: string, right: string): number => { + const leftValid = semverValid(left) !== null; + const rightValid = semverValid(right) !== null; + // Semver-comparable versions must be grouped ahead of the rest before either comparison runs. + if (leftValid !== rightValid) { return leftValid ? -1 : 1; } + if (leftValid) { return semverCompareBuild(left, right); } + // Compare by code unit because localeCompare ordering depends on runtime locale and ICU data. + return left < right ? -1 : left > right ? 1 : 0; +}; + +/** The distinct versions among a set of entries, ordered numerically. */ +export const distinctVersions = (entries: InstalledEntry[]): string[] => + [...new Set(entries.map(entry => entry.version))].sort(compareVersions); + +/** + * Which entries to print when there are more than `maxPrintedEntries` of them. + * + * Keep one entry per distinct version first: cutting the list where it happens to end can leave + * twenty lines all showing the same version and drop the duplicate the reader came for. + */ +const entriesToPrint = (report: InstalledCopyReport): InstalledEntry[] => { + if (report.entries.length <= maxPrintedEntries) { return report.entries; } + const chosen = new Set(); + for (const version of report.versions) { + if (chosen.size >= maxPrintedEntries) { break; } + const representative = report.entries.find(entry => entry.version === version); + if (representative) { chosen.add(representative); } + } + for (const entry of report.entries) { + if (chosen.size >= maxPrintedEntries) { break; } + chosen.add(entry); + } + // Printed in the order the manager reported them, not the order they were chosen in. + return report.entries.filter(entry => chosen.has(entry)); +}; + +/** + * Turns a report into printable lines, stating what was found and nothing more. No claim that an + * install is safe or broken, and no advice: a caller decides what, if anything, to say. + */ +export const formatInstalledCopies = (report: InstalledCopyReport): string[] => { + const versionCount = report.versions.length; + /* Don't add a count line when nothing was found. Also, multiple entries aren't necessarily + * multiple installed copies, so don't phrase them as copies. */ + const lines = report.entries.length === 0 ? [] : [ + `${report.packageName}: ${versionCount} distinct ${versionCount === 1 ? 'version' : 'versions'}, ` + + `reached by ${report.entries.length} dependent ` + + `${report.entries.length === 1 ? 'package' : 'packages'}` + + (report.packageManager ? ` (according to ${executableFor(report.packageManager)})` : ''), + ]; + const printed = entriesToPrint(report); + for (const entry of printed) { + const via = entry.dependencyPath.length ? entry.dependencyPath.join(' > ') : 'the workspace root'; + lines.push(` ${entry.version} via ${via}`); + } + if (printed.length < report.entries.length) { + // With more distinct versions than lines allowed, some versions get no line of their own. + const shown = new Set(printed.map(entry => entry.version)); + const unnamed = report.versions.filter(version => !shown.has(version)).length; + lines.push( + ` ... and ${report.entries.length - printed.length} more` + + (unnamed ? `, among them ${unnamed} further ${unnamed === 1 ? 'version' : 'versions'} not listed here` : '') + ); + } + for (const problem of report.problems) { lines.push(` problem: ${problem}`); } + return lines; +}; diff --git a/src/schematics/duplicatePackages/index.ts b/src/schematics/duplicatePackages/index.ts new file mode 100644 index 000000000..9811bf3de --- /dev/null +++ b/src/schematics/duplicatePackages/index.ts @@ -0,0 +1,232 @@ +/* + * Asks the workspace's own package manager what it installed for a package, and reports every + * entry it named plus the distinct versions among them. + * + * "Entry" rather than "copy", because a manager lists each place the package was reached from, and + * two entries at the same version may be one directory on disk or two. 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 rather than the duplication. + * + * This file owns identifying which manager the workspace uses and running it. Reading its output + * lives in one file per manager beside this one; turning a result into text lives in `format.ts`. + * + * Nothing here renders a verdict. It reports what each manager said. A caller that wants to warn + * writes that rule itself, where it can be read. + * + * Known limit: in a monorepo the question is answered for the whole workspace while `ng add` was + * pointed at one project inside it, so a project resolving exactly one version can be warned about + * a sibling's. Scoping it is per-manager work and neither yarn scopes by directory at all. + */ + +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import crossSpawn from 'cross-spawn'; +import { assertSafeDependencyName, lockfiles, readJson, stringAt, workspaceRootFor } from '../workspace.js'; +import { distinctVersions } from './format.js'; +import { executableFor, queries } from './queries.js'; +import { defaultTimeoutMs, yarnProbeTimeoutMs } from './types.js'; +import type { DeclaredManager, InstalledCopyReport, InstalledEntry, PackageManager, QueryOptions, SpawnOutcome } from './types.js'; + +export type { InstalledCopyReport, InstalledEntry, PackageManager, QueryOptions, SpawnOutcome } from './types.js'; +export { distinctVersions, formatInstalledCopies } from './format.js'; +export { investigationCommandFor, parseInstalledEntries, queryArgsFor } from './queries.js'; + +/** + * Decides from `yarn --version` report whether this is yarn 1.x or yarn 2+. Anything unrecognized + * falls back to yarn 2+, whose parser rejects unfamiliar input rather than mis-reading it. + */ +export const yarnFromVersion = (reportedVersion: string): PackageManager => { + /* Handles both `yarn --version` output (always a full version) and a declared specifier, which + * may be `1`, `^1.22.22` or `1.x`. Only the leading major matters, and "10" must not match. */ + const major = /^[^\d]*(\d+)/.exec(reportedVersion.trim())?.[1]; + return major === '1' ? 'yarn-classic' : 'yarn'; +}; + +/** + * Reads what a project declares about its package manager. The caller only falls back to looking + * for lockfiles when this finds nothing. A declaration is more certain than looking for lockfiles. + * + * Directories must be passed nearest first. An Angular workspace nested inside a monorepo often + * states its manager in its own `angular.json` while the monorepo root never mentions one, so + * reading only the monorepo root would throw away the more specific statement. + */ +const declaredManager = (directories: string[]): DeclaredManager => { + const declarations: string[] = []; + for (const directory of directories) { + declarations.push(stringAt(readJson(join(directory, 'package.json')), 'packageManager')); + declarations.push(stringAt(readJson(join(directory, 'angular.json')), 'cli', 'packageManager')); + } + for (const declaration of declarations) { + // corepack spells it `name@version`, angular.json spells it `name`. + const name = declaration.split('@')[0]; + if (!name) { continue; } + if (name === 'npm' || name === 'pnpm') { return { manager: name }; } + if (name === 'yarn') { + const version = declaration.includes('@') ? declaration.split('@')[1] : ''; + return version ? { manager: yarnFromVersion(version) } : { yarnOfUnknownVersion: true }; + } + // `cli.packageManager` also accepts bun and cnpm, which this module has no query for. + return { unqueryable: name }; + } + return {}; +}; + +/** Every spawn this module makes funnels through this one runner. Exported as object for specs. */ +export const commandHost = { + /** Runs a command without a shell and returns its output, whether or not it exited cleanly. */ + run(command: string, args: string[], cwd: string, timeoutMs: number): SpawnOutcome { + const result = crossSpawn.sync(command, args, { + cwd, + encoding: 'utf8', + // Raised to at least 1: Node reads `timeout: 0` as no timeout at all. + timeout: Math.max(1, timeoutMs), + // The default 1 MiB truncates a large monorepo's listing into an ENOBUFS failure. + maxBuffer: 64 * 1024 * 1024, + // This runs unattended inside `ng add`, where a console window would be a surprise. + windowsHide: true, + }); + return { + stdout: result.stdout ?? '', + status: result.status, + failure: result.error?.message, + }; + }, +}; + +/** Checks the workspace's declaration first, then its lockfile, and for yarn a version probe. */ +export const detectPackageManager = ( + workspaceRoot: string, + problems: string[] = [], +): PackageManager | undefined => + detectFrom(workspaceRoot, workspaceRootFor(workspaceRoot), problems); + +/** Detection for a caller that already found the workspace root. */ +const detectFrom = ( + startDirectory: string, + root: string, + problems: string[], +): PackageManager | undefined => { + /* Declarations are read from the caller's own directory as well as `root`, because a nested + * Angular workspace can name a manager its monorepo root does not. The query itself always + * runs in `root`, where the lockfile and node_modules live. */ + const declared = declaredManager(root === startDirectory ? [root] : [startDirectory, root]); + if (declared.manager) { return declared.manager; } + if (declared.unqueryable) { + problems.push( + `the workspace declares ${declared.unqueryable}, and this check was not designed to ` + + `handle ${declared.unqueryable}` + ); + return undefined; + } + // A declaration of plain `yarn` names the manager already, leaving only its version to find. + const detected = declared.yarnOfUnknownVersion + ? 'yarn' + : lockfiles.find(([, lockfile]) => existsSync(join(root, lockfile)))?.[0]; + if (detected !== 'yarn') { return detected; } + + /* The lockfile names its own generation: yarn 1 writes "# yarn lockfile v1" in its header, + * yarn 2+ writes an "__metadata:" block. Reading it beats probing the yarn on PATH, which can + * be a different generation than the one that wrote this project. */ + try { + const lockfileHead = readFileSync(join(root, 'yarn.lock'), 'utf8').slice(0, 500); + if (lockfileHead.includes('yarn lockfile v1')) { return 'yarn-classic'; } + if (lockfileHead.includes('__metadata:')) { return 'yarn'; } + } catch { /* No readable lockfile, e.g. a bare `yarn` declaration before install: probe. */ } + + const assumedYarn2Problem = + 'so yarn 2+ was assumed. If this project uses yarn 1.x, nothing would have been found'; + const probe = commandHost.run('yarn', ['--version'], root, yarnProbeTimeoutMs); + const reported = probe.stdout.trim(); + // Every yarn version contains a digit. Anything else is unreadable however the process exited. + if (!/\d/.test(reported)) { + const cause = probe.failure ?? (probe.status === 0 ? 'it printed no version' : `exit status ${probe.status}`); + problems.push(`could not determine the yarn version (${cause}), ${assumedYarn2Problem}`); + return 'yarn'; + } + const whichYarn = yarnFromVersion(reported); + if (probe.failure || probe.status !== 0) { + // Name the yarn actually chosen. A blanket "yarn 2+ was assumed" could contradict it. + problems.push( + `yarn printed version ${reported} but exited with ` + + `${probe.failure ?? `status ${probe.status}`}, so ${whichYarn} was used` + ); + } + return whichYarn; +}; + +/** + * Asks the workspace's package manager where `packageName` is installed and at which versions. + * @param packageName the package to ask about, for example `'firebase'` or `'rxfire'` + * @param workspaceRoot the directory holding the lockfile and `node_modules` + */ +export const findInstalledCopies = ( + packageName: string, + workspaceRoot: string, + options: QueryOptions = {}, +): InstalledCopyReport => { + const problems: string[] = []; + const timeoutMs = options.timeoutMs ?? defaultTimeoutMs; + assertSafeDependencyName(packageName, 'as the package to report installed copies of'); + + const root = workspaceRootFor(workspaceRoot); + /* Both directories: a nested Angular workspace can name a package manager its monorepo root + * does not. `root` is passed in because it has already been found. */ + const packageManager = options.packageManager + ?? detectFrom(workspaceRoot, root, problems); + if (!packageManager) { + if (problems.length === 0) { + problems.push('no package manager could be identified, so nothing was queried'); + } + return { packageName, packageManager: undefined, entries: [], versions: [], problems }; + } + + // run the package manager command to check for duplicate installs + const query = queries[packageManager]; + const outcome = commandHost.run( + executableFor(packageManager), query.args(packageName), root, timeoutMs); + + if (outcome.failure) { + // A kill on timeout arrives here as an error, not a status. + const timedOut = outcome.failure.includes('ETIMEDOUT'); + problems.push(timedOut + ? `${executableFor(packageManager)} took longer than the ${timeoutMs / 1000} second timeout` + : `could not run ${executableFor(packageManager)} (${outcome.failure})`); + return { packageName, packageManager, entries: [], versions: [], problems }; + } + + // The name the user can type. `yarn-classic` is this module's word, not a program. + const executable = executableFor(packageManager); + let entries: InstalledEntry[]; + try { + // Parse even on a non-zero exit: these commands report unrelated problems and still answer. + entries = query.parse(outcome.stdout, packageName, problems); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + problems.push(`${executable} output could not be parsed (${reason})`); + if (outcome.status !== 0) { problems.push(`${executable} exited with status ${outcome.status}`); } + return { packageName, packageManager, entries: [], versions: [], problems }; + } + + // Finding nothing is not the same as there being nothing, and silence would imply the latter. + if (entries.length === 0) { + /* The exit status is only reported when it corroborates an empty answer. These commands exit + * non-zero for reasons unrelated to the question, such as an unmet peer elsewhere, and a + * complete answer next to "exited with status 1" reads as a failed check. */ + if (outcome.status !== 0) { + problems.push(`${executable} exited with status ${outcome.status}`); + } + if (outcome.stdout.trim().length > 0) { + problems.push( + `${packageName} was not mentioned in ${executable}'s output, so either it is not ` + + 'installed or the output is in a shape this cannot read'); + } else if (outcome.status !== 0) { + problems.push( + `${executable} exited without printing anything, so nothing could be read about ` + + `${packageName}`); + } + /* Exit 0 with nothing printed is yarn 2+'s well-formed answer for "nothing depends on this + * package". No copies means no duplicate, so nothing is recorded and the caller stays silent. */ + } + + return { packageName, packageManager, entries, versions: distinctVersions(entries), problems }; +}; diff --git a/src/schematics/duplicatePackages/npm.ts b/src/schematics/duplicatePackages/npm.ts new file mode 100644 index 000000000..db1ff6c53 --- /dev/null +++ b/src/schematics/duplicatePackages/npm.ts @@ -0,0 +1,13 @@ +/* How to ask npm which copies of a package are installed, and how to read its answer. */ + +import { walkDependencyTree } from './shared.js'; +import type { InstalledEntry, ManagerQuery } from './types.js'; + +export const npmQuery: ManagerQuery = { + args: packageName => ['ls', packageName, '--all', '--json'], + parse: (stdout, packageName) => { + const found: InstalledEntry[] = []; + walkDependencyTree(JSON.parse(stdout), packageName, [], found); + return found; + }, +}; diff --git a/src/schematics/duplicatePackages/pnpm.ts b/src/schematics/duplicatePackages/pnpm.ts new file mode 100644 index 000000000..61963363e --- /dev/null +++ b/src/schematics/duplicatePackages/pnpm.ts @@ -0,0 +1,55 @@ +/* How to ask pnpm which copies of a package are installed, and how to read its answer. */ + +import { walkDependencyTree } from './shared.js'; +import type { InstalledEntry, ManagerQuery } from './types.js'; + +/** + * Parses output that may be several JSON documents concatenated rather than one. + * + * `pnpm -r ls --json` prints one array per project, separated by a blank line, so the whole + * output is not valid JSON. Splitting on blank lines and parsing each chunk handles both that + * and the single-document case every other manager emits. + */ +export const parseJsonDocuments = (stdout: string, problems: string[] = []): unknown[] => { + let unreadable = 0; + const trimmed = stdout.trim(); + if (!trimmed) { return []; } + try { return [JSON.parse(trimmed)]; } + catch { + const documents: unknown[] = []; + for (const chunk of trimmed.split(/\n\s*\n/)) { + try { documents.push(JSON.parse(chunk)); } + catch { unreadable++; } + } + if (documents.length === 0) { throw new Error('no parseable JSON document in the output'); } + if (unreadable > 0) { + const total = unreadable + documents.length; + problems.push(`the output arrived as ${total} separate blocks of JSON and ${unreadable} ` + + 'of them could not be read, so copies may be missing'); + } + return documents; + } +}; + +export const pnpmQuery: ManagerQuery = { + /* `-r` includes every workspace member. Without it pnpm reports the root project only, and a + * duplicate living in a member is missed with exit 0 and no warning. */ + args: packageName => ['-r', 'ls', packageName, '--depth', 'Infinity', '--json'], + parse: (stdout, packageName, problems) => { + const found: InstalledEntry[] = []; + for (const document of parseJsonDocuments(stdout, problems)) { + for (const project of Array.isArray(document) ? document : [document]) { + if (typeof project !== 'object' || project === null) { continue; } + /* `-r` reports one project per workspace member, and the member's name is the only + * thing that says where a copy lives. Starting the path with it keeps that. */ + const projectName = Reflect.get(project, 'name'); + const member = typeof projectName === 'string' ? [projectName] : []; + for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) { + walkDependencyTree( + { dependencies: Reflect.get(project, section) }, packageName, member, found); + } + } + } + return found; + }, +}; diff --git a/src/schematics/duplicatePackages/queries.ts b/src/schematics/duplicatePackages/queries.ts new file mode 100644 index 000000000..cd13758bd --- /dev/null +++ b/src/schematics/duplicatePackages/queries.ts @@ -0,0 +1,37 @@ +/* The four managers in one table. Adding a fifth means a `PackageManager` member and a row here. */ + +import { npmQuery } from './npm.js'; +import { pnpmQuery } from './pnpm.js'; +import type { InstalledEntry, ManagerQuery, PackageManager } from './types.js'; +import { yarnQuery } from './yarn.js'; +import { yarnClassicQuery } from './yarnClassic.js'; + +export const queries: Record = { + npm: npmQuery, + pnpm: pnpmQuery, + yarn: yarnQuery, + 'yarn-classic': yarnClassicQuery, +}; + +/** The executable to run for each manager. Both yarns are invoked as `yarn`. */ +export const executableFor = (packageManager: PackageManager): string => + packageManager === 'yarn-classic' ? 'yarn' : packageManager; + +/** The arguments used to query one manager. */ +export const queryArgsFor = (packageManager: PackageManager, packageName: string): string[] => + queries[packageManager].args(packageName); + +/** The command to show installed package versions, written so a user can run it themselves. */ +export const investigationCommandFor = (packageManager: PackageManager, packageName: string): string => + [ + executableFor(packageManager), + ...queryArgsFor(packageManager, packageName).filter(argument => argument !== '--json'), + ].join(' '); + +/** Reads one manager's output without running it. */ +export const parseInstalledEntries = ( + packageManager: PackageManager, + stdout: string, + packageName: string, + problems: string[] = [], +): InstalledEntry[] => queries[packageManager].parse(stdout, packageName, problems); diff --git a/src/schematics/duplicatePackages/shared.ts b/src/schematics/duplicatePackages/shared.ts new file mode 100644 index 000000000..380f90d53 --- /dev/null +++ b/src/schematics/duplicatePackages/shared.ts @@ -0,0 +1,31 @@ +/* The two helpers used by more than one package manager's parser. */ + +import type { InstalledEntry } from './types.js'; + +/** Printed whenever a line-oriented parser had to skip input. */ +export const unreadableLinesProblem = (count: number): string => + `${count} ${count === 1 ? 'line' : 'lines'} of the output could not be read, so copies may be missing`; + +/** + * Walks an npm-shaped `dependencies` tree, which pnpm also emits. + * Do not add a depth limit: running out of call stack warns the user. A limit truncates silently. + */ +export const walkDependencyTree = ( + node: unknown, + packageName: string, + path: string[], + found: InstalledEntry[], +) => { + if (typeof node !== 'object' || node === null) { return; } + const dependencies = Reflect.get(node, 'dependencies'); + if (typeof dependencies !== 'object' || dependencies === null) { return; } + for (const [name, dependency] of Object.entries(dependencies)) { + // Keep this skip: `Reflect.get` throws on a non-object, and one throw discards every entry. + if (typeof dependency !== 'object' || dependency === null) { continue; } + const version = Reflect.get(dependency, 'version'); + if (name === packageName && typeof version === 'string') { + found.push({ version, dependencyPath: [...path] }); + } + walkDependencyTree(dependency, packageName, [...path, name], found); + } +}; diff --git a/src/schematics/duplicatePackages/types.ts b/src/schematics/duplicatePackages/types.ts new file mode 100644 index 000000000..b43919bac --- /dev/null +++ b/src/schematics/duplicatePackages/types.ts @@ -0,0 +1,74 @@ +/* Shapes and limits shared by every file in this folder. No behavior lives here. */ + +import type { PackageManager } from '../workspace.js'; + +export type { PackageManager } from '../workspace.js'; + +/** One entry as the package manager reported it. */ +export interface InstalledEntry { + version: string; + /** The chain of packages leading to this entry, outermost first. */ + dependencyPath: string[]; +} + +export interface InstalledCopyReport { + packageName: string; + /** Undefined when no manager could be identified, in which case nothing was queried. */ + packageManager: PackageManager | undefined; + /** + * Every entry the manager reported. Two entries at the same version may be one shared copy or + * two. This is a count of dependency relationships rather than of physical directories. + */ + entries: InstalledEntry[]; + /** + * Distinct versions, ordered numerically. More than one guarantees more than one instance. + * One version does NOT guarantee a single instance. + */ + versions: string[]; + /** Anything that limited the answer. An empty `entries` with problems present is not "clean". */ + problems: string[]; +} + +/** + * What a project says about its own package manager, read from the two files that can declare it: + * the `packageManager` field of `package.json` (corepack's) and `cli.packageManager` in + * `angular.json` (the Angular CLI's). All fields absent means neither file said anything. + */ +export interface DeclaredManager { + /** Set when the declaration names a package manager this module can query. */ + manager?: PackageManager; + /** Set when a declaration names yarn without a version. */ + yarnOfUnknownVersion?: boolean; + /** Set when it names a package manager this module has no query for, such as bun or cnpm. */ + unqueryable?: string; +} + +/** What a spawn produced, whether it succeeded, failed or was never able to start. */ +export interface SpawnOutcome { + stdout: string; + status: number | null; + /** Set when the command could not be run or was killed, including on timeout. */ + failure: string | undefined; +} + +/** How to query one manager, and how to read its answer. */ +export interface ManagerQuery { + args: (packageName: string) => string[]; + /** `problems` is where a parser records anything that limited what it could read. */ + parse: (stdout: string, packageName: string, problems: string[]) => InstalledEntry[]; +} + +export interface QueryOptions { + /** Overrides detection, for a workspace whose manager is already known. */ + packageManager?: PackageManager; + /** Milliseconds allowed for the query. The `yarn --version` probe is limited separately. */ + timeoutMs?: number; +} + +export const defaultTimeoutMs = 30_000; + +/** Limit on `yarn --version` alone. Generous because corepack downloads yarn on first use. */ +export const yarnProbeTimeoutMs = 10_000; + +/** Beyond this many entries the printed list stops being readable and becomes a wall. */ +export const maxPrintedEntries = 20; diff --git a/src/schematics/duplicatePackages/yarn.ts b/src/schematics/duplicatePackages/yarn.ts new file mode 100644 index 000000000..eae792dfb --- /dev/null +++ b/src/schematics/duplicatePackages/yarn.ts @@ -0,0 +1,71 @@ +/* How to ask yarn 2+ which copies of a package are installed, and how to read its answer. */ + +import { unreadableLinesProblem } from './shared.js'; +import type { InstalledEntry, ManagerQuery } from './types.js'; + +/** + * Extracts a version from a yarn 2+ locator. + * + * Locators are `name@protocol:rest`. Only `npm:` puts the bare version last. `patch:`, + * `workspace:`, `portal:` and `link:` do not, and taking everything after the final colon yields + * a fragment of a URL. Yarn appends `::version=` for patched entries, which is the version that + * matters, so that is preferred when present. + */ +export const versionFromYarnLocator = (locator: string, packageName: string): string | undefined => { + if (!locator.startsWith(`${packageName}@`)) { return undefined; } + let descriptor = locator.slice(packageName.length + 1); + const patched = /::version=([^&]+)/.exec(descriptor); + if (patched) { return patched[1]; } + /* Yarn virtualizes every package that declares peerDependencies, wrapping the real descriptor + * as `virtual:#`. Unwrapping is required. */ + const virtualized = /^virtual:[^#]*#(.*)$/.exec(descriptor); + if (virtualized) { descriptor = virtualized[1]; } + if (descriptor.startsWith('npm:')) { + const rest = descriptor.slice('npm:'.length); + // An alias reads `npm:@`. Don't take the whole tail. + const aliased = /^(?:@[^/]+\/)?[^@]+@(.+)$/.exec(rest); + return aliased ? aliased[1] : rest; + } + /* `workspace:`, `portal:` and `link:` name a location rather than a version, but the copy is + * real and is a separate module instance. */ + return descriptor || undefined; +}; + +/** + * A dependent's locator with yarn's virtual wrapper taken off. + * `yarn why` reports a package that declares peer dependencies twice, once by its plain locator + * and once as `name@virtual:#`, both naming the same directory on disk. + */ +const plainDependent = (locator: string): string => locator.replace(/@virtual:[^#]*#/, '@'); + +export const yarnQuery: ManagerQuery = { + // Deliberately not `-R`. The flat form already reports every dependent at any depth. + args: packageName => ['why', packageName, '--json'], + parse: (stdout, packageName, problems) => { + const found: InstalledEntry[] = []; + const seen = new Set(); + let unreadable = 0; + // yarn 2+ emits one JSON object per line, each naming a dependent and what it pulls in. + for (const line of stdout.split('\n')) { + if (!line.trim()) { continue; } + let record: { value?: string; children?: Record }; + try { record = JSON.parse(line); } + catch { unreadable++; continue; } + // `null` parses cleanly and then throws on property reads, discarding every entry found. + if (typeof record !== 'object' || record === null) { unreadable++; continue; } + for (const locator of Object.keys(record.children ?? {})) { + const version = versionFromYarnLocator(locator, packageName); + if (!version) { continue; } + const dependencyPath = record.value ? [plainDependent(record.value)] : []; + /* One dependent is reported on more than one line whenever it declares peer + * dependencies. Counted twice it becomes "2 dependents" for one copy. */ + const identity = `${version} ${dependencyPath.join(' ')}`; + if (seen.has(identity)) { continue; } + seen.add(identity); + found.push({ version, dependencyPath }); + } + } + if (unreadable > 0) { problems.push(unreadableLinesProblem(unreadable)); } + return found; + }, +}; diff --git a/src/schematics/duplicatePackages/yarnClassic.ts b/src/schematics/duplicatePackages/yarnClassic.ts new file mode 100644 index 000000000..3f0090b01 --- /dev/null +++ b/src/schematics/duplicatePackages/yarnClassic.ts @@ -0,0 +1,38 @@ +/* How to ask yarn 1.x which copies of a package are installed, and how to read its answer. */ + +import { unreadableLinesProblem } from './shared.js'; +import type { InstalledEntry, ManagerQuery } from './types.js'; + +export const yarnClassicQuery: ManagerQuery = { + args: packageName => ['list', '--pattern', packageName, '--json', '--depth=Infinity'], + parse: (stdout, packageName, problems) => { + const found: InstalledEntry[] = []; + let unreadable = 0; + // yarn 1 emits one event per line and puts the dependency tree in a single `tree` event. + for (const line of stdout.split('\n')) { + if (!line.trim()) { continue; } + let event: { type?: string; data?: { trees?: unknown[] } }; + try { event = JSON.parse(line); } + catch { unreadable++; continue; } + // `null` parses cleanly and then throws on property reads, discarding every entry found. + if (typeof event !== 'object' || event === null) { unreadable++; continue; } + if (event.type !== 'tree') { continue; } + const walk = (nodes: unknown, path: string[]) => { + if (!Array.isArray(nodes)) { return; } + for (const node of nodes) { + if (typeof node !== 'object' || node === null) { continue; } + const name = Reflect.get(node, 'name'); + if (typeof name !== 'string') { continue; } + // Names read `firebase@12.18.0`. + if (name.startsWith(`${packageName}@`)) { + found.push({ version: name.slice(packageName.length + 1), dependencyPath: [...path] }); + } + walk(Reflect.get(node, 'children'), [...path, name]); + } + }; + walk(event.data?.trees, []); + } + if (unreadable > 0) { problems.push(unreadableLinesProblem(unreadable)); } + return found; + }, +}; diff --git a/src/schematics/setup/duplicateWarning.jasmine.ts b/src/schematics/setup/duplicateWarning.jasmine.ts new file mode 100644 index 000000000..33041829d --- /dev/null +++ b/src/schematics/setup/duplicateWarning.jasmine.ts @@ -0,0 +1,275 @@ +/* + * Specs for the message `ng add` prints about duplicate copies of a package. + * + * These assert the words a user actually reads. The reporter underneath has its own specs for + * what it finds. What is checked here is the promise around it: silence means the tree was queried + * and is fine, and anything else says which of the two other things happened. + * + * Each case pins the MEANING of the sentence, not just a substring inside it. + * + * The package manager is never really started. `commandHost.run` is stubbed with output shaped + * like each manager's own, so these run anywhere. + */ + +import { lstatSync, mkdtempSync, readdirSync, rmdirSync, unlinkSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { commandHost, investigationCommandFor } from '../duplicatePackages/index.js'; +import type { SpawnOutcome } from '../duplicatePackages/index.js'; +import { warnAboutDuplicateFirebase } from './duplicateWarning.js'; +import 'jasmine'; + +/** Shaped like `npm ls firebase --all --json` in a workspace holding two versions. */ +const twoVersions = JSON.stringify({ + name: 'app', version: '1.0.0', + dependencies: { + firebase: { version: '12.4.0' }, + '@angular/fire': { version: '20.0.1', dependencies: { firebase: { version: '12.18.0' } } }, + }, +}); + +/** + * One version, reached by three separate dependents. + * + * The distinction matters: a check written against the number of dependents rather than the + * number of versions passes every spec that uses a single-dependent workspace, then raises a + * false alarm on a healthy project like this one. + */ +const oneVersionThreeDependents = JSON.stringify({ + name: 'app', version: '1.0.0', + dependencies: { + firebase: { version: '12.4.0' }, + '@angular/fire': { + version: '20.0.1', + dependencies: { + firebase: { version: '12.4.0' }, + rxfire: { version: '6.2.0', dependencies: { firebase: { version: '12.4.0' } } }, + }, + }, + }, +}); + +/** Shaped like `pnpm -r ls firebase --depth Infinity --json`, which is a list of projects. */ +const pnpmTwoVersions = JSON.stringify([{ + name: 'app', + dependencies: { + firebase: { version: '12.4.0' }, + lib: { version: '1.0.0', dependencies: { firebase: { version: '12.18.0' } } }, + }, +}]); + +describe('the duplicate copy warning', () => { + + let workspaceRoot: string; + let warnings: string[]; + let debugs: string[]; + let context: { logger: { warn: (message: string) => void; debug: (message: string) => void } }; + + const removeDirectory = (target: string) => { + for (const entry of readdirSync(target)) { + const entryPath = join(target, entry); + if (lstatSync(entryPath).isDirectory()) { removeDirectory(entryPath); } else { unlinkSync(entryPath); } + } + rmdirSync(target); + }; + + const spawnOutcome = (outcome: Partial = {}): SpawnOutcome => + ({ stdout: '', status: 0, failure: undefined, ...outcome }); + + /** Names the manager by writing its lockfile, which is how the code identifies one. */ + const lockfile = (name: string) => writeFileSync(join(workspaceRoot, name), ''); + + beforeEach(() => { + workspaceRoot = mkdtempSync(join(tmpdir(), 'angularfire-warning-')); + warnings = []; + debugs = []; + context = { logger: { warn: m => warnings.push(m), debug: m => debugs.push(m) } }; + }); + + afterEach(() => removeDirectory(workspaceRoot)); + + describe('when two versions are installed', () => { + + beforeEach(() => { + lockfile('package-lock.json'); + spyOn(commandHost, 'run').and.returnValue(spawnOutcome({ stdout: twoVersions })); + }); + + it('says that there is more than one version', () => { + /* The sentence has to carry the finding. Pinning only the version numbers and the chains + * leaves the surrounding words free to say anything, including the opposite. */ + warnAboutDuplicateFirebase(workspaceRoot, context); + expect(warnings.length).toBe(1); + /* Anchored at the start, because `toContain` is satisfied by the negation of the sentence + * it is checking: "does not have more than one version of firebase installed" contains + * "more than one version of firebase installed". */ + expect(warnings[0]).toMatch(/^\u26a0\ufe0f Your workspace has more than one version of firebase installed\./); + }); + + it('names the dependency that pulled in each copy', () => { + warnAboutDuplicateFirebase(workspaceRoot, context); + expect(warnings[0]).toContain('12.18.0 via @angular/fire'); + expect(warnings[0]).toContain('12.4.0 via the workspace root'); + }); + + it('explains why it matters', () => { + warnAboutDuplicateFirebase(workspaceRoot, context); + // The whole sentence, because a trailing "but not here" survives a substring. + expect(warnings[0]).toContain( + 'Two copies of the firebase SDK loaded by one app become two separate module instances, ' + + "and they reject each other's objects at runtime with errors that name your own code " + + 'rather than the duplication.'); + }); + + }); + + /* An npm workspace cannot tell a manager-specific command from a hardcoded `npm ls`, because + * the two are identical there. Only a non-npm workspace can. */ + it('offers the command for the manager in use, not always npm', () => { + lockfile('pnpm-lock.yaml'); + spyOn(commandHost, 'run').and.returnValue(spawnOutcome({ stdout: pnpmTwoVersions })); + warnAboutDuplicateFirebase(workspaceRoot, context); + expect(warnings[0]).toContain('pnpm -r ls firebase'); + expect(warnings[0]).not.toContain('npm ls firebase'); + }); + + it('says nothing at all when there is one version and nothing went wrong', () => { + lockfile('package-lock.json'); + spyOn(commandHost, 'run').and.returnValue(spawnOutcome({ stdout: oneVersionThreeDependents })); + warnAboutDuplicateFirebase(workspaceRoot, context); + expect(warnings).toEqual([]); + }); + + /* Three packages depending on the same version is one copy on disk and a healthy project. + * Counting dependents rather than versions turns it into a false alarm. */ + it('does not mistake several dependents on one version for a duplicate', () => { + lockfile('package-lock.json'); + spyOn(commandHost, 'run').and.returnValue(spawnOutcome({ stdout: oneVersionThreeDependents })); + warnAboutDuplicateFirebase(workspaceRoot, context); + expect(warnings).toEqual([]); + }); + + /* Exit 0 with nothing printed is yarn 2+'s well-formed answer for "nothing depends on this + * package", so it must not warn. The cost, accepted deliberately: a stale lockfile omitting + * firebase entirely now passes in silence. The shipping caller runs right after ng add + * installed firebase, so the lockfile entry it reads is fresh. */ + it('stays silent when the manager cleanly answers that nothing depends on the package', () => { + lockfile('yarn.lock'); + spyOn(commandHost, 'run').and.returnValue(spawnOutcome({ stdout: '4.5.3\n' })) + .and.returnValues(spawnOutcome({ stdout: '4.5.3\n' }), spawnOutcome({ stdout: '' })); + warnAboutDuplicateFirebase(workspaceRoot, context); + expect(warnings.length).toBe(0); + }); + + it('gives the reason it could not finish, not just that it could not', () => { + lockfile('package-lock.json'); + spyOn(commandHost, 'run').and.returnValue( + spawnOutcome({ status: null, failure: 'spawnSync npm ENOENT' })); + warnAboutDuplicateFirebase(workspaceRoot, context); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain('could not run npm'); + expect(warnings[0]).toContain('ENOENT'); + }); + + it('does not claim a count when it found nothing', () => { + lockfile('package-lock.json'); + spyOn(commandHost, 'run').and.returnValue( + spawnOutcome({ status: null, failure: 'spawnSync npm ENOENT' })); + warnAboutDuplicateFirebase(workspaceRoot, context); + expect(warnings[0]).not.toContain('0 distinct'); + expect(warnings[0]).not.toContain('0 dependents'); + }); + + /* npm exits non-zero for reasons unrelated to the question while still answering it, such as + * an unmet peer elsewhere in the tree. A complete single-version answer is a clean result, and + * warning "could not fully check" above it told a healthy project the check failed. */ + it('stays silent when the answer is complete despite a non-zero exit', () => { + lockfile('package-lock.json'); + spyOn(commandHost, 'run').and.returnValue( + spawnOutcome({ stdout: oneVersionThreeDependents, status: 1 })); + warnAboutDuplicateFirebase(workspaceRoot, context); + expect(warnings.length).toBe(0); + }); + + + it('suggests no command when it could not tell which manager to suggest one for', () => { + writeFileSync(join(workspaceRoot, 'package.json'), JSON.stringify({ packageManager: 'bun@1.1.30' })); + warnAboutDuplicateFirebase(workspaceRoot, context); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain('declares bun'); + expect(warnings[0]).not.toContain('Run '); + expect(warnings[0]).not.toContain('no package manager could be identified'); + }); + + describe('when two versions are found AND something limited the check', () => { + + /* The shape no fixture produced before, and the one where the message most easily + * contradicts itself. npm exits non-zero for reasons unrelated to the question while still + * answering it, so a real duplicate arriving alongside a caveat is ordinary. */ + beforeEach(() => { + lockfile('package-lock.json'); + spyOn(commandHost, 'run').and.returnValue(spawnOutcome({ + stdout: twoVersions, status: 1, + })); + }); + + it('still leads with the duplicate, not with the caveat', () => { + warnAboutDuplicateFirebase(workspaceRoot, context); + expect(warnings.length).toBe(1); + expect(warnings[0]).toMatch(/^\u26a0\ufe0f Your workspace has more than one version of firebase installed\./); + expect(warnings[0]).not.toContain('Could not fully check'); + }); + + it('says it once, not once per branch', () => { + warnAboutDuplicateFirebase(workspaceRoot, context); + expect(warnings.length).toBe(1); + }); + + }); + + it('points at the right package when asked about one other than the default', () => { + lockfile('package-lock.json'); + spyOn(commandHost, 'run').and.returnValue(spawnOutcome({ + stdout: JSON.stringify({ + name: 'app', version: '1.0.0', + dependencies: { + rxfire: { version: '6.2.0' }, + lib: { version: '1.0.0', dependencies: { rxfire: { version: '6.1.0' } } }, + }, + }), + })); + warnAboutDuplicateFirebase(workspaceRoot, context, 'rxfire'); + expect(warnings[0]).toContain('more than one version of rxfire installed'); + expect(warnings[0]).toContain('npm ls rxfire --all'); + expect(warnings[0]).not.toContain('firebase'); + }); + + it('still offers a command when the check could not finish', () => { + lockfile('package-lock.json'); + spyOn(commandHost, 'run').and.returnValue( + spawnOutcome({ status: null, failure: 'spawnSync npm ENOENT' })); + warnAboutDuplicateFirebase(workspaceRoot, context); + expect(warnings[0]).toContain('Run npm ls firebase --all'); + }); + + it('never lets a failed check take down the command it runs inside', () => { + lockfile('package-lock.json'); + spyOn(commandHost, 'run').and.throwError('something unexpected'); + expect(() => warnAboutDuplicateFirebase(workspaceRoot, context)).not.toThrow(); + expect(warnings).toEqual([]); + expect(debugs.join(' ')).toContain('something unexpected'); + }); + + describe('the command it suggests', () => { + + it('is the same question the code itself asked', () => { + expect(investigationCommandFor('npm', 'firebase')).toBe('npm ls firebase --all'); + expect(investigationCommandFor('pnpm', 'firebase')).toBe('pnpm -r ls firebase --depth Infinity'); + expect(investigationCommandFor('yarn', 'firebase')).toBe('yarn why firebase'); + expect(investigationCommandFor('yarn-classic', 'firebase')) + .toBe('yarn list --pattern firebase --depth=Infinity'); + }); + + }); + +}); diff --git a/src/schematics/setup/duplicateWarning.ts b/src/schematics/setup/duplicateWarning.ts new file mode 100644 index 000000000..a4bd8c9cc --- /dev/null +++ b/src/schematics/setup/duplicateWarning.ts @@ -0,0 +1,68 @@ +/* + * Decides what, if anything, `ng add` prints about duplicate copies of a package. + * + * `duplicatePackages/` finds the entries and passes no judgment on them. Choosing between + * warning loudly, warning that the check fell short, and printing nothing at all happens here + * instead, in one place. + */ + +import { findInstalledCopies, formatInstalledCopies, investigationCommandFor } from '../duplicatePackages/index.js'; +import type { InstalledCopyReport } from '../duplicatePackages/index.js'; + +/** + * The narrow slice of the Angular CLI's `SchematicContext` that `warnAboutDuplicateFirebase` + * uses: a function to write a warning, and a function to write a debug line. Helps unit tests. + */ +export interface WarningContext { + logger: { warn(message: string): void; debug(message: string): void }; +} + +/* Give user the command to view duplicate installs if their package manager was identified. */ +const runItYourself = (report: InstalledCopyReport, packageName: string): string => + report.packageManager ? ` Run ${investigationCommandFor(report.packageManager, packageName)}` : ''; + +/** + * Give the user a warning if duplicate versions are identified, or if something stopped the check + * from being thorough. Print nothing when one version is identified and nothing went wrong. + */ +export const warnAboutDuplicateFirebase = ( + workspaceRoot: string, + context: WarningContext, + packageName = 'firebase', +): void => { + // Use try/catch so `ng add @angular/fire` doesn't abort when the duplicate install check fails. + try { + const report = findInstalledCopies(packageName, workspaceRoot); + + /* `findings` names the chain of packages leading to copy, e.g. `11.10.0 via @angular/fire`. */ + const findings = formatInstalledCopies(report).join('\n'); + const alsoRun = runItYourself(report, packageName); + + /* One sentence for both warnings. It explains the hazard rather than asserting it happened, + * so it reads correctly whether two versions were found or the check fell short. */ + const whyItMatters = + `Two copies of the ${packageName} SDK loaded by one app become two separate module ` + + 'instances, and they reject each other\'s objects at runtime with errors that name ' + + 'your own code rather than the duplication.'; + + if (report.versions.length > 1) { + context.logger.warn( + `⚠️ Your workspace has more than one version of ${packageName} installed.\n\n` + + `${findings}\n\n` + + `${whyItMatters}${alsoRun ? `${alsoRun} for the full tree.` : ''}` + ); + return; + } + + if (report.problems.length > 0) { + context.logger.warn( + `⚠️ Could not fully check whether ${packageName} is installed more than once.\n\n` + + `${findings}\n\n` + + `${whyItMatters}${alsoRun ? `${alsoRun} to check yourself.` : ''}` + ); + } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + context.logger.debug(`Could not check for duplicate copies of ${packageName}: ${reason}`); + } +}; diff --git a/src/schematics/setup/index.ts b/src/schematics/setup/index.ts index 8a657e17a..173f4b398 100644 --- a/src/schematics/setup/index.ts +++ b/src/schematics/setup/index.ts @@ -16,6 +16,7 @@ import { parseDataConnectConfig, setupTanstackDependencies, } from '../utils'; +import { warnAboutDuplicateFirebase } from './duplicateWarning'; import { addFirestoreToFirebaseJson, createFirestoreStarterFiles, @@ -65,6 +66,10 @@ export const ngAddSetupProject = ( let projectRoot: string = (host as any)._backend._root; if (process.platform.startsWith('win32')) { projectRoot = asWindowsPath(normalize(projectRoot)); } + /* Before the prompt, so it runs whatever the user selects, and after the install task this + * schematic is scheduled behind, so node_modules is on disk to be queried. */ + warnAboutDuplicateFirebase(projectRoot, context); + const features = await featuresPrompt(); if (features.length === 0) { diff --git a/src/schematics/workspace.ts b/src/schematics/workspace.ts new file mode 100644 index 000000000..febc88d61 --- /dev/null +++ b/src/schematics/workspace.ts @@ -0,0 +1,108 @@ +/* + * What more than one schematic needs to know about a user's workspace as it exists on disk, plus + * the rules for handling what it finds there. + * + * `common.ts` works against the schematic `Tree`, which is the pending state of a change. The + * readers here go to the real filesystem, which is what a schematic needs when it wants to know + * what is actually installed rather than what is about to be written. + * + * `assertSafeDependencyName` reads nothing. It lives here because it guards values taken from + * these same files before they reach a process argv, and both callers of it are callers of the + * readers above. + */ + +import { existsSync, readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { SchematicsException } from '@angular-devkit/schematics'; +import { parse as parseJsonWithComments } from 'jsonc-parser'; + +/** + * `yarn` means yarn 2 and later. `yarn-classic` is yarn 1.x, which is still what + * `npm i -g yarn` installs and which reports dependencies in an unrelated format. + */ +export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'yarn-classic'; + +/** + * Lockfile names, and the manager each one identifies, in the order they are checked. + * + * Both yarns write `yarn.lock`, so it maps to yarn 2+ here and whoever needs to tell the two + * apart settles that separately. + */ +export const lockfiles: [PackageManager, string][] = [ + ['pnpm', 'pnpm-lock.yaml'], + ['yarn', 'yarn.lock'], + ['npm', 'package-lock.json'], +]; + +/** + * Reads and parses a JSON file, returning undefined rather than throwing when it cannot be used. + * + * A missing, unreadable or malformed file is an ordinary shape for the things this is pointed at + * (a workspace `package.json`, an `angular.json`), so callers branch on the result instead of + * wrapping every call. + */ +export const readJson = (path: string): unknown => { + try { + /* jsonc, not JSON.parse: Angular tolerates comments in angular.json and this repo already + * reads it that way (`utils.ts`). Strict parsing silently dropped a commented file's + * `cli.packageManager` declaration. */ + return parseJsonWithComments(readFileSync(path, 'utf8')); + } catch { return undefined; } +}; + +/** + * Reads a nested string field out of a parsed JSON file, returning '' for any other shape. + * + * These files are the user's to write, so every level may be missing or hold a type the schema + * does not allow, and none of that is worth an exception. + */ +export const stringAt = (source: unknown, ...path: string[]): string => { + let value: unknown = source; + for (const key of path) { + if (typeof value !== 'object' || value === null) { return ''; } + value = Reflect.get(value, key); + } + return typeof value === 'string' ? value : ''; +}; + +/** + * How far above the starting directory a monorepo root is looked for. Without a limit the walk + * goes all the way to the filesystem root. + */ +export const maxWorkspaceWalkDepth = 8; + +/** + * Finds the directory that owns the install, by walking up from `startDirectory` until a lockfile + * or a `packageManager` declaration appears. + */ +export const workspaceRootFor = (startDirectory: string): string => { + let directory = startDirectory; + for (let depth = 0; depth <= maxWorkspaceWalkDepth; depth++) { + /* Lockfiles this cannot query still mark the directory that owns the install. Without them + * a bun or deno project walks past its own root and the question is answered wherever an + * unrelated ancestor left a lockfile. */ + const ownershipMarkers = [...lockfiles.map(([, lockfile]) => lockfile), + 'bun.lock', 'bun.lockb', 'deno.lock']; + const owns = ownershipMarkers.some(marker => existsSync(join(directory, marker))) + || stringAt(readJson(join(directory, 'package.json')), 'packageManager') !== ''; + if (owns) { return directory; } + const parent = dirname(directory); + if (parent === directory) { break; } + directory = parent; + } + return startDirectory; +}; + +/** + * Rejects a dependency name that could be read as a shell command or as a package-manager flag. + * @param name the dependency name to check + * @param source names where the value came from, so a rejected name tells the user which file + * to go and edit. + */ +export const assertSafeDependencyName = (name: string, source: string): string => { + if (typeof name !== 'string' || name.length === 0 || name.startsWith('-') || + /[\s;&|$`(){}<>!\\'"]/.test(name)) { + throw new SchematicsException(`Invalid dependency name ${JSON.stringify(name)} ${source}.`); + } + return name; +};