nx
Version:
1,466 lines • 72.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.LOCAL_PATH_MODULES_DIR = exports.PNPM_LOCKFILE_RESOLUTION_CONFIG_FIELDS = void 0;
exports.stripPrunedLockfilePnpmConfig = stripPrunedLockfilePnpmConfig;
exports.dropInheritedPnpmPatchedDependencies = dropInheritedPnpmPatchedDependencies;
exports.getPrunedPnpmInstallSettingsYaml = getPrunedPnpmInstallSettingsYaml;
exports.normalizePrunedPatchPath = normalizePrunedPatchPath;
exports.getPrunedPnpmPackageJsonBuildSettings = getPrunedPnpmPackageJsonBuildSettings;
exports.getPrunedPnpmPatchArtifacts = getPrunedPnpmPatchArtifacts;
exports.containLocalPath = containLocalPath;
exports.uncontainLocalPath = uncontainLocalPath;
exports.normalizeLocalPathSpec = normalizeLocalPathSpec;
exports.uncontainLocalPathSpec = uncontainLocalPathSpec;
exports.warnOnWorkspaceModulePathCollision = warnOnWorkspaceModulePathCollision;
exports.containShippedLocalFilePaths = containShippedLocalFilePaths;
exports.containShippedLocalLinkRefs = containShippedLocalLinkRefs;
exports.getPrunedPnpmLocalPathArtifacts = getPrunedPnpmLocalPathArtifacts;
exports.validatePrunedLocalPathClosure = validatePrunedLocalPathClosure;
exports.relocatePrunedLocalPathSpec = relocatePrunedLocalPathSpec;
exports.warnIncompletePrunedPnpmOutput = warnIncompletePrunedPnpmOutput;
exports.warnUnshippableLocalPathSpec = warnUnshippableLocalPathSpec;
exports.movePeerDependencyToDependencies = movePeerDependencyToDependencies;
exports.dropEmptyPeerDependencySections = dropEmptyPeerDependencySections;
exports.rewritePrunedLocalPathSpecifiers = rewritePrunedLocalPathSpecifiers;
exports.getPrunedPnpmInstallArtifacts = getPrunedPnpmInstallArtifacts;
const fs_1 = require("fs");
const path_1 = require("path");
const catalog_1 = require("../../../utils/catalog");
const fileutils_1 = require("../../../utils/fileutils");
const logger_1 = require("../../../utils/logger");
const output_1 = require("../../../utils/output");
const package_manager_1 = require("../../../utils/package-manager");
const path_2 = require("../../../utils/path");
const workspace_root_1 = require("../../../utils/workspace-root");
const pnpm_normalizer_1 = require("./utils/pnpm-normalizer");
/**
* The resolution-time pnpm config a pruned standalone output must not carry,
* with the name each emitted file gives it: `manifest` is the `pnpm.*` key in
* `package.json`, `lockfile` the top-level lockfile key, and `null` means that
* file never declares the field. The two names differ often enough
* (`packageExtensions` against `packageExtensionsChecksum`) that pairing them
* in one table is what keeps the manifest strip and the lockfile strip from
* drifting apart when pnpm adds a field.
*
* `patchedDependencies` is deliberately absent: it is filtered and re-declared
* against the shipped patch files rather than dropped.
*/
const PNPM_RESOLUTION_CONFIG = [
{ manifest: 'overrides', lockfile: 'overrides' },
{
manifest: 'ignoredOptionalDependencies',
lockfile: 'ignoredOptionalDependencies',
},
{ manifest: 'packageExtensions', lockfile: 'packageExtensionsChecksum' },
{ manifest: null, lockfile: 'pnpmfileChecksum' },
{ manifest: null, lockfile: 'settings' },
{ manifest: null, lockfile: 'catalogs' },
];
const PNPM_MANIFEST_RESOLUTION_CONFIG_FIELDS = PNPM_RESOLUTION_CONFIG.flatMap((field) => (field.manifest === null ? [] : [field.manifest]));
exports.PNPM_LOCKFILE_RESOLUTION_CONFIG_FIELDS = PNPM_RESOLUTION_CONFIG.map((field) => field.lockfile);
/**
* Drops the resolution-time pnpm config a pruned standalone lockfile already
* resolves into its snapshots, then drops an emptied `pnpm` block. Re-declaring
* it next to a pruned lockfile makes pnpm <=10 fail with
* ERR_PNPM_LOCKFILE_CONFIG_MISMATCH. Only for an actually pruned lockfile: the
* root-lockfile fallback keeps the config, which that lockfile still declares.
*
* Counterpart to `stripStandaloneLockfileConfig` in the pnpm lock-file parser,
* which drops the same fields from the generated lockfile.
*/
function stripPrunedLockfilePnpmConfig(packageJson) {
if (!packageJson.pnpm) {
return;
}
for (const field of PNPM_MANIFEST_RESOLUTION_CONFIG_FIELDS) {
delete packageJson.pnpm[field];
}
if (Object.keys(packageJson.pnpm).length === 0) {
delete packageJson.pnpm;
}
}
/**
* Drops the `pnpm.patchedDependencies` the emitted manifest inherited from the
* project it was built from. The declaration a pruned output needs is the one
* `getPrunedPnpmPatchArtifacts` derives from the lockfile shipping beside it; an
* inherited one names the workspace's patch paths rather than the output's, and
* survives even when the output ships no patch file at all, which fails the
* install as pnpm hashes every declared patch. pnpm reads the field from the
* workspace root alone, so a project-level block was inert at home and cannot be
* trusted here either.
*/
function dropInheritedPnpmPatchedDependencies(packageJson) {
if (!packageJson.pnpm?.patchedDependencies) {
return;
}
delete packageJson.pnpm.patchedDependencies;
if (Object.keys(packageJson.pnpm).length === 0) {
delete packageJson.pnpm;
}
}
/**
* Builds the settings-only pnpm-workspace.yaml every standalone pruned output
* ships. Unconditional because a conditional artifact cannot be retracted: a
* cache replay restores only the files the replayed entry holds and a bundler
* overwrites rather than cleans its output directory, so an earlier build's
* copy would survive and its stale settings would apply. A `packages: []`-only
* file is inert, verified installable with identical module resolution on
* pnpm 9, 10 and 11.
*
* On pnpm 11+, this file carries declared `minimumReleaseAge`,
* `minimumReleaseAgeExclude`, `minimumReleaseAgeStrict`, build approvals,
* `supportedArchitectures`, and applicable `patchedDependencies` from the
* workspace root. Approvals and patches are scoped to the pruned lockfile.
*
* On pnpm <=10, only build approvals, `supportedArchitectures`, and
* `patchedDependencies` are emitted in `package.json`.
*
* Settings encoded in the pruned resolution, such as `overrides` and
* `packageExtensions`, are not copied (`stripPrunedLockfilePnpmConfig`).
*
* The major is the build machine's pnpm, which is all that is knowable at
* build time: an output built on pnpm <=10 but deployed on pnpm 11+ will not
* pick its approvals up. `precomputed` lets a caller pass the pnpm major and
* pruned patchedDependencies it already resolved instead of recomputing them.
*/
function getPrunedPnpmInstallSettingsYaml(workspaceRootPath = workspace_root_1.workspaceRoot, prunedLockfileContent, precomputed) {
const settings = getPrunedPnpmWorkspaceSettings(workspaceRootPath, prunedLockfileContent, precomputed) ?? {};
const { dump } = require('@zkochan/js-yaml');
// pnpm 9 rejects a pnpm-workspace.yaml without a `packages` field; an empty
// list is accepted by pnpm 9-11 without pulling any importer into the install.
return dump({ packages: [], ...settings });
}
/**
* pnpm-workspace.yaml fields carried verbatim from the workspace root to the
* pruned output, with no interpretation beyond "declared or not" (unlike
* `allowBuilds`, filtered to the pruned lockfile, and `patchedDependencies`,
* resolved against it):
* - `supportedArchitectures`.
* - `minimumReleaseAge`/`minimumReleaseAgeExclude`: silently dropping these
* falls back to pnpm's own default cutoff instead of the workspace's,
* which can reject lockfile entries the workspace itself allows (or vice
* versa).
* - `minimumReleaseAgeStrict`: starting with pnpm 11.0.0, a declared
* `minimumReleaseAge` enables strict mode unless `minimumReleaseAgeStrict:
* false` is also declared. Dropping an explicit `false` here would
* silently re-enable strict mode in the pruned output and make its
* install reject an immature exact dependency the source workspace
* accepted.
*/
const STRAIGHT_COPY_WORKSPACE_SETTINGS = [
'supportedArchitectures',
'minimumReleaseAge',
'minimumReleaseAgeExclude',
'minimumReleaseAgeStrict',
];
/**
* The settings `getPrunedPnpmInstallSettingsYaml` emits, before serialization,
* so a caller can tell which of them the workspace actually declares. Null when
* the workspace declares none, or on a pnpm major that reads them from the
* emitted package.json instead.
*/
function getPrunedPnpmWorkspaceSettings(workspaceRootPath, prunedLockfileContent, precomputed) {
// pnpm 11 was the first major to read these settings only from
// pnpm-workspace.yaml; later majors keep that behavior. pnpm 10 and below
// still read them from the emitted package.json, so nothing to carry.
const pnpmMajor = resolvePnpmMajor(precomputed, workspaceRootPath);
if (pnpmMajor === null || pnpmMajor < 11) {
return null;
}
let rootSettings;
try {
const rootWorkspaceYaml = (0, path_1.join)(workspaceRootPath, 'pnpm-workspace.yaml');
if (!(0, fs_1.existsSync)(rootWorkspaceYaml)) {
return null;
}
// An empty or comment-only file parses to null/undefined; treat it as no
// settings rather than dereferencing it below.
rootSettings = (0, fileutils_1.readYamlFile)(rootWorkspaceYaml) ?? {};
}
catch {
// Unreadable or malformed pnpm-workspace.yaml: skip rather than guess.
logger_1.logger.warn('Could not read the workspace root pnpm-workspace.yaml; the pruned output will not declare pnpm install settings (build-script approvals, supportedArchitectures, minimumReleaseAge, patchedDependencies).');
return null;
}
const settings = {};
if (rootSettings.allowBuilds) {
const allowBuilds = prunedLockfileContent
? filterAllowBuildsToLockfile(rootSettings.allowBuilds, prunedLockfileContent)
: rootSettings.allowBuilds;
if (Object.keys(allowBuilds).length > 0) {
settings.allowBuilds = allowBuilds;
}
}
for (const field of STRAIGHT_COPY_WORKSPACE_SETTINGS) {
assignDefined(settings, rootSettings, field);
}
const patchedDependencies = precomputed?.patchedDependencies ??
(prunedLockfileContent
? getPrunedPatchedDependencies(workspaceRootPath, prunedLockfileContent)
: {});
if (Object.keys(patchedDependencies).length > 0) {
settings.patchedDependencies =
normalizePrunedPatchedDependencies(patchedDependencies);
}
return Object.keys(settings).length > 0 ? settings : null;
}
const pnpmMajorByWorkspaceRoot = new Map();
/**
* The pnpm major of the workspace's package manager, or null when it cannot be
* determined (unknown or unparseable version). Memoized per workspace root:
* `getPackageManagerVersion` re-reads the root manifest on every call and shells
* out to `pnpm --version` when it declares no `packageManager`, while the
* bundler plugins reach this once per compilation, so a watched build would pay
* it on every rebuild.
*/
function getPnpmMajor(workspaceRootPath) {
if (pnpmMajorByWorkspaceRoot.has(workspaceRootPath)) {
return pnpmMajorByWorkspaceRoot.get(workspaceRootPath);
}
let major;
try {
const parsed = Number.parseInt((0, package_manager_1.getPackageManagerVersion)('pnpm', workspaceRootPath).split('.')[0], 10);
major = Number.isNaN(parsed) ? null : parsed;
}
catch {
major = null;
}
// Only a successful detection is remembered: `getPackageManagerVersion`
// shells out when the root declares no `packageManager`, and caching one
// failed spawn would disable the pnpm settings for the rest of the process.
if (major !== null) {
pnpmMajorByWorkspaceRoot.set(workspaceRootPath, major);
}
return major;
}
/**
* `getPnpmMajor` for the prune orchestrators: warns on an undeterminable
* version, since every version-gated install setting is skipped in that case.
*/
function getPnpmMajorOrWarn(workspaceRootPath) {
const pnpmMajor = getPnpmMajor(workspaceRootPath);
if (pnpmMajor === null) {
logger_1.logger.warn('Could not determine the pnpm version. The pruned output will not carry pnpm build-script approvals, supportedArchitectures, minimumReleaseAge, or patchedDependencies declarations; patch files still ship.');
}
return pnpmMajor;
}
/**
* The pnpm major each install-settings builder works from. Probing is skipped
* whenever an orchestrator passed its own config, on the presence of that config
* rather than on the value: a precomputed `null` means the probe already failed
* and warned, and re-probing it here could disagree with what the sibling
* builders decided from that same `null`.
*/
function resolvePnpmMajor(precomputed, workspaceRootPath) {
return precomputed ? precomputed.pnpmMajor : getPnpmMajor(workspaceRootPath);
}
/**
* Keeps only the `allowBuilds` entries whose package is present in the pruned
* lockfile. Build-script approvals for packages the prune dropped are inert, so
* dropping them keeps the emitted pnpm-workspace.yaml scoped to the deployment.
* Carries the approvals verbatim when `getBuildApprovalScopeNames` cannot
* determine that scope.
*/
function filterAllowBuildsToLockfile(allowBuilds, prunedLockfileContent) {
const present = getBuildApprovalScopeNames(prunedLockfileContent);
if (present === null) {
return allowBuilds;
}
const filtered = {};
for (const [name, allowed] of Object.entries(allowBuilds)) {
if (present.has(name)) {
filtered[name] = allowed;
}
}
return filtered;
}
let lastParsedPnpmLockfile = null;
/**
* Parses pnpm lockfile content, memoizing the last result: one prune run reads
* the same content for the patch scope, the build-script approvals, the
* local-path artifacts, and the link-closure validation, and a watch-mode
* rebuild re-reads an unchanged lockfile. The fallback path passes the root
* lockfile in, which pnpm 11 writes as two YAML documents for a package manager
* it persists (see `shouldPersistLockfile`), so the workspace document is
* extracted the way the pnpm lockfile parser does. Returns null when the content is not
* valid YAML or does not parse to an object. Consumers must not mutate the
* returned document.
*/
function parsePnpmLockfileYaml(content) {
if (lastParsedPnpmLockfile?.content !== content) {
// One failure disables three independent output-correctness steps at once,
// and the result is memoized, so it would stay invisible for the rest of
// the process.
const warnUnusable = (cause) => {
logger_1.logger.warn(`Could not parse the pnpm lockfile (${cause}); the pruned output will not carry patch declarations, local-path artifacts, or a validated link closure.`);
};
let parsed;
// A lockfile that opens a YAML document and never separates a second one
// extracts to nothing, which would otherwise read as an empty lockfile.
const document = (0, pnpm_normalizer_1.extractMainLockfileDocument)(content);
if (document.trim() === '' && content.trim() !== '') {
warnUnusable('it carries no lockfile document');
parsed = null;
}
else {
try {
parsed = require('@zkochan/js-yaml').load(document) ?? {};
}
catch (e) {
warnUnusable(e instanceof Error ? e.message : String(e));
parsed = null;
}
}
if (parsed !== null &&
(typeof parsed !== 'object' || Array.isArray(parsed))) {
warnUnusable('it does not parse to a YAML mapping');
parsed = null;
}
lastParsedPnpmLockfile = { content, parsed: parsed };
}
return lastParsedPnpmLockfile.parsed;
}
/**
* The package names a build approval can apply to in the output, read from a
* pnpm v9 lockfile's `packages` keys (`name@version`, `@scope/name@version`,
* optionally with a `(peer@ver)` suffix). Returns null when that scope cannot be
* determined, which callers treat as "carry approvals verbatim", the inert
* direction: an approval for an absent package does nothing, a dropped one
* silently skips a needed build script. Three cases yield null:
* - unparseable content;
* - a pre-v9 lockfile, whose `/name@version` (v6) and `/name/version` (v5) keys
* this parse would mangle;
* - a lockfile with an importer other than the output itself, which is
* the root-lockfile fallback. It lists the workspace's projects
* under `importers` and never `packages`, so a workspace module the output
* ships as a `file:` directory dependency, which pnpm does gate on the
* approval list, has no name here to match.
*/
function getBuildApprovalScopeNames(lockfileContent) {
const parsed = parsePnpmLockfileYaml(lockfileContent);
const lockfileVersion = Number.parseFloat(String(parsed?.lockfileVersion));
if (!parsed || Number.isNaN(lockfileVersion) || lockfileVersion < 9) {
return null;
}
if (Object.keys(parsed.importers ?? {}).some((importer) => importer !== '.')) {
return null;
}
const names = new Set();
for (const key of Object.keys(parsed.packages ?? {})) {
// Skip index 0 so a scoped key's leading `@` is not read as the separator.
const versionSeparator = key.indexOf('@', 1);
names.add(versionSeparator === -1 ? key : key.slice(0, versionSeparator));
}
return names;
}
/**
* The workspace root's `patchedDependencies` (package key -> patch file path),
* scoped to the patches the pruned lockfile keeps. pnpm 11 declares them in
* pnpm-workspace.yaml; pnpm 10 and below in the package.json `pnpm` field, so
* read both root sources. A patch entry for a package the prune dropped would
* fail `pnpm install --frozen-lockfile` with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH,
* so the pruned lockfile's kept `patchedDependencies` keys are the scope.
*/
function getPrunedPatchedDependencies(workspaceRootPath, prunedLockfileContent) {
if (!prunedLockfileContent) {
return {};
}
const survivingKeys = getPrunedLockfilePatchedKeys(prunedLockfileContent);
if (survivingKeys.size === 0) {
return {};
}
const rootPatches = readRootPatchedDependencies(workspaceRootPath);
const scoped = {};
for (const key of survivingKeys) {
if (rootPatches[key]) {
scoped[key] = rootPatches[key];
}
}
return scoped;
}
/**
* The path a `.patch` file takes inside the pruned output, always under the
* output's declared `patches/` directory: a source path outside it would fall
* outside the prune target's cached `patches` output and be dropped on a cache
* replay, and a `..` asset name is not one a bundler can emit. The source
* sub-structure is kept so same-named patches in different directories stay
* distinct. The path is collapsed first, the way pnpm collapses it before
* recording it in the lockfile (`./x` and `a/../x` are both stored as `x`), so
* the config path read here and the lockfile path read by
* `filterPatchedDependenciesToPrunedPackages` (which calls this same helper)
* produce the same destination. A `..` left after collapsing escapes the
* workspace and is dropped, the one way two sources can still meet on one
* destination; `getPrunedPnpmPatchArtifacts` rejects that pair rather than
* shipping one file for both.
*/
function normalizePrunedPatchPath(patchPath) {
const segments = path_1.posix
.normalize(patchPath.replace(/\\/g, '/'))
.split('/')
.filter((segment) => segment !== '' && segment !== '.' && segment !== '..');
return `patches/${segments.join('/')}`;
}
function normalizePrunedPatchedDependencies(patchedDependencies) {
const normalized = {};
for (const [key, patchPath] of Object.entries(patchedDependencies)) {
normalized[key] = normalizePrunedPatchPath(patchPath);
}
return normalized;
}
function readRootPatchedDependencies(workspaceRootPath) {
const merged = {};
// pnpm <=10 reads patchedDependencies from the package.json `pnpm` field,
// pnpm 11 only from pnpm-workspace.yaml. When both declare the same key the
// pnpm-workspace.yaml value is the authoritative one on pnpm 11 (pnpm
// migrates the config there), so read package.json first and let the
// pnpm-workspace.yaml value win on conflict. Each source is read in its own
// try so a broken one does not discard the other's patches.
try {
const rootPackageJsonPath = (0, path_1.join)(workspaceRootPath, 'package.json');
if ((0, fs_1.existsSync)(rootPackageJsonPath)) {
const rootPackageJson = (0, fileutils_1.readJsonFile)(rootPackageJsonPath);
Object.assign(merged, rootPackageJson.pnpm?.patchedDependencies ?? {});
}
}
catch {
logger_1.logger.warn('Could not read patchedDependencies from the workspace root package.json; the pruned output will not carry the patches it declares.');
}
try {
const rootWorkspaceYaml = (0, path_1.join)(workspaceRootPath, 'pnpm-workspace.yaml');
if ((0, fs_1.existsSync)(rootWorkspaceYaml)) {
const yaml = (0, fileutils_1.readYamlFile)(rootWorkspaceYaml);
Object.assign(merged, yaml?.patchedDependencies ?? {});
}
}
catch {
logger_1.logger.warn('Could not read patchedDependencies from the workspace root pnpm-workspace.yaml; the pruned output will not carry the patches it declares.');
}
return merged;
}
/** Copies `key` from `source` to `target` unless the source leaves it unset. */
function assignDefined(target, source, key) {
if (source[key] !== undefined) {
target[key] = source[key];
}
}
/**
* The workspace root's pnpm build-script settings, read from both the
* pnpm-workspace.yaml and the root package.json `pnpm` field, with the
* pnpm-workspace.yaml value winning per field (pnpm migrates config there, and
* pnpm 10 reads both). Mirrors `readRootPatchedDependencies`.
*/
function readRootPnpmBuildSettings(workspaceRootPath) {
const fields = [
'onlyBuiltDependencies',
'neverBuiltDependencies',
'allowBuilds',
'supportedArchitectures',
];
const merged = {};
const sources = [];
// Each source is read in its own try so a broken one does not discard the
// other's settings.
try {
const rootPackageJsonPath = (0, path_1.join)(workspaceRootPath, 'package.json');
if ((0, fs_1.existsSync)(rootPackageJsonPath)) {
sources.push((0, fileutils_1.readJsonFile)(rootPackageJsonPath).pnpm ?? {});
}
}
catch {
logger_1.logger.warn('Could not read the pnpm build-script settings from the workspace root package.json; the pruned output will not carry the settings it declares.');
}
try {
const rootWorkspaceYaml = (0, path_1.join)(workspaceRootPath, 'pnpm-workspace.yaml');
if ((0, fs_1.existsSync)(rootWorkspaceYaml)) {
sources.push((0, fileutils_1.readYamlFile)(rootWorkspaceYaml) ?? {});
}
}
catch {
logger_1.logger.warn('Could not read the pnpm build-script settings from the workspace root pnpm-workspace.yaml; the pruned output will not carry the settings it declares.');
}
// Later source (pnpm-workspace.yaml) wins per field.
for (const source of sources) {
for (const field of fields) {
assignDefined(merged, source, field);
}
}
return merged;
}
/**
* The pnpm build-script approvals a standalone pruned output declares in its
* emitted package.json so native production deps still run their build scripts
* on pnpm <=10; null on pnpm 11+, which reads approvals only from
* pnpm-workspace.yaml (`getPrunedPnpmInstallSettingsYaml` carries them there;
* keep the two in sync when pnpm moves the read again). Both root sources
* (pnpm-workspace.yaml wins over the package.json `pnpm` field) are read, and
* a pnpm 10.26+ `allowBuilds` map is folded into the on/never-built lists
* pnpm <=10 understands. Approvals are scoped to the packages the pruned
* lockfile keeps (one for a dropped package is inert); when the lockfile's
* names cannot be extracted (a pre-v9 lockfile, unparseable content) they are
* carried verbatim instead of scoped to nothing.
*/
function getPrunedPnpmPackageJsonBuildSettings(workspaceRootPath = workspace_root_1.workspaceRoot, prunedLockfileContent, precomputed) {
const pnpmMajor = resolvePnpmMajor(precomputed, workspaceRootPath);
if (pnpmMajor === null || pnpmMajor >= 11) {
return null;
}
const root = readRootPnpmBuildSettings(workspaceRootPath);
const present = prunedLockfileContent
? getBuildApprovalScopeNames(prunedLockfileContent)
: null;
const scopeToLockfile = (names) => {
const scoped = [...names];
return present ? scoped.filter((name) => present.has(name)) : scoped;
};
// pnpm 10.26+ declares approvals as an allowBuilds map; fold it into the
// on/never-built lists pnpm <=10 reads from package.json.
const onlyBuilt = new Set(root.onlyBuiltDependencies ?? []);
const neverBuilt = new Set(root.neverBuiltDependencies ?? []);
for (const [name, allowed] of Object.entries(root.allowBuilds ?? {})) {
(allowed ? onlyBuilt : neverBuilt).add(name);
}
const settings = {};
const scopedOnlyBuilt = scopeToLockfile(onlyBuilt);
if (scopedOnlyBuilt.length > 0) {
settings.onlyBuiltDependencies = scopedOnlyBuilt;
}
const scopedNeverBuilt = scopeToLockfile(neverBuilt);
if (scopedNeverBuilt.length > 0) {
settings.neverBuiltDependencies = scopedNeverBuilt;
}
if (root.supportedArchitectures) {
settings.supportedArchitectures = root.supportedArchitectures;
}
return Object.keys(settings).length > 0 ? settings : null;
}
/**
* Folds the pnpm <=10 package.json additions a standalone pruned output needs
* (build approvals from `getPrunedPnpmPackageJsonBuildSettings`, plus the
* `patchedDependencies` declaration) onto `packageJson` in place. Build-approval
* lists union the manifest's own entries with the carried ones so a project-level
* approval is never dropped. Does nothing when there is nothing to add.
*/
function applyPrunedPnpmPackageJsonSettings(packageJson, buildSettings, patchedDependencies) {
if (!buildSettings && !patchedDependencies) {
return;
}
const union = (a = [], b = []) => [
...new Set([...a, ...b]),
];
packageJson.pnpm ??= {};
if (buildSettings?.onlyBuiltDependencies) {
packageJson.pnpm.onlyBuiltDependencies = union(packageJson.pnpm.onlyBuiltDependencies, buildSettings.onlyBuiltDependencies);
}
if (buildSettings?.neverBuiltDependencies) {
packageJson.pnpm.neverBuiltDependencies = union(packageJson.pnpm.neverBuiltDependencies, buildSettings.neverBuiltDependencies);
}
if (buildSettings?.supportedArchitectures) {
packageJson.pnpm.supportedArchitectures = {
...buildSettings.supportedArchitectures,
...packageJson.pnpm.supportedArchitectures,
};
}
if (patchedDependencies) {
packageJson.pnpm.patchedDependencies = patchedDependencies;
}
}
function getPrunedLockfilePatchedKeys(prunedLockfileContent) {
const parsed = parsePnpmLockfileYaml(prunedLockfileContent);
return new Set(Object.keys(parsed?.patchedDependencies ?? {}));
}
/**
* Patch artifacts a standalone pruned output must ship to keep a `pnpm patch`
* workspace installable: the `.patch` files (path relative to the output root,
* plus content) and, on pnpm 10 and below, the `patchedDependencies` map to
* declare in the emitted package.json. On pnpm 11+ that map is carried in
* pnpm-workspace.yaml (see `getPrunedPnpmInstallSettingsYaml`), so
* `packageJsonPatchedDependencies` is null there. Both are scoped to the patches
* the pruned lockfile keeps. Returns the file contents so the file-writing prune
* paths and the bundler asset pipelines can each ship them their own way.
*/
function getPrunedPnpmPatchArtifacts(workspaceRootPath = workspace_root_1.workspaceRoot, prunedLockfileContent, precomputed) {
const patchedDependencies = precomputed?.patchedDependencies ??
getPrunedPatchedDependencies(workspaceRootPath, prunedLockfileContent);
if (Object.keys(patchedDependencies).length === 0) {
return { patchFiles: [], packageJsonPatchedDependencies: null };
}
const patchFiles = [];
// normalizePrunedPatchPath collapses the path, so two distinct sources can
// normalize to one shipped path, which would ship a single file for both
// entries and apply the wrong patch. Detect the clash and fail loudly rather
// than silently corrupt the output.
const shippedFrom = new Map();
for (const patchPath of new Set(Object.values(patchedDependencies))) {
// The config/lockfile side normalizes an absolute patch path under patches/,
// so read its source from that absolute location to keep the shipped file in
// sync; only a relative path resolves against the workspace root.
const source = (0, path_1.isAbsolute)(patchPath)
? patchPath
: (0, path_1.join)(workspaceRootPath, patchPath);
const destination = normalizePrunedPatchPath(patchPath);
const existingSource = shippedFrom.get(destination);
if (existingSource !== undefined && existingSource !== source) {
throw new Error(`Cannot prune pnpm patches: "${existingSource}" and "${source}" both ship to "${destination}" in the standalone output. Rename one so the patches do not collide.`);
}
shippedFrom.set(destination, source);
// A file check rather than a mere existence one: a path whose segments all
// normalize away (``, `.`, `..`) resolves to a directory, which would fail
// the read below with a raw EISDIR instead of taking the warn path.
if ((0, fileutils_1.fileExists)(source)) {
// Ship the patch under the `patches/<subpath>` path the pruned output
// declares, reading it from wherever the workspace kept it.
patchFiles.push({
path: destination,
content: (0, fs_1.readFileSync)(source, 'utf-8'),
});
}
else {
// The root config declares this patch but no file is there (already a
// broken workspace, the root install would fail too). Warn rather than
// drop the declaration: the pruned lockfile still lists the patch, so
// dropping only the config would trade this for a lockfile config mismatch.
logger_1.logger.warn(`Patch file "${patchPath}" referenced by patchedDependencies was not found; the pruned output declares the patch but cannot ship the file.`);
}
}
const pnpmMajor = resolvePnpmMajor(precomputed, workspaceRootPath);
return {
patchFiles,
packageJsonPatchedDependencies: pnpmMajor !== null && pnpmMajor < 11
? normalizePrunedPatchedDependencies(patchedDependencies)
: null,
};
}
const WORKSPACE_MODULES_DIR = 'workspace_modules';
/**
* Non-workspace local-path deps (`file:` tarballs/dirs, `link:` targets) ship
* under this single output directory rather than at their workspace-relative
* path. A generated `prune-lockfile` target declares dynamic output paths it
* cannot enumerate at generate time; corralling every vendored artifact under
* one directory lets `getPruneTargets` declare it, so a cache replay restores it
* (an artifact shipped outside a declared output is dropped on replay, breaking
* the standalone deploy). The manifest specifiers and pruned-lockfile refs are
* relocated into this directory to match; source reads strip it back off.
*/
exports.LOCAL_PATH_MODULES_DIR = 'local_path_modules';
/**
* Relocates a workspace-relative local-path into the shipped output directory.
* Injective, so `uncontainLocalPath` recovers the source path for every input:
* a workspace directory literally named `local_path_modules/` relocates like
* any other rather than being mistaken for an already-relocated path. Callers
* must therefore relocate each path exactly once.
*/
function containLocalPath(wsRelativePath) {
return `${exports.LOCAL_PATH_MODULES_DIR}/${wsRelativePath}`;
}
/**
* The workspace-relative source path of a shipped local-path artifact, i.e. the
* inverse of `containLocalPath`. A path that is not under the shipped directory
* (an unshippable target left at its original spec) is returned unchanged.
*/
function uncontainLocalPath(shippedPath) {
if (shippedPath === exports.LOCAL_PATH_MODULES_DIR) {
return '';
}
return shippedPath.startsWith(`${exports.LOCAL_PATH_MODULES_DIR}/`)
? shippedPath.slice(exports.LOCAL_PATH_MODULES_DIR.length + 1)
: shippedPath;
}
/**
* A `file:`/`link:` specifier with its path separators unified, so specifiers
* that differ only in how they were authored compare equal. Returns anything
* that is not a local-path specifier unchanged.
*/
function normalizeLocalPathSpec(spec) {
const protocol = spec.startsWith('link:')
? 'link:'
: spec.startsWith('file:')
? 'file:'
: null;
return protocol
? `${protocol}${(0, path_2.normalizePath)(spec.slice(protocol.length))}`
: spec;
}
/**
* A relocated `file:`/`link:` specifier read back as the source path it was
* relocated from, so it compares equal to the source specifier for the same
* target (`normalizeLocalPathSpec`). Relocation is injective and strips exactly
* one level, so only the relocated side of a comparison may go through this: a
* source path that itself starts with the shipped directory's name relocates
* like any other, and stripping it too would read it as a different target.
*/
function uncontainLocalPathSpec(spec) {
const normalized = normalizeLocalPathSpec(spec);
const protocol = normalized.startsWith('link:')
? 'link:'
: normalized.startsWith('file:')
? 'file:'
: null;
return protocol
? `${protocol}${uncontainLocalPath(normalized.slice(protocol.length))}`
: normalized;
}
/**
* The absolute source of a local-path target, or the reason it cannot ship into
* the pruned output. The containment check is lexical, so it cannot see where a
* symlinked source points: resolving it is what separates a link into the
* workspace, which ships like any other directory, from one that lands on the
* workspace root or leaves it. A dangling link is reported rather than accepted,
* which `lstat` would have done. Shared so the artifact collector and the
* closure validation cannot disagree about what the output actually carries.
*/
function resolveLocalPathSource(wsRelativePath, workspaceRootPath, workspaceRootRealPath) {
if (localPathEscapesOutput(wsRelativePath)) {
return { source: null, reason: 'outside-workspace' };
}
const attempted = (0, path_1.join)(workspaceRootPath, wsRelativePath);
let resolved;
try {
resolved = (0, fs_1.realpathSync)(attempted);
}
catch {
return { source: null, reason: 'missing', attempted };
}
// Shipping the root itself would copy the whole workspace into the output.
if (resolved === workspaceRootRealPath) {
return { source: null, reason: 'workspace-root', attempted, resolved };
}
if (!resolved.startsWith(`${workspaceRootRealPath}${path_1.sep}`)) {
return { source: null, reason: 'outside-workspace', attempted, resolved };
}
return { source: attempted, attempted, resolved };
}
/**
* Contains a workspace-relative `file:` path, leaving unshippable ones as-is.
* `synthesized` holds the copied-workspace-module paths the caller assembled,
* which are already output paths: they are recognized by identity rather than by
* their `workspace_modules/` prefix, so a workspace directory that happens to
* carry that name relocates like any other source.
*/
function containVendoredFilePath(wsRelativePath, synthesized) {
if (wsRelativePath === '' ||
wsRelativePath === '.' ||
synthesized.has(wsRelativePath) ||
localPathEscapesOutput(wsRelativePath)) {
return wsRelativePath;
}
return containLocalPath(wsRelativePath);
}
/**
* Contains the `file:` path in a lockfile token, which is either a bare spec
* (`file:X`) or one carrying the target's real package name (`name@file:X`, the
* shape pnpm records for an aliased dependency and uses as that package's key).
* Package keys and the refs that point at them go through this one function, so
* a renamed key and its refs cannot drift apart; pnpm rejects that mismatch with
* ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY. A resolved-peer suffix rides along in
* the sliced path (`name@file:X(peer@1.0.0)`) and survives untouched because
* containment only prepends.
*/
function containFileToken(token, synthesized) {
const marker = token.startsWith('file:') ? 'file:' : '@file:';
const index = token.startsWith('file:') ? 0 : token.indexOf(marker);
if (index === -1) {
return token;
}
const pathStart = index + marker.length;
// Normalize separators first, matching the artifact shipping side
// (getPrunedPnpmLocalPathArtifacts), so a backslash-authored token contains to
// the same posix path the artifacts ship to.
const path = (0, path_2.normalizePath)(token.slice(pathStart));
const contained = containVendoredFilePath(path, synthesized);
return contained === path
? token
: `${token.slice(0, pathStart)}${contained}`;
}
/**
* Warns when a workspace directory occupies the output path of a copied
* workspace module. Both spell `workspace_modules/<name>`, so no later pass can
* tell them apart: the vendored source is read as the copied module and never
* ships, leaving the lockfile pointing at a path the output does not carry.
*/
function warnOnWorkspaceModulePathCollision(sourcePackages, synthesizedModulePaths) {
for (const snapshot of Object.values(sourcePackages ?? {})) {
const directory = snapshot
?.resolution?.directory;
if (typeof directory === 'string' &&
synthesizedModulePaths.has((0, path_2.normalizePath)(directory))) {
logger_1.logger.warn(`Local-path dependency "file:${directory}" sits where the pruned output copies a workspace module of the same name, so it cannot ship separately. Move it out of ${WORKSPACE_MODULES_DIR}/ to deploy it.`);
}
}
}
/**
* Relocates every shippable non-workspace `file:` local-path reference in a
* pruned lockfile (package keys, resolutions, and snapshot/importer dependency
* refs) under `LOCAL_PATH_MODULES_DIR`, matching where the artifacts ship, so a
* standalone `pnpm install` resolves them. `link:` refs are relocated upstream
* (`containShippedLocalLinkRefs` for the source snapshots, the assembly's own
* synthesis sites for the rest); only `file:` paths, which the source lockfile
* carries verbatim, are contained here. Workspace-module and escaping paths
* are left untouched. Mutates `lockfile` in place; the key rename and every ref
* use the same `file:` path, so they stay in sync.
*
* Takes the normalized document rather than a looser shape: dependency refs are
* rewritten only where they are plain strings, which is what normalization
* guarantees. A raw v9 file records an importer ref as `{ specifier, version }`
* and would have its package key renamed while that ref kept pointing at the
* old path.
*/
function containShippedLocalFilePaths(lockfile, synthesizedModulePaths = new Set()) {
const synthesized = synthesizedModulePaths;
const containSnapshot = (snapshot) => {
if (!snapshot || typeof snapshot !== 'object') {
return;
}
const record = snapshot;
const resolution = record.resolution;
if (resolution && typeof resolution === 'object') {
if (typeof resolution.directory === 'string') {
// Normalize separators first, matching the artifact shipping side
// (getPrunedPnpmLocalPathArtifacts), so a backslash directory contains
// to the same posix path the artifacts ship to.
resolution.directory = containVendoredFilePath((0, path_2.normalizePath)(resolution.directory), synthesized);
}
if (typeof resolution.tarball === 'string') {
resolution.tarball = containFileToken(resolution.tarball, synthesized);
}
}
for (const section of LOCKFILE_DEP_SECTIONS) {
const deps = record[section];
if (!deps || typeof deps !== 'object') {
continue;
}
for (const [name, ref] of Object.entries(deps)) {
if (typeof ref === 'string') {
deps[name] = containFileToken(ref, synthesized);
}
}
}
};
if (lockfile.packages) {
const contained = {};
for (const [key, snapshot] of Object.entries(lockfile.packages)) {
containSnapshot(snapshot);
contained[containFileToken(key, synthesized)] = snapshot;
}
lockfile.packages = contained;
}
for (const importer of Object.values(lockfile.importers ?? {})) {
containSnapshot(importer);
}
}
/**
* Relocates the shippable `link:` refs a source lockfile's package snapshots
* carry, so they resolve from the pruned output's root. pnpm reads a snapshot
* `link:` ref against the lockfile directory, which is the workspace root at
* source and the deploy root in the output, so a ref left verbatim points at a
* path the standalone output does not carry once the target ships under
* `LOCAL_PATH_MODULES_DIR`. A target that cannot ship keeps its ref, matching
* the copied manifest; the artifact collector reports why.
*
* Takes the source snapshots alone rather than the assembled document: the
* assembly relocates its own refs at their synthesis site, and a second pass
* cannot tell an already-relocated path from a workspace path that starts with
* the shipped directory's name. Mutates the snapshots in place.
*/
function containShippedLocalLinkRefs(sourceSnapshots) {
for (const snapshot of Object.values(sourceSnapshots ?? {})) {
if (!snapshot || typeof snapshot !== 'object') {
continue;
}
const record = snapshot;
for (const section of LOCKFILE_DEP_SECTIONS) {
const deps = record[section];
if (!deps || typeof deps !== 'object') {
continue;
}
for (const [name, ref] of Object.entries(deps)) {
if (typeof ref !== 'string' || !ref.startsWith('link:')) {
continue;
}
deps[name] = relocatePrunedLocalPathSpec(ref, '', '')?.spec ?? ref;
}
}
}
}
const LOCKFILE_DEP_SECTIONS = [
'dependencies',
'optionalDependencies',
'devDependencies',
];
/** True when a workspace-root-relative path escapes the output root. */
function localPathEscapesOutput(wsRelativePath) {
// An absolute path counts as escaping: join(workspaceRoot, target) would
// silently rebase it under the workspace root instead of resolving it.
return ((0, path_1.isAbsolute)(wsRelativePath) || wsRelativePath.split(/[\\/]/).includes('..'));
}
/** True when a resolution `directory` points at a copied workspace module. */
function isUnderWorkspaceModules(directory) {
return (directory === WORKSPACE_MODULES_DIR ||
directory.startsWith(`${WORKSPACE_MODULES_DIR}/`) ||
directory.startsWith(`${WORKSPACE_MODULES_DIR}\\`));
}
/**
* pnpm resolves every pruned-lockfile `link:` ref relative to the lockfile
* directory: snapshot refs are read against it directly, and the output's only
* importer is `.`, the lockfile directory itself. Normalize the ref to a
* workspace-root-relative posix path. An absolute target is returned as-is
* (join would rebase it) so the escape check can reject it.
*/
function resolveLinkTarget(linkRef) {
const refPath = linkRef.slice('link:'.length);
return (0, path_2.normalizePath)((0, path_1.isAbsolute)(refPath) ? refPath : (0, path_1.join)(refPath));
}
function parsePrunedLockfile(content) {
return parsePnpmLockfileYaml(content);
}
/**
* The non-workspace `link:` target directories the pruned lockfile references
* (root importer versions and package snapshot refs, e.g. from copied
* workspace modules and vendored `file:` directories), each resolved to a
* workspace-root-relative posix path paired with the lockfile ref it came from.
* Targets under `workspace_modules/` are excluded (copy-workspace-modules ships
* those); a target that escapes the workspace root is included so the caller
* can report it; a target that is the workspace root itself is skipped with a
* warning (shipping it would copy the entire workspace).
*/
// The link-closure validator and the local-path artifact collector both walk
// the same parsed lockfile in one prune run; caching per document runs the walk
// (and its warnings) once. Callers must not mutate the returned array.
const collectedPrunedLinkTargets = new WeakMap();
function collectPrunedLinkTargetDirs(parsed) {
const cached = collectedPrunedLinkTargets.get(parsed);
if (cached) {
return cached;
}
const targets = new Map();
const addRef = (value) => {
const ref = typeof value === 'string' ? value : value?.version;
if (typeof ref !== 'string' || !ref.startsWith('link:')) {
return;
}
const target = resolveLinkTarget(ref);
if (target === '' || target === '.') {
logger_1.logger.warn(`Local-path dependency "${ref}" resolves to the workspace root itself and cannot be shipped into the pruned output.`);
return;
}
if (!isUnderWorkspaceModules(target) && !targets.has(target)) {
targets.set(target, ref);
}
};
// Every link: ref resolves from the lockfile dir (see resolveLinkTarget), so
// scan the root importer and both dependency-carrying package sections.
const rootImporter = parsed.importers?.['.'] ?? {};
for (const section of LOCKFILE_DEP_SECTIONS) {
for (const value of Object.values(rootImporter[section] ?? {})) {
addRef(value);
}
}
for (const section of [parsed.snapshots, parsed.packages]) {
for (const snapshot of Object.values(section ?? {})) {
for (const depSection of LOCKFILE_DEP_SECTIONS) {
for (const value of Object.values(snapshot?.[depSection] ?? {})) {
addRef(value);
}
}
}
}
const result = [...targets].map(([target, ref]) => ({ target, ref }));
collectedPrunedLinkTargets.set(parsed, result);
return result;
}
/**
* The non-workspace local-path packages a standalone pruned output must ship so
* `pnpm install` can resolve them. The pruned lockfile records each path
* relocated under `LOCAL_PATH_MODULES_DIR` (see containLocalPath), so `path`
* ships there while `sourcePath` reads from the original workspace location.
* Three shapes ship: a `file:` tarball (the `.tgz`), a `file:` directory not
* under `workspace_modules/` (copied workspace modules ship via
* copy-workspace-modules and are skipped here), and a `link:` target directory
* (a root importer `link:` version, or a package `link:` snapshot ref).
* `node_modules` is filtered from every directory copy; a symlink inside a
* shipped tree is skipped with a warning while a symlinked root ships when it
* resolves under the workspace root; entries are deduped by destination; a
* source missing on disk or resolving outside the workspace root is skipped
* with a warning (not reproducibly deployable). Source paths rather than bytes
* so the file-writing prune paths can copy without buffering whole trees.
*/
function getPrunedPnpmLocalPathArtifacts(workspaceRootPath = workspace_root_1.workspaceRoot, prunedLockfileContent) {
if (!prunedLockfileContent) {
return [];
}
const parsed = parsePrunedLockfile(prunedLockfileContent);
if (!parsed) {
return [];
}
const artifacts = [];
const shippedRoots = new Set();
const seenDestinations = new Set();
// Compared against every resolved source below, so the containment check is
// not defeated by a workspace root that is itself reached through a link
// (`/tmp` on macOS). Falls back to the declared root when it cannot be
// resolved; the per-target resolution then reports the failure.
let workspaceRootRealPath;
try {
workspaceRootRealPath = (0, fs_1.realpathSync)(workspaceRootPath);
}
catch {
workspaceRootRealPath = workspaceRootPath;
}
// The absolute source for a shippable target, or null (with a warning) when
// the target escapes the workspace root or is missing on disk.
const resolveShippableSource = (wsRelativePath, origin) => {
const resolution = resolveLocalPathSource(wsRelativePath, workspaceRootPath, workspaceRootRealPath);
if (resolution.source) {
return resolution.source;
}
if (resolution.reason === 'missing') {
logger_1.logger.warn(`Local-path dependency "${origin}" was not found at ${resolution.attempted}; the pruned output references it but cannot ship it.`);
}
else if (resolution.reason === 'workspace-root') {
warnUnshippableLocalPathSpec(`"${origin}"`, 'workspace-root');
}
else {
logger_1.logger.warn(resolution.resolved
? `Local-path dependency "${origin}" resolves to ${resolution.resolved}, outside the workspace root, and cannot be shipped into the pruned output. Vendor it inside the workspace to deploy it.`
: `Local-path dependency "${origin}" resolves outside the workspace root and cannot be shipped into the pruned output. Vendor it inside the workspace to deploy it.`);
}
return null;
};
// Callers pass the shipped path (relocated under LOCAL_PATH_MODULES_DIR, as the
// pruned lockfile records it); it is the artifact destination, while the source
// is read from the original workspace location.
const shipFile = (shippedPath, origin) => {
if (shippedRoots.has(shippedPath)) {
return;
}
shippedRoots.add(shippedPath);
const source = resolveShippableSource(uncontainLocalPath(shippedPath), origin);
if (!source || seenDestinations.has(shippedPath)) {
return;
}
seenDestinations.add(shippedPath);
artifacts.push({ path: shippedPath, sourcePath: source });
};
const shipDirectory = (shippedPath, origin) => {
if (shippedRoots.has(shippedPath)) {
return;
}
shippedRoots.add(shippedPath);
const source = resolveShippableSource(uncontainLocalPath(shippedPath), origin);
if (!source) {
return;
}
if (!(0, fs_1.statSync)(source).isDirectory()) {
logger_1.logger.warn(`Local-path dependency "${origin}" is not a directory at ${source}; the pruned output references it but cannot ship it.`);
return;
}
// Walk the tree, skipping node_modules, deduping by destination.
const walk = (absoluteDir, destinationDir) => {
for (const entry of (0, fs_1.readdirSync)(absoluteDir, { withFileTypes: true })) {
if (entry.name === 'node_modules') {
continue;
}
const absoluteEntry = (0, path_1.join)(absoluteDir, entry.name);
const destinationEntry = `${destinationDir}/${entry.name}`;
if (entry.isDirectory()) {
walk(absoluteEntry, destinationEntry);
}
else if (entry.isFile()) {
if (seenDestinations.has(destinationEntry)) {
continue;
}
seenDestinations.add(destinationEntry);
artifacts.push({ path: destinationEntry, sourcePath: absoluteEntry });
}
else if (entry.isSymbolicLink()) {
// Following links risks cycles and machine-specific targets, so they
// are not shipped; surface the gap instead of silently dropping it.
logger_1.logger.warn(`Local-path dependency "${origin}" contains a symbolic link at ${absoluteEntry}, which is not shipped into the pruned output.`);
}
}
};
walk(source, shippedPath);
};
// file: tarball + file: directory resolutions (resolutions live in `packages`).
for (const snapshot of Object.values(parsed.packages ?? {})) {
const tarball = snapshot?.resolution?.tarball;
if (tarball?.startsWith('file:')) {
shipFile(tarball.slice('file:'.length), tarball);
continue;
}
const directory = snapshot?.resolution?.directory;
if (directory && !isUnderWorkspaceModules(directory)) {
shipDirectory((0, path_2.normalizePath)(directory), `file:${directory}`);
}
}
// link: targets (root importer versions + directory-package snapshot refs).
for (const { target, ref } of collectPrunedLinkTargetDirs(parsed)) {
shipDirectory(target, ref);
}
return artifacts;
}
/**
* Whether the pruned lockfile references a non-workspace local path at all.
* Reads the same refs as `getPrunedPnpmLocalPathArtifacts` without walking the
* trees behind them, so a caller that only needs the answer neither lists every
* shipped file nor repeats that function's per-target warnings.
*/
function prunedLockfileReferencesLocalPaths(prunedLockfileContent) {
const parsed = parsePrunedLockfile(prunedLockfileContent);
if (!parsed) {
return false;
}
for (const snapshot of Object.values(parsed.packages ?? {})) {
if (snapshot?.resolution?.tarball?.startsWith('file:')) {
return true;
}
const directory = snapshot?.resolution?.directory;
if (directory && !isUnderWorkspaceModules(directory)) {
return true;
}
}
return collectPrunedLinkTargetDirs(parsed).length > 0;
}
/**
* Fails the pruned build when a shipped local-path target has a required
* dependency that will not be resolvable in the standalone deploy: a `link:`
* target (a symlink, not a packed package, so pnpm never installs its
* dependency closure) or a `file:` directory package whose lockfile entry
* carries no dependency edges (a peer backfilled when `autoInstallPeers` is
* off). Both install, `pnpm install --frozen-lockfile` exits 0, and the target
* resolves its `require`s only from the deploy-root node_modules, so a
* required dep missing there fails at runtime with MODULE_NOT_FOUND; this
* throws at build time with the remedy.
*
* Only a required dep absent from the app's installed direct deps fails. A
* peer or optional dep of the target, or a required dep present only in the
* app's devDependencies (a `--prod` install may omit it), warns instead; these
* are not provably broken. The app's own peerDependencies count as installed:
* the pruned root importer folds them into `dependencies` (mirroring pnpm's
* autoInstallPeers). A backfilled `file:` tarball peer's manifest is inside
* the archive and is not read, so its closure is not validated. pnpm-only;
* call sites gate on the package manager.
*/
function validatePrunedLocalPathClosure(packageJson, workspaceRootPath, prunedLockfileContent) {
if (!prunedLockfileContent) {
return;
}
const parsed = parsePrunedLockfile(prunedLockfileContent);
if (!parsed) {
return;
}
// Targets are keyed by their original workspace source path (the pruned
// lockfile records them relocated under LOCAL_PATH_MODULES_DIR; strip it back
// to read the target's manifest from disk).
const targets = new Map();
// Copied workspace modules and entries with resolved edges install their
// recorded closure and are skipped; a link: target for the same path wins the
// kind so the failure message names the sharper cause.
for (const [key, entry] of Object.entries(parsed.packages ?? {})) {
const directory = entry?.resolution?.directory;
if (!directory || isUnderWorkspaceModules(directory)) {
continue;
}
const source = uncontainLocalPath((0, path_2.normalizePath)(directory)).replace(/\/+$/, '');
// The workspace root is not a shippable target, and validating it would
// fail the build over the root manifest's own dependencies.
if (source === '' || source === '.' || localPathEscapesOutput(source)) {
continue;
}
const snapshot = parsed.snapshots?.[key] ?? entry;
const hasEdges = LOCKFILE_DEP_SECTIONS.some((section) => Object.keys(snapshot?.[section] ?? {}).length > 0);
if (!hasEdges) {
targets.set(source, 'directory');
}
}
for (const { target } of collectPrunedLinkTargetDirs(parsed)) {
const source = uncontainLocalPath(target);
if (!localPathEscapesOutput(source)) {
targets.set(source, 'link');
}
}
if (targets.size === 0) {
return;
}
const rootInstalled = new Set([
...Object.keys(packageJson.dependencies ?? {}),
...Object.keys(packageJson.optionalDependencies ?? {}),
...Object.keys(packageJson.peerDependencies ?? {}),
]);
const rootDev = new Set(Object.keys(packageJson.devDependencies ?? {}));
const appName = packageJson.name || 'the app';
let workspaceRootRealPath;
try {
workspaceRootRealPath = (0, fs_1.realpathSync)(workspaceRootPath);
}
catch {
workspaceRootRealPath = workspaceRootPath;
}
for (const [target, kind] of targets) {
// A target the output cannot carry has no closure to break in the deploy,
// and the artifact collector reports why it cannot ship. Resolving it here
// too is what keeps a symlinked target from failing the build over the
// manifest of whatever it points at.
if (!resolveLocalPathSource(target, workspaceRootPath, workspaceRootRealPath)
.source) {
continue;
}
const manifestPath = (0, path_1.join)(workspaceRootPath, target, 'package.json');
if (!(0, fs_1.existsSync)(manifestPath)) {
continue;
}
const descriptor = kind === 'link' ? 'linked package' : 'local package';
let targetManifest;
try {
targetManifest = (0, fileutils_1.readJsonFile)(manifestPath);
}
catch {
logger_1.logger.warn(`${descriptor} ${target} has an unreadable package.json at ${manifestPath}; its dependency closure was not validated for the standalone deploy.`);
continue;
}
const targetName = targetManifest.name || target;
for (const dep of Object.keys(targetManifest.dependencies ?? {})) {
if (rootInstalled.has(dep)) {
continue;
}
if (rootDev.has(dep)) {
logger_1.logger.warn(`${descriptor} ${targetName} requires ${dep}, which is only a devDependency of ${appName}; a production (--prod) install of the standalone deploy would omit it.`);
continue;
}
throw new Error(kind === 'link'
? `linked package ${targetName} requires ${dep}, which won't be resolvable in the standalone deploy (link: cannot provide a self-contained dependency closure). Convert ${targetName} to a file: dependency for a self-contained artifact, or add ${dep} to ${appName}'s dependencies.`
: `local package ${targetName} requires ${dep}, which won't be resolvable in the standalone deploy (its dependency closure was never resolved into the pruned lockfile). Declare ${targetName} as a regular dependency of the package that peer-depends on it, enable autoInstallPeers, or add ${dep} to ${appName}'s dependencies.`);
}
for (const dep of [
...Object.keys(targetManifest.peerDependencies ?? {}),
...Object.keys(targetManifest.optionalDependencies ?? {}),
]) {
if (rootInstalled.has(dep) || rootDev.has(dep)) {
continue;
}
logger_1.logger.warn(`${descriptor} ${targetName} may need ${dep} at runtime, which is not a dependency of ${appName}; add it to ${appName} if it fails to resolve.`);
}
}
}
/**
* Relocates a `file:`/`link:` specifier recorded relative to `sourceDir` so it
* resolves from `destDir` (both workspace-root-relative posix paths, '' meaning
* the workspace root itself) to the target's shipped location under
* `LOCAL_PATH_MODULES_DIR`. Returns null for a non-local-path spec. When the
* target cannot ship into the pruned output, `spec` is returned unchanged with
* the `reason`: absolute or escaping the workspace root (`outside-workspace`),
* or the workspace root itself (`workspace-root`).
* Every layer of the pruned output (app manifest, copied-module manifests,
* lockfile snapshot refs) relocates through this one function so the layers
* cannot disagree.
*/
function relocatePrunedLocalPathSpec(spec, sourceDir, destDir) {
const protocol = spec.startsWith('link:')
? 'link:'
: spec.startsWith('file:')
? 'file:'
: null;
if (!protocol) {
return null;
}
// Normalize separators before joining, matching the artifact shipping side:
// a backslash-authored spec is one opaque segment to a posix join(), so its
// `..` never resolves and an in-workspace target reads as escaping.
const rawPath = (0, path_2.normalizePath)(spec.slice(protocol.length));
// join() does not reset on an absolute segment; it would silently rebase the
// target under sourceDir, so reject absolutes up front.
if ((0, path_1.isAbsolute)(rawPath)) {
return { spec, reason: 'outside-workspace' };
}
// join() keeps a trailing separator, so `../../` arrives as './' rather than
// '.' and would slip past the workspace-root check below.
const wsRelativeTarget = (0, path_2.normalizePath)((0, path_1.join)(sourceDir, rawPath)).replace(/\/+$/, '');
// A `..` segment escapes the workspace, and so does an absolute result: the
// absolute-spec check above cannot see an absolute `sourceDir`, which join()
// carries through and containment would turn into
// `local_path_modules//abs/...`, a path the output never ships.
if ((0, path_1.isAbsolute)(wsRelativeTarget) ||
wsRelativeTarget.split('/').includes('..')) {
return { spec, reason: 'outside-workspace' };
}
if (wsRelativeTarget === '' || wsRelativeTarget === '.') {
return { spec, reason: 'workspace-root' };
}
// The target ships under LOCAL_PATH_MODULES_DIR (see containLocalPath), so
// resolve the spec against its shipped location, not its workspace path.
const shippedTarget = containLocalPath(wsRelativeTarget);
const relocated = destDir === '' || destDir === '.'
? shippedTarget
: (0, path_2.normalizePath)((0, path_1.relative)(destDir, shippedTarget));
return { spec: `${protocol}${relocated}` };
}
/**
* Warns when a pruned pnpm lockfile needs install-time artifacts that only
* `generatePrunedDeployOutput` ships, naming the ones this workspace actually
* needs. For callers of
* the bare `createLockFile`, which hands back a lockfile and nothing else: the
* pieces below live outside it, so an output assembled from the lockfile and
* the manifest alone installs without the workspace's build-script approvals,
* patches, or vendored local paths. Silent when the workspace needs none of
* them, which is the common case.
*/
function warnIncompletePrunedPnpmOutput(lockFileContent, workspaceRootPath = workspace_root_1.workspaceRoot) {
const missing = [];
// Keyed on the approvals themselves rather than on the emitted file, which a
// workspace declaring only patches still gets.
const workspaceSettings = getPrunedPnpmWorkspaceSettings(workspaceRootPath, lockFileContent);
if (workspaceSettings?.allowBuilds !== undefined ||
workspaceSettings?.supportedArchitectures !== undefined ||
getPrunedPnpmPackageJsonBuildSettings(workspaceRootPath, lockFileContent)) {
missing.push('the build-script approvals and supportedArchitectures the workspace declares');
}
if (Object.keys(getPrunedPatchedDependencies(workspaceRootPath, lockFileContent)).length > 0) {
missing.push('the patch files its patchedDependencies declare');
}
if (prunedLockfileReferencesLocalPaths(lockFileContent)) {
missing.push('the vendored file:/link: dependencies it references');
}
if (missing.length === 0) {
return;
}
output_1.output.warn({
title: 'The pruned pnpm lockfile needs artifacts this call does not return',
bodyLines: [
`A standalone install of the output will be missing ${missing.join(', and ')}.`,
'Use generatePrunedDeployOutput to ship them.',
],
});
}
/** Warns that a local-path target cannot ship, with the reason-specific remedy. */
function warnUnshippableLocalPathSpec(description, reason) {
logger_1.logger.warn(reason === 'workspace-root'
? `Local-path dependency ${description} resolves to the workspace root itself and cannot be shipped into the pruned output.`
: `Local-path dependency ${description} resolves outside the workspace root and cannot be shipped into the pruned output. Vendor it inside the workspace to deploy it.`);
}
/**
* Moves a peer dependency into `dependencies` under the given specifier: pnpm
* rejects a `file:`/`link:` spec under peerDependencies outright, so a shipped
* local path or workspace module declared there would fail the whole install.
* The `peerDependenciesMeta` entry goes with it, since the optional/required
* marker is orphaned once the dependency is no longer a peer.
*/
function movePeerDependencyToDependencies(packageJson, name, spec) {
(packageJson.dependencies ??= {})[name] = spec;
if (packageJson.peerDependencies) {
delete packageJson.peerDependencies[name];
}
if (packageJson.peerDependenciesMeta) {
delete packageJson.peerDependenciesMeta[name];
}
}
/**
* Drops a `peerDependencies`/`peerDependenciesMeta` section left empty by
* `movePeerDependencyToDependencies`, so a manifest that declared nothing but
* moved peers does not ship an empty section.
*/
function dropEmptyPeerDependencySections(packageJson) {
if (packageJson.peerDependencies &&
Object.keys(packageJson.peerDependencies).length === 0) {
delete packageJson.peerDependencies;
}
if (packageJson.peerDependenciesMeta &&
Object.keys(packageJson.peerDependenciesMeta).length === 0) {
delete packageJson.peerDependenciesMeta;
}
}
/**
* Rewrites a standalone pruned manifest's non-workspace local-path specifiers
* (`file:` tarball/dir, `link:` dir) to their shipped location under
* `LOCAL_PATH_MODULES_DIR`, so a non-frozen `pnpm install` of the deploy
* output resolves them from where the shipped source sits (see
* `getPrunedPnpmLocalPathArtifacts`). Mutates `packageJson` in place.
* pnpm-only; call sites gate on the package manager.
*
* Per specifier, in order: resolve a `catalog:` reference first (the bundler's
* `createPackageJson` does not), skip a workspace package (copied to
* `workspace_modules/`), then relocate from `projectRoot`-relative to the
* shipped location. A `file:`/`link:` peer dependency is moved into
* `dependencies` with its `peerDependenciesMeta` entry dropped even when the
* target cannot ship (pnpm rejects such a spec under peerDependencies
* outright, so leaving it would fail the whole install), mirroring the
* workspace-module handling. An unshippable target otherwise keeps its
* specifier, with a warning.
*/
function rewritePrunedLocalPathSpecifiers(packageJson, projectRoot, workspaceRootPath, workspacePackageNames) {
const catalogManager = (0, catalog_1.getCatalogManager)(workspaceRootPath);
const sections = [
'dependencies',
'optionalDependencies',
'devDependencies',
'peerDependencies',
];
for (const section of sections) {
const deps = packageJson[section];
if (!deps) {
continue;
}
for (const [name, specifier] of Object.entries(deps)) {
let resolved = specifier;
if (catalogManager?.isCatalogReference(specifier)) {
const resolvedCatalog = catalogManager.resolveCatalogReference(workspaceRootPath, name, specifier);
if (resolvedCatalog) {
resolved = resolvedCatalog;
deps[name] = resolvedCatalog;
}
}
if (workspacePackageNames.has(name)) {
continue;
}
const relocation = relocatePrunedLocalPathSpec(resolved, projectRoot, '');
if (!relocation) {
continue;
}
if (relocation.reason) {
warnUnshippableLocalPathSpec(`"${name}": "${resolved}"`, relocation.reason);
}
if (section === 'peerDependencies') {
// Moved even when the target cannot ship: pnpm rejects the spec here
// either way, so leaving it would fail the whole install.
movePeerDependencyToDependencies(packageJson, name, relocation.spec);
}
else if (!relocation.reason) {
deps[name] = relocation.spec;
}
}
}
dropEmptyPeerDependencySections(packageJson);
}
/**
* The pnpm install-time artifacts a standalone pruned output needs, as data
* for a caller to write or emit: the settings-only pnpm-workspace.yaml, the
* `pnpm patch` files, and the non-workspace local-path dependencies (carried
* as a source path rather than content so a directory sink can copy them
* straight across). Everything is resolved before returning, so a colliding
* patch path aborts before the caller ships anything. The pnpm <=10
* build-script approvals and `patchedDependencies` declaration are folded onto
* `packageJson` in place (see `getPrunedPnpmPackageJsonBuildSettings`), so
* write or emit the manifest after this returns.
*
* Pass `includeLocalPathArtifacts: false` for the root-lockfile fallback
* (`pruned: false` from `createPrunedLockfile`): its importer references the
* whole workspace, so shipping its local-path trees would copy unrelated
* sources into the output.
*/
function getPrunedPnpmInstallArtifacts(workspaceRootPath, prunedLockfileContent, packageJson, options) {
const config = {
pnpmMajor: getPnpmMajorOrWarn(workspaceRootPath),
patchedDependencies: getPrunedPatchedDependencies(workspaceRootPath, prunedLockfileContent),
};
const { patchFiles, packageJsonPatchedDependencies } = getPrunedPnpmPatchArtifacts(workspaceRootPath, prunedLockfileContent, config);
const yaml = getPrunedPnpmInstallSettingsYaml(workspaceRootPath, prunedLockfileContent, config);
const artifacts = [
{ path: 'pnpm-workspace.yaml', content: yaml },
];
artifacts.push(...patchFiles);
if (options?.includeLocalPathArtifacts !== false) {
artifacts.push(...getPrunedPnpmLocalPathArtifacts(workspaceRootPath, prunedLockfileContent));
}
const buildSettings = getPrunedPnpmPackageJsonBuildSettings(workspaceRootPath, prunedLockfileContent, config);
applyPrunedPnpmPackageJsonSettings(packageJson, buildSettings, packageJsonPatchedDependencies);
return artifacts;
}