mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
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:
parent
65a8075b0e
commit
8ac7c722b8
7 changed files with 906 additions and 3 deletions
13
README.md
13
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
|
### 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/*"
|
||||||
|
|
|
||||||
23
packages/safe-chain/src/config/packageManagerName.js
Normal file
23
packages/safe-chain/src/config/packageManagerName.js
Normal 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;
|
||||||
|
}
|
||||||
320
packages/safe-chain/src/config/pnpmWorkspaceConfig.js
Normal file
320
packages/safe-chain/src/config/pnpmWorkspaceConfig.js
Normal 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);
|
||||||
|
}
|
||||||
337
packages/safe-chain/src/config/pnpmWorkspaceConfig.spec.js
Normal file
337
packages/safe-chain/src/config/pnpmWorkspaceConfig.spec.js
Normal 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, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
192
packages/safe-chain/src/config/settings.pnpmWorkspace.spec.js
Normal file
192
packages/safe-chain/src/config/settings.pnpmWorkspace.spec.js
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue