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) <noreply@anthropic.com>
This commit is contained in:
Ethan Setnik 2026-05-15 17:39:00 -04:00
parent 65a8075b0e
commit 8ac7c722b8
No known key found for this signature in database
GPG key ID: 0D4CC215CFFBCECB
7 changed files with 906 additions and 3 deletions

View file

@ -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 ### 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 ```shell
export SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS="@aikidosec/*" export SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS="@aikidosec/*"

View file

@ -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;
}

View file

@ -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);
}

View file

@ -0,0 +1,337 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import path from "path";
/** @type {Map<string, string>} */
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, []);
});
});

View file

@ -1,6 +1,8 @@
import * as cliArguments from "./cliArguments.js"; import * as cliArguments from "./cliArguments.js";
import * as configFile from "./configFile.js"; import * as configFile from "./configFile.js";
import * as environmentVariables from "./environmentVariables.js"; import * as environmentVariables from "./environmentVariables.js";
import * as pnpmWorkspaceConfig from "./pnpmWorkspaceConfig.js";
import { getPackageManagerName } from "./packageManagerName.js";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
export const LOGGING_SILENT = "silent"; export const LOGGING_SILENT = "silent";
@ -71,9 +73,22 @@ export function getMinimumPackageAgeHours() {
return configValue; 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; return defaultMinimumPackageAge;
} }
function isPnpmShim() {
const name = getPackageManagerName();
return name === "pnpm" || name === "pnpx";
}
/** /**
* @param {string | undefined} value * @param {string | undefined} value
* @returns {number | undefined} * @returns {number | undefined}
@ -194,9 +209,12 @@ export function getMinimumPackageAgeExclusions() {
environmentVariables.getMinimumPackageAgeExclusions() environmentVariables.getMinimumPackageAgeExclusions()
); );
const configExclusions = configFile.getMinimumPackageAgeExclusions(); const configExclusions = configFile.getMinimumPackageAgeExclusions();
const pnpmExclusions = isPnpmShim()
? pnpmWorkspaceConfig.getMinimumReleaseAgeExclusions()
: [];
// Merge both sources and remove duplicates // Merge all sources and remove duplicates
const allExclusions = [...envExclusions, ...configExclusions]; const allExclusions = [...envExclusions, ...configExclusions, ...pnpmExclusions];
return [...new Set(allExclusions)]; return [...new Set(allExclusions)];
} }

View file

@ -0,0 +1,192 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import path from "path";
/** @type {Map<string, string>} */
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<string, string | undefined>} */
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"]);
});
});
});

View file

@ -16,6 +16,7 @@ import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js";
import { createRushPackageManager } from "./rush/createRushPackageManager.js"; import { createRushPackageManager } from "./rush/createRushPackageManager.js";
import { createRushxPackageManager } from "./rushx/createRushxPackageManager.js"; import { createRushxPackageManager } from "./rushx/createRushxPackageManager.js";
import { createUvxPackageManager } from "./uvx/createUvxPackageManager.js"; import { createUvxPackageManager } from "./uvx/createUvxPackageManager.js";
import { setPackageManagerName } from "../config/packageManagerName.js";
/** /**
* @type {{packageManagerName: PackageManager | null}} * @type {{packageManagerName: PackageManager | null}}
@ -45,6 +46,7 @@ const state = {
* @return {PackageManager} * @return {PackageManager}
*/ */
export function initializePackageManager(packageManagerName, context) { export function initializePackageManager(packageManagerName, context) {
setPackageManagerName(packageManagerName);
if (packageManagerName === "npm") { if (packageManagerName === "npm") {
state.packageManagerName = createNpmPackageManager(); state.packageManagerName = createNpmPackageManager();
} else if (packageManagerName === "npx") { } else if (packageManagerName === "npx") {