From 8ac7c722b8f7df1e9f651acbe40c0acfd4913581 Mon Sep 17 00:00:00 2001 From: Ethan Setnik Date: Fri, 15 May 2026 17:39:00 -0400 Subject: [PATCH] feat: respect pnpm minimumReleaseAge from pnpm-workspace.yaml When invoked as the pnpm/pnpx shim, walk up from cwd looking for pnpm-workspace.yaml (or a pnpm field in package.json) and use its minimumReleaseAge and minimumReleaseAgeExclude as the floor and exclusion list. Existing CLI args, env vars, and ~/.safe-chain/config.json still override, so the project's pnpm config can act as a single source of truth without forcing users to duplicate settings across two systems. Fixes #460 Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 13 +- .../src/config/packageManagerName.js | 23 ++ .../src/config/pnpmWorkspaceConfig.js | 320 +++++++++++++++++ .../src/config/pnpmWorkspaceConfig.spec.js | 337 ++++++++++++++++++ packages/safe-chain/src/config/settings.js | 22 +- .../src/config/settings.pnpmWorkspace.spec.js | 192 ++++++++++ .../packagemanager/currentPackageManager.js | 2 + 7 files changed, 906 insertions(+), 3 deletions(-) create mode 100644 packages/safe-chain/src/config/packageManagerName.js create mode 100644 packages/safe-chain/src/config/pnpmWorkspaceConfig.js create mode 100644 packages/safe-chain/src/config/pnpmWorkspaceConfig.spec.js create mode 100644 packages/safe-chain/src/config/settings.pnpmWorkspace.spec.js diff --git a/README.md b/README.md index 039e355..258d79b 100644 --- a/README.md +++ b/README.md @@ -240,9 +240,20 @@ You can set the minimum package age through multiple sources (in order of priori } ``` +4. **pnpm workspace config** (only when invoked as the `pnpm` or `pnpx` shim): + + Safe Chain reads `minimumReleaseAge` (in minutes — see [pnpm settings](https://pnpm.io/settings#minimumreleaseage)) from the nearest `pnpm-workspace.yaml`, falling back to a `pnpm` field in `package.json`. Any of the higher-priority sources above override it. + + ```yaml + # pnpm-workspace.yaml + minimumReleaseAge: 1440 # 24 hours + minimumReleaseAgeExclude: + - "@aikidosec/*" + ``` + ### Excluding Packages -Exclude trusted packages from minimum age filtering via environment variable or config file (both are merged). Use `@scope/*` to trust all packages from an organization: +Exclude trusted packages from minimum age filtering via environment variable or config file (both are merged). When invoked as `pnpm`/`pnpx`, `minimumReleaseAgeExclude` from `pnpm-workspace.yaml` is also merged in. Use `@scope/*` to trust all packages from an organization: ```shell export SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS="@aikidosec/*" diff --git a/packages/safe-chain/src/config/packageManagerName.js b/packages/safe-chain/src/config/packageManagerName.js new file mode 100644 index 0000000..82a4c1e --- /dev/null +++ b/packages/safe-chain/src/config/packageManagerName.js @@ -0,0 +1,23 @@ +/** + * Tracks the package manager name (e.g. "pnpm", "npm", "yarn") that this + * invocation is shimming. Stored as a small standalone module so that + * lightweight config readers can branch on it without transitively importing + * the heavy package-manager creator graph. + */ + +/** @type {{name: string | null}} */ +const state = { name: null }; + +/** + * @param {string | null} name + */ +export function setPackageManagerName(name) { + state.name = name; +} + +/** + * @returns {string | null} + */ +export function getPackageManagerName() { + return state.name; +} diff --git a/packages/safe-chain/src/config/pnpmWorkspaceConfig.js b/packages/safe-chain/src/config/pnpmWorkspaceConfig.js new file mode 100644 index 0000000..b2f5244 --- /dev/null +++ b/packages/safe-chain/src/config/pnpmWorkspaceConfig.js @@ -0,0 +1,320 @@ +import fs from "fs"; +import path from "path"; + +/** + * @typedef {Object} PnpmWorkspaceSettings + * @property {number | undefined} minimumReleaseAgeMinutes + * @property {string[]} minimumReleaseAgeExclude + */ + +/** @returns {PnpmWorkspaceSettings} */ +function emptySettings() { + return { + minimumReleaseAgeMinutes: undefined, + minimumReleaseAgeExclude: [], + }; +} + +/** @type {{ resolved: boolean, value: PnpmWorkspaceSettings }} */ +const cache = { + resolved: false, + value: emptySettings(), +}; + +/** + * Resets the cached pnpm workspace settings. Intended for tests. + */ +export function resetPnpmWorkspaceConfigCache() { + cache.resolved = false; + cache.value = emptySettings(); +} + +/** + * Walks up from `process.cwd()` looking for the nearest `pnpm-workspace.yaml`, + * falling back to a `pnpm` field inside `package.json`. Returns parsed settings + * the first time it is called and caches the result for the process lifetime. + * + * @returns {PnpmWorkspaceSettings} + */ +export function getPnpmWorkspaceSettings() { + if (cache.resolved) { + return cache.value; + } + cache.resolved = true; + + const found = findPnpmConfig(process.cwd()); + if (found) { + cache.value = found; + } + return cache.value; +} + +/** + * The minimum release age (in hours) declared by the nearest pnpm workspace + * config, or undefined if not declared. + * + * @returns {number | undefined} + */ +export function getMinimumReleaseAgeHours() { + const { minimumReleaseAgeMinutes } = getPnpmWorkspaceSettings(); + if (minimumReleaseAgeMinutes === undefined) { + return undefined; + } + return minimumReleaseAgeMinutes / 60; +} + +/** + * @returns {string[]} + */ +export function getMinimumReleaseAgeExclusions() { + return getPnpmWorkspaceSettings().minimumReleaseAgeExclude; +} + +/** + * @param {string} startDir + * @returns {PnpmWorkspaceSettings | undefined} + */ +function findPnpmConfig(startDir) { + let dir = path.resolve(startDir); + while (true) { + const workspacePath = path.join(dir, "pnpm-workspace.yaml"); + if (fs.existsSync(workspacePath)) { + const parsed = safeParseYaml(workspacePath); + if (parsed) { + return parsed; + } + } + + const packageJsonPath = path.join(dir, "package.json"); + if (fs.existsSync(packageJsonPath)) { + const parsed = safeParsePackageJsonPnpmField(packageJsonPath); + if (parsed) { + return parsed; + } + // package.json exists but has no pnpm field — keep walking up; in a + // pnpm monorepo the relevant config lives at the workspace root. + } + + const parent = path.dirname(dir); + if (parent === dir) { + return undefined; + } + dir = parent; + } +} + +/** + * @param {string} filePath + * @returns {PnpmWorkspaceSettings | undefined} + */ +function safeParseYaml(filePath) { + let content; + try { + content = fs.readFileSync(filePath, "utf8"); + } catch { + return undefined; + } + return parsePnpmWorkspaceYaml(content); +} + +/** + * @param {string} filePath + * @returns {PnpmWorkspaceSettings | undefined} + */ +function safeParsePackageJsonPnpmField(filePath) { + let pkg; + try { + pkg = JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return undefined; + } + if (!pkg || typeof pkg !== "object" || !pkg.pnpm || typeof pkg.pnpm !== "object") { + return undefined; + } + return { + minimumReleaseAgeMinutes: validatePositiveNumber(pkg.pnpm.minimumReleaseAge), + minimumReleaseAgeExclude: validateStringArray(pkg.pnpm.minimumReleaseAgeExclude), + }; +} + +/** + * Minimal YAML parser scoped to the keys we read from `pnpm-workspace.yaml`: + * `minimumReleaseAge` (numeric scalar) and `minimumReleaseAgeExclude` + * (block or flow list of strings). All other top-level keys are ignored. + * + * @param {string} content + * @returns {PnpmWorkspaceSettings} + */ +export function parsePnpmWorkspaceYaml(content) { + /** @type {PnpmWorkspaceSettings} */ + const result = { + minimumReleaseAgeMinutes: undefined, + minimumReleaseAgeExclude: [], + }; + + const lines = content.split(/\r?\n/); + let i = 0; + while (i < lines.length) { + const rawLine = lines[i]; + const line = stripYamlComment(rawLine); + + if (line.trim() === "" || isTopLevelIndented(rawLine)) { + i++; + continue; + } + + const keyMatch = line.match(/^([A-Za-z_][\w-]*)\s*:(.*)$/); + if (!keyMatch) { + i++; + continue; + } + + const key = keyMatch[1]; + const rest = keyMatch[2].trim(); + + if (key === "minimumReleaseAge") { + result.minimumReleaseAgeMinutes = parseScalarNumber(rest); + i++; + continue; + } + + if (key === "minimumReleaseAgeExclude") { + if (rest.startsWith("[")) { + result.minimumReleaseAgeExclude = parseFlowArray(rest); + i++; + continue; + } + const { items, nextIndex } = parseBlockArray(lines, i + 1); + result.minimumReleaseAgeExclude = items; + i = nextIndex; + continue; + } + + i++; + } + + return result; +} + +/** + * @param {string} line + * @returns {string} + */ +function stripYamlComment(line) { + // Strip `#` comments that aren't inside quotes. Pnpm's settings here are + // simple keys/values, so a quote-aware scan is enough. + let inSingle = false; + let inDouble = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"' && !inSingle) inDouble = !inDouble; + else if (ch === "'" && !inDouble) inSingle = !inSingle; + else if (ch === "#" && !inSingle && !inDouble) { + return line.slice(0, i); + } + } + return line; +} + +/** + * @param {string} rawLine + * @returns {boolean} + */ +function isTopLevelIndented(rawLine) { + return /^[ \t]/.test(rawLine); +} + +/** + * @param {string} value + * @returns {number | undefined} + */ +function parseScalarNumber(value) { + if (!value) return undefined; + const unquoted = unquote(value); + const num = Number(unquoted); + if (Number.isNaN(num) || num < 0) { + return undefined; + } + return num; +} + +/** + * @param {string} flowText + * @returns {string[]} + */ +function parseFlowArray(flowText) { + const closing = flowText.indexOf("]"); + if (closing === -1) return []; + const inner = flowText.slice(1, closing); + return inner + .split(",") + .map((item) => unquote(item.trim())) + .filter((item) => item.length > 0); +} + +/** + * @param {string[]} lines + * @param {number} startIndex + * @returns {{ items: string[], nextIndex: number }} + */ +function parseBlockArray(lines, startIndex) { + const items = []; + let i = startIndex; + while (i < lines.length) { + const rawLine = lines[i]; + const stripped = stripYamlComment(rawLine); + if (stripped.trim() === "") { + i++; + continue; + } + + const itemMatch = stripped.match(/^\s+-\s+(.*)$/); + if (itemMatch) { + const value = unquote(itemMatch[1].trim()); + if (value.length > 0) { + items.push(value); + } + i++; + continue; + } + + // Non-empty, non-list line — the block list has ended. + break; + } + return { items, nextIndex: i }; +} + +/** + * @param {string} value + * @returns {string} + */ +function unquote(value) { + if (value.length >= 2) { + const first = value[0]; + const last = value[value.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return value.slice(1, -1); + } + } + return value; +} + +/** + * @param {unknown} value + * @returns {number | undefined} + */ +function validatePositiveNumber(value) { + if (typeof value !== "number" || Number.isNaN(value) || value < 0) { + return undefined; + } + return value; +} + +/** + * @param {unknown} value + * @returns {string[]} + */ +function validateStringArray(value) { + if (!Array.isArray(value)) return []; + return value.filter((item) => typeof item === "string" && item.length > 0); +} diff --git a/packages/safe-chain/src/config/pnpmWorkspaceConfig.spec.js b/packages/safe-chain/src/config/pnpmWorkspaceConfig.spec.js new file mode 100644 index 0000000..601e774 --- /dev/null +++ b/packages/safe-chain/src/config/pnpmWorkspaceConfig.spec.js @@ -0,0 +1,337 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; +import path from "path"; + +/** @type {Map} */ +let mockFiles = new Map(); +mock.module("fs", { + namedExports: { + existsSync: (filePath) => mockFiles.has(filePath), + readFileSync: (filePath) => { + if (!mockFiles.has(filePath)) { + throw new Error(`ENOENT: no such file: ${filePath}`); + } + return mockFiles.get(filePath); + }, + writeFileSync: (filePath, content) => mockFiles.set(filePath, content), + mkdirSync: () => {}, + }, +}); + +const { + parsePnpmWorkspaceYaml, + getPnpmWorkspaceSettings, + getMinimumReleaseAgeHours, + getMinimumReleaseAgeExclusions, + resetPnpmWorkspaceConfigCache, +} = await import("./pnpmWorkspaceConfig.js"); + +describe("parsePnpmWorkspaceYaml", () => { + it("returns empty settings for empty content", () => { + const result = parsePnpmWorkspaceYaml(""); + assert.strictEqual(result.minimumReleaseAgeMinutes, undefined); + assert.deepStrictEqual(result.minimumReleaseAgeExclude, []); + }); + + it("parses numeric minimumReleaseAge", () => { + const result = parsePnpmWorkspaceYaml("minimumReleaseAge: 1440\n"); + assert.strictEqual(result.minimumReleaseAgeMinutes, 1440); + }); + + it("parses minimumReleaseAge with inline comment", () => { + const result = parsePnpmWorkspaceYaml( + "minimumReleaseAge: 1440 # 24 hours\n" + ); + assert.strictEqual(result.minimumReleaseAgeMinutes, 1440); + }); + + it("ignores negative minimumReleaseAge", () => { + const result = parsePnpmWorkspaceYaml("minimumReleaseAge: -5\n"); + assert.strictEqual(result.minimumReleaseAgeMinutes, undefined); + }); + + it("ignores non-numeric minimumReleaseAge", () => { + const result = parsePnpmWorkspaceYaml("minimumReleaseAge: hello\n"); + assert.strictEqual(result.minimumReleaseAgeMinutes, undefined); + }); + + it("parses block-style minimumReleaseAgeExclude", () => { + const yaml = [ + "minimumReleaseAgeExclude:", + " - react", + " - '@babel/*'", + " - \"lodash\"", + ].join("\n"); + const result = parsePnpmWorkspaceYaml(yaml); + assert.deepStrictEqual(result.minimumReleaseAgeExclude, [ + "react", + "@babel/*", + "lodash", + ]); + }); + + it("parses flow-style minimumReleaseAgeExclude", () => { + const result = parsePnpmWorkspaceYaml( + "minimumReleaseAgeExclude: ['react', \"@babel/*\", lodash]\n" + ); + assert.deepStrictEqual(result.minimumReleaseAgeExclude, [ + "react", + "@babel/*", + "lodash", + ]); + }); + + it("stops the block list at the next top-level key", () => { + const yaml = [ + "minimumReleaseAgeExclude:", + " - react", + " - lodash", + "minimumReleaseAge: 720", + "packages:", + " - 'apps/*'", + ].join("\n"); + const result = parsePnpmWorkspaceYaml(yaml); + assert.deepStrictEqual(result.minimumReleaseAgeExclude, ["react", "lodash"]); + assert.strictEqual(result.minimumReleaseAgeMinutes, 720); + }); + + it("ignores unrelated top-level keys", () => { + const yaml = [ + "packages:", + " - 'apps/*'", + " - 'libs/*'", + "minimumReleaseAge: 60", + ].join("\n"); + const result = parsePnpmWorkspaceYaml(yaml); + assert.strictEqual(result.minimumReleaseAgeMinutes, 60); + assert.deepStrictEqual(result.minimumReleaseAgeExclude, []); + }); + + it("skips comment-only and blank lines", () => { + const yaml = [ + "# top comment", + "", + "minimumReleaseAge: 30 # inline", + "", + "# another comment", + "minimumReleaseAgeExclude:", + " # list comment", + " - react", + ].join("\n"); + const result = parsePnpmWorkspaceYaml(yaml); + assert.strictEqual(result.minimumReleaseAgeMinutes, 30); + assert.deepStrictEqual(result.minimumReleaseAgeExclude, ["react"]); + }); + + it("preserves '#' inside quoted strings", () => { + const result = parsePnpmWorkspaceYaml( + "minimumReleaseAgeExclude: ['pkg#1', \"pkg#2\"]\n" + ); + assert.deepStrictEqual(result.minimumReleaseAgeExclude, ["pkg#1", "pkg#2"]); + }); +}); + +describe("getPnpmWorkspaceSettings (directory walk)", () => { + const startDir = "/tmp/some-project/packages/inner"; + + /** @type {(() => string) | undefined} */ + let originalCwd; + function overrideCwd(dir) { + if (!originalCwd) originalCwd = process.cwd; + process.cwd = () => dir; + } + function restoreCwd() { + if (originalCwd) { + process.cwd = originalCwd; + originalCwd = undefined; + } + } + + beforeEach(() => { + resetPnpmWorkspaceConfigCache(); + mockFiles.clear(); + }); + + afterEach(() => { + mockFiles.clear(); + resetPnpmWorkspaceConfigCache(); + restoreCwd(); + }); + + it("finds pnpm-workspace.yaml in the same directory", () => { + overrideCwd(startDir); + mockFiles.set( + path.join(startDir, "pnpm-workspace.yaml"), + "minimumReleaseAge: 60\n" + ); + + const settings = getPnpmWorkspaceSettings(); + assert.strictEqual(settings.minimumReleaseAgeMinutes, 60); + }); + + it("walks up to find pnpm-workspace.yaml at the project root", () => { + overrideCwd(startDir); + mockFiles.set( + "/tmp/some-project/pnpm-workspace.yaml", + [ + "minimumReleaseAge: 1440", + "minimumReleaseAgeExclude:", + " - react", + ].join("\n") + ); + + const settings = getPnpmWorkspaceSettings(); + assert.strictEqual(settings.minimumReleaseAgeMinutes, 1440); + assert.deepStrictEqual(settings.minimumReleaseAgeExclude, ["react"]); + }); + + it("falls back to package.json#pnpm when no pnpm-workspace.yaml is present", () => { + overrideCwd(startDir); + mockFiles.set( + "/tmp/some-project/package.json", + JSON.stringify({ + name: "root", + pnpm: { + minimumReleaseAge: 720, + minimumReleaseAgeExclude: ["express", "@types/*"], + }, + }) + ); + + const settings = getPnpmWorkspaceSettings(); + assert.strictEqual(settings.minimumReleaseAgeMinutes, 720); + assert.deepStrictEqual(settings.minimumReleaseAgeExclude, [ + "express", + "@types/*", + ]); + }); + + it("continues walking up past a package.json that has no pnpm field", () => { + overrideCwd(startDir); + // Inner package.json with no pnpm field — should not terminate the walk + mockFiles.set( + "/tmp/some-project/packages/inner/package.json", + JSON.stringify({ name: "inner" }) + ); + // Workspace config at the monorepo root — this is what should win + mockFiles.set( + "/tmp/some-project/pnpm-workspace.yaml", + "minimumReleaseAge: 999\n" + ); + + const settings = getPnpmWorkspaceSettings(); + assert.strictEqual(settings.minimumReleaseAgeMinutes, 999); + }); + + it("returns the nearest package.json#pnpm when no pnpm-workspace.yaml exists", () => { + overrideCwd(startDir); + mockFiles.set( + "/tmp/some-project/packages/inner/package.json", + JSON.stringify({ name: "inner" }) + ); + mockFiles.set( + "/tmp/some-project/package.json", + JSON.stringify({ + name: "root", + pnpm: { minimumReleaseAge: 60 }, + }) + ); + + const settings = getPnpmWorkspaceSettings(); + assert.strictEqual(settings.minimumReleaseAgeMinutes, 60); + }); + + it("returns empty settings when no config files found", () => { + overrideCwd(startDir); + + const settings = getPnpmWorkspaceSettings(); + assert.strictEqual(settings.minimumReleaseAgeMinutes, undefined); + assert.deepStrictEqual(settings.minimumReleaseAgeExclude, []); + }); + + it("converts minimumReleaseAge minutes to hours via getMinimumReleaseAgeHours", () => { + overrideCwd(startDir); + mockFiles.set( + path.join(startDir, "pnpm-workspace.yaml"), + "minimumReleaseAge: 1440\n" + ); + + assert.strictEqual(getMinimumReleaseAgeHours(), 24); + }); + + it("returns exclusions via getMinimumReleaseAgeExclusions", () => { + overrideCwd(startDir); + mockFiles.set( + path.join(startDir, "pnpm-workspace.yaml"), + [ + "minimumReleaseAgeExclude:", + " - react", + " - lodash", + ].join("\n") + ); + + assert.deepStrictEqual(getMinimumReleaseAgeExclusions(), ["react", "lodash"]); + }); + + it("caches results across calls", () => { + overrideCwd(startDir); + mockFiles.set( + path.join(startDir, "pnpm-workspace.yaml"), + "minimumReleaseAge: 60\n" + ); + + const first = getPnpmWorkspaceSettings(); + // Mutate the underlying mock — cached result should not change. + mockFiles.set( + path.join(startDir, "pnpm-workspace.yaml"), + "minimumReleaseAge: 999\n" + ); + const second = getPnpmWorkspaceSettings(); + assert.strictEqual(first.minimumReleaseAgeMinutes, 60); + assert.strictEqual(second.minimumReleaseAgeMinutes, 60); + }); + + it("handles malformed JSON in package.json gracefully", () => { + overrideCwd(startDir); + mockFiles.set( + "/tmp/some-project/packages/inner/package.json", + "{ not valid json" + ); + + const settings = getPnpmWorkspaceSettings(); + assert.strictEqual(settings.minimumReleaseAgeMinutes, undefined); + assert.deepStrictEqual(settings.minimumReleaseAgeExclude, []); + }); + + it("treats null minimumReleaseAge in package.json#pnpm as undefined", () => { + overrideCwd(startDir); + mockFiles.set( + "/tmp/some-project/packages/inner/package.json", + JSON.stringify({ + name: "inner", + pnpm: { minimumReleaseAge: null }, + }) + ); + + const settings = getPnpmWorkspaceSettings(); + assert.strictEqual(settings.minimumReleaseAgeMinutes, undefined); + }); + + it("ignores non-array minimumReleaseAgeExclude in package.json#pnpm", () => { + overrideCwd(startDir); + mockFiles.set( + "/tmp/some-project/packages/inner/package.json", + JSON.stringify({ + name: "inner", + pnpm: { + minimumReleaseAge: 30, + minimumReleaseAgeExclude: "not-an-array", + }, + }) + ); + + const settings = getPnpmWorkspaceSettings(); + assert.strictEqual(settings.minimumReleaseAgeMinutes, 30); + assert.deepStrictEqual(settings.minimumReleaseAgeExclude, []); + }); +}); diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index d04411e..9597fd6 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -1,6 +1,8 @@ import * as cliArguments from "./cliArguments.js"; import * as configFile from "./configFile.js"; import * as environmentVariables from "./environmentVariables.js"; +import * as pnpmWorkspaceConfig from "./pnpmWorkspaceConfig.js"; +import { getPackageManagerName } from "./packageManagerName.js"; import { ui } from "../environment/userInteraction.js"; export const LOGGING_SILENT = "silent"; @@ -71,9 +73,22 @@ export function getMinimumPackageAgeHours() { return configValue; } + // Priority 4: pnpm-workspace.yaml / package.json#pnpm (only under the pnpm shim) + if (isPnpmShim()) { + const pnpmValue = pnpmWorkspaceConfig.getMinimumReleaseAgeHours(); + if (pnpmValue !== undefined) { + return pnpmValue; + } + } + return defaultMinimumPackageAge; } +function isPnpmShim() { + const name = getPackageManagerName(); + return name === "pnpm" || name === "pnpx"; +} + /** * @param {string | undefined} value * @returns {number | undefined} @@ -194,9 +209,12 @@ export function getMinimumPackageAgeExclusions() { environmentVariables.getMinimumPackageAgeExclusions() ); const configExclusions = configFile.getMinimumPackageAgeExclusions(); + const pnpmExclusions = isPnpmShim() + ? pnpmWorkspaceConfig.getMinimumReleaseAgeExclusions() + : []; - // Merge both sources and remove duplicates - const allExclusions = [...envExclusions, ...configExclusions]; + // Merge all sources and remove duplicates + const allExclusions = [...envExclusions, ...configExclusions, ...pnpmExclusions]; return [...new Set(allExclusions)]; } diff --git a/packages/safe-chain/src/config/settings.pnpmWorkspace.spec.js b/packages/safe-chain/src/config/settings.pnpmWorkspace.spec.js new file mode 100644 index 0000000..00645e8 --- /dev/null +++ b/packages/safe-chain/src/config/settings.pnpmWorkspace.spec.js @@ -0,0 +1,192 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; +import path from "path"; + +/** @type {Map} */ +let mockFiles = new Map(); +mock.module("fs", { + namedExports: { + existsSync: (filePath) => mockFiles.has(filePath), + readFileSync: (filePath) => { + if (!mockFiles.has(filePath)) { + throw new Error(`ENOENT: no such file: ${filePath}`); + } + return mockFiles.get(filePath); + }, + writeFileSync: (filePath, content) => mockFiles.set(filePath, content), + mkdirSync: () => {}, + }, +}); + +let currentPackageManagerName = null; +mock.module("./packageManagerName.js", { + namedExports: { + getPackageManagerName: () => currentPackageManagerName, + setPackageManagerName: (name) => { + currentPackageManagerName = name; + }, + }, +}); + +const { + getMinimumPackageAgeHours, + getMinimumPackageAgeExclusions, +} = await import("./settings.js"); +const { initializeCliArguments } = await import("./cliArguments.js"); +const { resetPnpmWorkspaceConfigCache } = await import( + "./pnpmWorkspaceConfig.js" +); + +const PNPM_WORKSPACE_ENV = [ + "SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS", + "SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS", + "SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS", +]; + +describe("pnpm workspace config integration", () => { + /** @type {Record} */ + let savedEnv; + /** @type {(() => string) | undefined} */ + let originalCwd; + const cwd = "/tmp/project"; + + function overrideCwd(dir) { + if (!originalCwd) originalCwd = process.cwd; + process.cwd = () => dir; + } + + beforeEach(() => { + savedEnv = {}; + for (const name of PNPM_WORKSPACE_ENV) { + savedEnv[name] = process.env[name]; + delete process.env[name]; + } + mockFiles.clear(); + resetPnpmWorkspaceConfigCache(); + initializeCliArguments([]); + currentPackageManagerName = null; + overrideCwd(cwd); + }); + + afterEach(() => { + for (const name of PNPM_WORKSPACE_ENV) { + if (savedEnv[name] !== undefined) { + process.env[name] = savedEnv[name]; + } else { + delete process.env[name]; + } + } + mockFiles.clear(); + resetPnpmWorkspaceConfigCache(); + currentPackageManagerName = null; + if (originalCwd) { + process.cwd = originalCwd; + originalCwd = undefined; + } + }); + + describe("getMinimumPackageAgeHours", () => { + it("uses pnpm-workspace.yaml when running as pnpm shim and no higher source is set", () => { + currentPackageManagerName = "pnpm"; + mockFiles.set( + path.join(cwd, "pnpm-workspace.yaml"), + "minimumReleaseAge: 1440\n" + ); + + assert.strictEqual(getMinimumPackageAgeHours(), 24); + }); + + it("uses pnpm-workspace.yaml when running as pnpx shim", () => { + currentPackageManagerName = "pnpx"; + mockFiles.set( + path.join(cwd, "pnpm-workspace.yaml"), + "minimumReleaseAge: 720\n" + ); + + assert.strictEqual(getMinimumPackageAgeHours(), 12); + }); + + it("falls back to default when not running as pnpm shim, even if pnpm-workspace.yaml exists", () => { + currentPackageManagerName = "npm"; + mockFiles.set( + path.join(cwd, "pnpm-workspace.yaml"), + "minimumReleaseAge: 1440\n" + ); + + assert.strictEqual(getMinimumPackageAgeHours(), 48); + }); + + it("env var overrides pnpm-workspace.yaml", () => { + currentPackageManagerName = "pnpm"; + process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS = "6"; + mockFiles.set( + path.join(cwd, "pnpm-workspace.yaml"), + "minimumReleaseAge: 1440\n" + ); + + assert.strictEqual(getMinimumPackageAgeHours(), 6); + }); + + it("falls back to package.json#pnpm when no pnpm-workspace.yaml is present", () => { + currentPackageManagerName = "pnpm"; + mockFiles.set( + path.join(cwd, "package.json"), + JSON.stringify({ + name: "x", + pnpm: { minimumReleaseAge: 60 }, + }) + ); + + assert.strictEqual(getMinimumPackageAgeHours(), 1); + }); + }); + + describe("getMinimumPackageAgeExclusions", () => { + it("merges pnpm-workspace.yaml exclusions when running as pnpm shim", () => { + currentPackageManagerName = "pnpm"; + process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS = "from-env"; + mockFiles.set( + path.join(cwd, "pnpm-workspace.yaml"), + [ + "minimumReleaseAgeExclude:", + " - from-pnpm", + " - '@scope/*'", + ].join("\n") + ); + + const exclusions = getMinimumPackageAgeExclusions(); + assert.deepStrictEqual(exclusions, ["from-env", "from-pnpm", "@scope/*"]); + }); + + it("does not include pnpm-workspace.yaml exclusions when running as npm shim", () => { + currentPackageManagerName = "npm"; + process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS = "from-env"; + mockFiles.set( + path.join(cwd, "pnpm-workspace.yaml"), + [ + "minimumReleaseAgeExclude:", + " - from-pnpm", + ].join("\n") + ); + + const exclusions = getMinimumPackageAgeExclusions(); + assert.deepStrictEqual(exclusions, ["from-env"]); + }); + + it("deduplicates exclusions across all sources", () => { + currentPackageManagerName = "pnpm"; + process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS = "react,lodash"; + mockFiles.set( + path.join(cwd, "pnpm-workspace.yaml"), + [ + "minimumReleaseAgeExclude:", + " - react", + " - express", + ].join("\n") + ); + + const exclusions = getMinimumPackageAgeExclusions(); + assert.deepStrictEqual(exclusions, ["react", "lodash", "express"]); + }); + }); +}); diff --git a/packages/safe-chain/src/packagemanager/currentPackageManager.js b/packages/safe-chain/src/packagemanager/currentPackageManager.js index 90050d3..775c3d1 100644 --- a/packages/safe-chain/src/packagemanager/currentPackageManager.js +++ b/packages/safe-chain/src/packagemanager/currentPackageManager.js @@ -16,6 +16,7 @@ import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js"; import { createRushPackageManager } from "./rush/createRushPackageManager.js"; import { createRushxPackageManager } from "./rushx/createRushxPackageManager.js"; import { createUvxPackageManager } from "./uvx/createUvxPackageManager.js"; +import { setPackageManagerName } from "../config/packageManagerName.js"; /** * @type {{packageManagerName: PackageManager | null}} @@ -45,6 +46,7 @@ const state = { * @return {PackageManager} */ export function initializePackageManager(packageManagerName, context) { + setPackageManagerName(packageManagerName); if (packageManagerName === "npm") { state.packageManagerName = createNpmPackageManager(); } else if (packageManagerName === "npx") {