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