pmsec 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # pmsec (Node)
2
+
3
+ Cross-platform CLI that inspects and applies install-time cooldown settings
4
+ (npm `min-release-age`, pnpm `minimum-release-age`, yarn `npmMinimalAgeGate`,
5
+ bun `minimumReleaseAge`, cargo `minimum-release-age`, mise `minimum_release_age`,
6
+ uv `exclude-newer`) so freshly-published, possibly-malicious packages can't
7
+ land in your machine within hours of upload.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npx pmsec check --min 7
13
+ npx pmsec set 7
14
+ npx pmsec unset
15
+ ```
16
+
17
+ ```bash
18
+ uvx pmsec check --min 7
19
+ uvx pmsec set 7
20
+ uvx pmsec unset
21
+ ```
22
+
23
+ If your environment already enforces cooldown (or routes through a proxy
24
+ registry), bootstrap pmsec by overriding just for that call:
25
+
26
+ ```bash
27
+ npx --registry=https://registry.npmjs.org/ --min-release-age=0 pmsec check
28
+ uvx --index https://pypi.org/simple --exclude-newer-package pmsec=2099-01-01 pmsec check
29
+ ```
30
+
31
+ Zero runtime dependencies, ESM, requires Node 20+.
32
+
33
+ ## Commands
34
+
35
+ | Command | Description |
36
+ | --- | --- |
37
+ | `pmsec check [--min N]` | Read each tool's config; exit 1 if any tool is below `N` days or unset. |
38
+ | `pmsec set <DAYS>` | Write `DAYS`-day cooldown to every selected tool. Always proceeds; if the runtime is too old to honor the key, prints a `⚠` line under the success line. |
39
+ | `pmsec unset` | Remove only the cooldown key from each config (other keys preserved). |
40
+
41
+ Options: `--tool npm,pnpm,yarn,bun,cargo,mise,uv`, `--json`.
42
+
43
+ When the target file is owned by another user (typical: `~/.npmrc` left
44
+ root-owned by an old `sudo npm config set`), pmsec auto-runs
45
+ `sudo chown $(id -u):$(id -g) <path>` and retries the write. You'll be
46
+ prompted for your password once; subsequent runs need no escalation.
47
+
48
+ See the [project README](https://github.com/HikaruEgashira/pmsec) for the
49
+ full table of keys, units, paths, and environment overrides.
50
+
51
+ ## License
52
+
53
+ MIT
package/bin/cli.mjs ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "../src/cli.mjs";
3
+ process.exit(await run(process.argv.slice(2)));
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "pmsec",
3
+ "version": "0.3.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Inspect and apply install-time cooldown (min-release-age / exclude-newer) for npm and uv.",
8
+ "homepage": "https://github.com/HikaruEgashira/pmsec#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/HikaruEgashira/pmsec.git",
12
+ "directory": "node"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/HikaruEgashira/pmsec/issues"
16
+ },
17
+ "type": "module",
18
+ "license": "MIT",
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "bin": {
23
+ "pmsec": "./bin/cli.mjs"
24
+ },
25
+ "files": [
26
+ "bin",
27
+ "src",
28
+ "README.md"
29
+ ],
30
+ "scripts": {
31
+ "test": "node --test"
32
+ },
33
+ "keywords": [
34
+ "supply-chain",
35
+ "cooldown",
36
+ "min-release-age",
37
+ "exclude-newer",
38
+ "npm",
39
+ "uv",
40
+ "pmsec"
41
+ ]
42
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,179 @@
1
+ import { homedir } from "node:os";
2
+ import * as npm from "./tools/npm.mjs";
3
+ import * as pnpm from "./tools/pnpm.mjs";
4
+ import * as yarn from "./tools/yarn.mjs";
5
+ import * as bun from "./tools/bun.mjs";
6
+ import * as cargo from "./tools/cargo.mjs";
7
+ import * as mise from "./tools/mise.mjs";
8
+ import * as uv from "./tools/uv.mjs";
9
+
10
+ const TOOLS = [npm, pnpm, yarn, bun, cargo, mise, uv];
11
+ const DEFAULT_MIN = 7;
12
+
13
+ const USAGE = `pmsec <command> [options]
14
+
15
+ Commands:
16
+ check Inspect cooldown settings (exit 1 if any tool below --min)
17
+ set <DAYS> Apply DAYS-day cooldown to all selected tools
18
+ unset Remove cooldown settings from selected tools
19
+
20
+ Options:
21
+ --tool TOOL[,TOOL] Restrict to specific tools (npm,pnpm,yarn,bun,cargo,mise,uv)
22
+ --min DAYS Minimum acceptable days for check (default ${DEFAULT_MIN})
23
+ --json Emit JSON output
24
+ -h, --help Show this help
25
+
26
+ Examples:
27
+ npx pmsec check --min 7
28
+ npx pmsec set 7
29
+ npx pmsec unset --tool npm
30
+ `;
31
+
32
+ function parse(argv) {
33
+ const opts = { command: null, days: null, min: DEFAULT_MIN, json: false, only: null, help: false };
34
+ const positional = [];
35
+ for (let i = 0; i < argv.length; i++) {
36
+ const a = argv[i];
37
+ if (a === "-h" || a === "--help") opts.help = true;
38
+ else if (a === "--json") opts.json = true;
39
+ else if (a === "--min") opts.min = Number(argv[++i]);
40
+ else if (a.startsWith("--min=")) opts.min = Number(a.slice(6));
41
+ else if (a === "--tool") opts.only = argv[++i].split(",");
42
+ else if (a.startsWith("--tool=")) opts.only = a.slice(7).split(",");
43
+ else if (a.startsWith("-")) throw new Error(`unknown flag: ${a}`);
44
+ else positional.push(a);
45
+ }
46
+ opts.command = positional[0] ?? null;
47
+ if (opts.command === "set") opts.days = Number(positional[1]);
48
+ return opts;
49
+ }
50
+
51
+ function selectTools(only) {
52
+ if (!only) return TOOLS;
53
+ const found = TOOLS.filter(t => only.includes(t.name));
54
+ const missing = only.filter(n => !TOOLS.some(t => t.name === n));
55
+ if (missing.length) throw new Error(`unknown tool(s): ${missing.join(",")}`);
56
+ return found;
57
+ }
58
+
59
+ function preflightWarn(t) {
60
+ if (typeof t.preflight !== "function") return null;
61
+ const pf = t.preflight();
62
+ return pf?.message ?? null;
63
+ }
64
+
65
+ async function gatherStatus(targets, env, home, platform) {
66
+ return Promise.all(targets.map(async t => {
67
+ const r = await t.read(env, home, platform);
68
+ return { tool: t.name, key: t.key, path: r.path, configured: r.configured, days: r.days, warn: preflightWarn(t) };
69
+ }));
70
+ }
71
+
72
+ function renderHuman(rows, min) {
73
+ const lines = [];
74
+ for (const r of rows) {
75
+ const status = r.days === null ? "MISSING" : r.days < min ? "STALE " : "OK ";
76
+ const value = r.configured ?? "(unset)";
77
+ const tail = r.warn ? `\n ⚠ ${r.warn}` : "";
78
+ lines.push(`${status} ${r.tool.padEnd(4)} ${r.key} = ${value} [${r.path}]${tail}`);
79
+ }
80
+ return lines.join("\n") + "\n";
81
+ }
82
+
83
+ async function runCheck(targets, { min, json }, env, home, platform, out, err) {
84
+ const rows = await gatherStatus(targets, env, home, platform);
85
+ const failing = rows.filter(r => r.days === null || r.days < min);
86
+ if (json) out.write(JSON.stringify({ min, rows, ok: failing.length === 0 }, null, 2) + "\n");
87
+ else out.write(renderHuman(rows, min));
88
+ if (failing.length) err.write(`pmsec: ${failing.length} tool(s) below ${min} days\n`);
89
+ return failing.length ? 1 : 0;
90
+ }
91
+
92
+ function shQuote(s) { return s ? `'${String(s).replace(/'/g, `'\\''`)}'` : ""; }
93
+
94
+ function explainFsError(e, tool) {
95
+ if (e?.code === "EACCES" || e?.code === "EPERM") {
96
+ const p = e.path ?? "";
97
+ const q = shQuote(p);
98
+ return `${tool}: cannot write ${p} (${e.code}). Check file ownership: \`ls -la ${q}\` — if owned by root, run \`sudo chown -h $(id -u):$(id -g) ${q}\`.`;
99
+ }
100
+ if (e?.code === "EROFS") return `${tool}: ${e.path ?? ""} is on a read-only filesystem (EROFS).`;
101
+ return `${tool}: ${e?.message ?? e}`;
102
+ }
103
+
104
+ async function runSet(targets, days, json, env, home, platform, out, err) {
105
+ if (!Number.isInteger(days) || days <= 0) throw new Error(`set requires integer DAYS > 0`);
106
+ const results = [];
107
+ const failures = [];
108
+ const warnings = [];
109
+ for (const t of targets) {
110
+ const warn = preflightWarn(t);
111
+ if (warn) warnings.push({ tool: t.name, warn });
112
+ try {
113
+ const r = await t.write(days, env, home, platform);
114
+ results.push({ tool: t.name, path: r.path, days, ok: true, warn });
115
+ } catch (e) {
116
+ failures.push({ tool: t.name, error: explainFsError(e, t.name) });
117
+ results.push({ tool: t.name, path: e?.path ?? null, days, ok: false, error: explainFsError(e, t.name), warn });
118
+ }
119
+ }
120
+ if (json) out.write(JSON.stringify({ set: days, results, warnings, ok: failures.length === 0 }, null, 2) + "\n");
121
+ else for (const r of results) {
122
+ if (r.ok) {
123
+ const tail = r.warn ? `\n ⚠ ${r.warn}` : "";
124
+ out.write(`set ${r.tool.padEnd(4)} ${r.days} days [${r.path}]${tail}\n`);
125
+ } else {
126
+ out.write(`FAIL ${r.tool.padEnd(4)} ${r.error}\n`);
127
+ }
128
+ }
129
+ for (const f of failures) err.write(`pmsec: ${f.error}\n`);
130
+ if (warnings.length) err.write(`pmsec: ${warnings.length} tool(s) configured but runtime may silently ignore the cooldown — see ⚠ above\n`);
131
+ return failures.length ? 1 : 0;
132
+ }
133
+
134
+ async function runUnset(targets, json, env, home, platform, out, err) {
135
+ const results = [];
136
+ const failures = [];
137
+ for (const t of targets) {
138
+ try {
139
+ const r = await t.unset(env, home, platform);
140
+ results.push({ tool: t.name, path: r.path, removed: r.removed, ok: true });
141
+ } catch (e) {
142
+ failures.push({ tool: t.name, error: explainFsError(e, t.name) });
143
+ results.push({ tool: t.name, path: e?.path ?? null, removed: false, ok: false, error: explainFsError(e, t.name) });
144
+ }
145
+ }
146
+ if (json) out.write(JSON.stringify({ results, ok: failures.length === 0 }, null, 2) + "\n");
147
+ else for (const r of results) {
148
+ if (!r.ok) out.write(`FAIL ${r.tool.padEnd(4)} ${r.error}\n`);
149
+ else out.write(`${r.removed ? "rm " : "skip"} ${r.tool.padEnd(4)} [${r.path}]\n`);
150
+ }
151
+ for (const f of failures) err.write(`pmsec: ${f.error}\n`);
152
+ return failures.length ? 1 : 0;
153
+ }
154
+
155
+ export async function run(argv, {
156
+ env = process.env,
157
+ home = homedir(),
158
+ platform = process.platform,
159
+ out = process.stdout,
160
+ err = process.stderr,
161
+ } = {}) {
162
+ let opts;
163
+ try { opts = parse(argv); }
164
+ catch (e) { err.write(`pmsec: ${e.message}\n`); return 2; }
165
+ if (opts.help || !opts.command) { out.write(USAGE); return opts.help ? 0 : 2; }
166
+ let targets;
167
+ try { targets = selectTools(opts.only); }
168
+ catch (e) { err.write(`pmsec: ${e.message}\n`); return 2; }
169
+ try {
170
+ if (opts.command === "check") return await runCheck(targets, opts, env, home, platform, out, err);
171
+ if (opts.command === "set") return await runSet(targets, opts.days, opts.json, env, home, platform, out, err);
172
+ if (opts.command === "unset") return await runUnset(targets, opts.json, env, home, platform, out, err);
173
+ err.write(`pmsec: unknown command "${opts.command}"\n`);
174
+ return 2;
175
+ } catch (e) {
176
+ err.write(`pmsec: ${e.message}\n`);
177
+ return 1;
178
+ }
179
+ }
@@ -0,0 +1,43 @@
1
+ import { bunConfigPath } from "../util/paths.mjs";
2
+ import { readSafe, writeAtomic } from "../util/io.mjs";
3
+ import { readKey, setKey, removeKey } from "../util/lines.mjs";
4
+ import { detectVersion, gte } from "../util/version.mjs";
5
+
6
+ export const name = "bun";
7
+ export const key = "minimumReleaseAge";
8
+ export const section = "install";
9
+ export const docs = "https://bun.com/docs/runtime/bunfig#install";
10
+ export const minBin = [1, 3, 0];
11
+
12
+ export function path(env, home) { return bunConfigPath(env, home); }
13
+
14
+ export function preflight() {
15
+ const v = detectVersion("bun");
16
+ if (v === null) return { ok: true, message: null };
17
+ if (gte(v, minBin)) return { ok: true, version: v.raw, message: null };
18
+ return { ok: true, warn: true, version: v.raw, message: `bun ${v.raw} < ${minBin.join(".")}: minimumReleaseAge is silently ignored. Upgrade bun to enforce the cooldown.` };
19
+ }
20
+
21
+ export async function read(env, home) {
22
+ const p = path(env, home);
23
+ const raw = await readSafe(p);
24
+ const value = readKey(raw, key, { section });
25
+ const seconds = value === null ? null : Number(value);
26
+ return { path: p, configured: value, days: seconds === null || Number.isNaN(seconds) ? null : Math.floor(seconds / 86400) };
27
+ }
28
+
29
+ export async function write(days, env, home) {
30
+ const p = path(env, home);
31
+ const before = await readSafe(p);
32
+ const after = setKey(before, key, `${key} = ${days * 86400}`, { section });
33
+ await writeAtomic(p, after);
34
+ return { path: p, before, after };
35
+ }
36
+
37
+ export async function unset(env, home) {
38
+ const p = path(env, home);
39
+ const before = await readSafe(p);
40
+ const { text: after, removed } = removeKey(before, key, { section });
41
+ if (removed) await writeAtomic(p, after);
42
+ return { path: p, removed };
43
+ }
@@ -0,0 +1,41 @@
1
+ import { cargoConfigPath } from "../util/paths.mjs";
2
+ import { readSafe, writeAtomic } from "../util/io.mjs";
3
+ import { readKey, setKey, removeKey } from "../util/lines.mjs";
4
+
5
+ export const name = "cargo";
6
+ export const key = "minimum-release-age";
7
+ export const section = "install";
8
+ export const docs = "https://rust-lang.github.io/rfcs/3801-package-cooldown.html";
9
+
10
+ export function path(env, home) { return cargoConfigPath(env, home); }
11
+
12
+ function parseDays(value) {
13
+ if (value === null) return null;
14
+ const m = value.match(/^"?\s*(\d+)\s*(d|days?|w|weeks?)\s*"?$/i);
15
+ if (!m) return null;
16
+ const n = Number(m[1]);
17
+ return /^(w|weeks?)$/i.test(m[2]) ? n * 7 : n;
18
+ }
19
+
20
+ export async function read(env, home) {
21
+ const p = path(env, home);
22
+ const raw = await readSafe(p);
23
+ const value = readKey(raw, key, { section });
24
+ return { path: p, configured: value, days: parseDays(value) };
25
+ }
26
+
27
+ export async function write(days, env, home) {
28
+ const p = path(env, home);
29
+ const before = await readSafe(p);
30
+ const after = setKey(before, key, `${key} = "${days}d"`, { section });
31
+ await writeAtomic(p, after);
32
+ return { path: p, before, after };
33
+ }
34
+
35
+ export async function unset(env, home) {
36
+ const p = path(env, home);
37
+ const before = await readSafe(p);
38
+ const { text: after, removed } = removeKey(before, key, { section });
39
+ if (removed) await writeAtomic(p, after);
40
+ return { path: p, removed };
41
+ }
@@ -0,0 +1,53 @@
1
+ import { miseConfigPath } from "../util/paths.mjs";
2
+ import { readSafe, writeAtomic } from "../util/io.mjs";
3
+ import { readKey, setKey, removeKey } from "../util/lines.mjs";
4
+ import { detectVersion, gte } from "../util/version.mjs";
5
+ export const name = "mise";
6
+ export const key = "minimum_release_age";
7
+ export const section = "settings";
8
+ export const docs = "https://mise.jdx.dev/configuration/settings.html#minimum_release_age";
9
+ export const minBin = [2026, 4, 22];
10
+
11
+ export function path(env, home, platform) { return miseConfigPath(env, home, platform); }
12
+
13
+ export function preflight() {
14
+ const v = detectVersion("mise");
15
+ if (v === null) return { ok: true, message: null };
16
+ if (gte(v, minBin)) return { ok: true, version: v.raw, message: null };
17
+ return { ok: true, warn: true, version: v.raw, message: `mise ${v.raw} < ${minBin.join(".")}: setting was named install_before before 2026.4.22 and minimum_release_age is silently ignored on older mise. Upgrade mise (\`mise self-update\`) to enforce the cooldown.` };
18
+ }
19
+
20
+ function parseDays(value) {
21
+ if (value === null) return null;
22
+ const m = value.match(/^"?\s*(\d+)\s*(d|days?|w|weeks?|m|months?|y|years?)\s*"?$/i);
23
+ if (!m) return null;
24
+ const n = Number(m[1]);
25
+ const unit = m[2].toLowerCase();
26
+ if (/^(w|weeks?)$/.test(unit)) return n * 7;
27
+ if (/^(m|months?)$/.test(unit)) return n * 30;
28
+ if (/^(y|years?)$/.test(unit)) return n * 365;
29
+ return n;
30
+ }
31
+
32
+ export async function read(env, home, platform) {
33
+ const p = path(env, home, platform);
34
+ const raw = await readSafe(p);
35
+ const value = readKey(raw, key, { section });
36
+ return { path: p, configured: value, days: parseDays(value) };
37
+ }
38
+
39
+ export async function write(days, env, home, platform) {
40
+ const p = path(env, home, platform);
41
+ const before = await readSafe(p);
42
+ const after = setKey(before, key, `${key} = "${days}d"`, { section });
43
+ await writeAtomic(p, after);
44
+ return { path: p, before, after };
45
+ }
46
+
47
+ export async function unset(env, home, platform) {
48
+ const p = path(env, home, platform);
49
+ const before = await readSafe(p);
50
+ const { text: after, removed } = removeKey(before, key, { section });
51
+ if (removed) await writeAtomic(p, after);
52
+ return { path: p, removed };
53
+ }
@@ -0,0 +1,41 @@
1
+ import { npmrcPath } from "../util/paths.mjs";
2
+ import { readSafe, writeAtomic } from "../util/io.mjs";
3
+ import { readKey, setKey, removeKey } from "../util/lines.mjs";
4
+ import { detectVersion, gte } from "../util/version.mjs";
5
+
6
+ export const name = "npm";
7
+ export const key = "min-release-age";
8
+ export const docs = "https://docs.npmjs.com/cli/v11/using-npm/config#min-release-age";
9
+ export const minBin = [11, 10, 0];
10
+
11
+ export function path(env, home) { return npmrcPath(env, home); }
12
+
13
+ export function preflight() {
14
+ const v = detectVersion("npm");
15
+ if (v === null) return { ok: true, message: null };
16
+ if (gte(v, minBin)) return { ok: true, version: v.raw, message: null };
17
+ return { ok: true, warn: true, version: v.raw, message: `npm ${v.raw} < ${minBin.join(".")}: min-release-age is silently ignored. Upgrade npm to enforce the cooldown.` };
18
+ }
19
+
20
+ export async function read(env, home) {
21
+ const p = path(env, home);
22
+ const raw = await readSafe(p);
23
+ const value = readKey(raw, key);
24
+ return { path: p, configured: value, days: value === null ? null : Number(value) };
25
+ }
26
+
27
+ export async function write(days, env, home) {
28
+ const p = path(env, home);
29
+ const before = await readSafe(p);
30
+ const after = setKey(before, key, `${key}=${days}`);
31
+ await writeAtomic(p, after);
32
+ return { path: p, before, after };
33
+ }
34
+
35
+ export async function unset(env, home) {
36
+ const p = path(env, home);
37
+ const before = await readSafe(p);
38
+ const { text: after, removed } = removeKey(before, key);
39
+ if (removed) await writeAtomic(p, after);
40
+ return { path: p, removed };
41
+ }
@@ -0,0 +1,42 @@
1
+ import { npmrcPath } from "../util/paths.mjs";
2
+ import { readSafe, writeAtomic } from "../util/io.mjs";
3
+ import { readKey, setKey, removeKey } from "../util/lines.mjs";
4
+ import { detectVersion, gte } from "../util/version.mjs";
5
+
6
+ export const name = "pnpm";
7
+ export const key = "minimum-release-age";
8
+ export const docs = "https://pnpm.io/settings#minimumreleaseage";
9
+ export const minBin = [10, 6, 0];
10
+
11
+ export function path(env, home) { return npmrcPath(env, home); }
12
+
13
+ export function preflight() {
14
+ const v = detectVersion("pnpm");
15
+ if (v === null) return { ok: true, message: null };
16
+ if (gte(v, minBin)) return { ok: true, version: v.raw, message: null };
17
+ return { ok: true, warn: true, version: v.raw, message: `pnpm ${v.raw} < ${minBin.join(".")}: minimum-release-age is silently ignored. Upgrade pnpm to enforce the cooldown.` };
18
+ }
19
+
20
+ export async function read(env, home) {
21
+ const p = path(env, home);
22
+ const raw = await readSafe(p);
23
+ const value = readKey(raw, key);
24
+ const minutes = value === null ? null : Number(value);
25
+ return { path: p, configured: value, days: minutes === null || Number.isNaN(minutes) ? null : Math.floor(minutes / (60 * 24)) };
26
+ }
27
+
28
+ export async function write(days, env, home) {
29
+ const p = path(env, home);
30
+ const before = await readSafe(p);
31
+ const after = setKey(before, key, `${key}=${days * 24 * 60}`);
32
+ await writeAtomic(p, after);
33
+ return { path: p, before, after };
34
+ }
35
+
36
+ export async function unset(env, home) {
37
+ const p = path(env, home);
38
+ const before = await readSafe(p);
39
+ const { text: after, removed } = removeKey(before, key);
40
+ if (removed) await writeAtomic(p, after);
41
+ return { path: p, removed };
42
+ }
@@ -0,0 +1,49 @@
1
+ import { uvConfigPath } from "../util/paths.mjs";
2
+ import { readSafe, writeAtomic } from "../util/io.mjs";
3
+ import { readKey, setKey, removeKey } from "../util/lines.mjs";
4
+ import { detectVersion, gte } from "../util/version.mjs";
5
+
6
+ export const name = "uv";
7
+ export const key = "exclude-newer";
8
+ export const docs = "https://docs.astral.sh/uv/reference/settings/#exclude-newer";
9
+ export const minBin = [0, 9, 17];
10
+
11
+ export function path(env, home, platform) { return uvConfigPath(env, home, platform); }
12
+
13
+ export function preflight() {
14
+ const v = detectVersion("uv");
15
+ if (v === null) return { ok: true, message: null };
16
+ if (gte(v, minBin)) return { ok: true, version: v.raw, message: null };
17
+ return { ok: true, warn: true, version: v.raw, message: `uv ${v.raw} < ${minBin.join(".")}: writing exclude-newer = "N days" will break this uv until you \`uv self update\` (file will fail to parse).` };
18
+ }
19
+
20
+ function parseDays(value) {
21
+ if (value === null) return null;
22
+ const m = value.match(/^"\s*(\d+)\s*(day|days|d|week|weeks|w)\s*"$/i);
23
+ if (!m) return null;
24
+ const n = Number(m[1]);
25
+ return /^(week|weeks|w)$/i.test(m[2]) ? n * 7 : n;
26
+ }
27
+
28
+ export async function read(env, home, platform) {
29
+ const p = path(env, home, platform);
30
+ const raw = await readSafe(p);
31
+ const value = readKey(raw, key);
32
+ return { path: p, configured: value, days: parseDays(value) };
33
+ }
34
+
35
+ export async function write(days, env, home, platform) {
36
+ const p = path(env, home, platform);
37
+ const before = await readSafe(p);
38
+ const after = setKey(before, key, `${key} = "${days} days"`);
39
+ await writeAtomic(p, after);
40
+ return { path: p, before, after };
41
+ }
42
+
43
+ export async function unset(env, home, platform) {
44
+ const p = path(env, home, platform);
45
+ const before = await readSafe(p);
46
+ const { text: after, removed } = removeKey(before, key);
47
+ if (removed) await writeAtomic(p, after);
48
+ return { path: p, removed };
49
+ }
@@ -0,0 +1,50 @@
1
+ import { yarnrcPath } from "../util/paths.mjs";
2
+ import { readSafe, writeAtomic } from "../util/io.mjs";
3
+ import { readKey, setKey, removeKey } from "../util/lines.mjs";
4
+ import { detectVersion, gte } from "../util/version.mjs";
5
+
6
+ export const name = "yarn";
7
+ export const key = "npmMinimalAgeGate";
8
+ export const docs = "https://yarnpkg.com/configuration/yarnrc#npmMinimalAgeGate";
9
+ export const minBin = [4, 10, 0];
10
+ const SEP = ":";
11
+
12
+ export function path(env, home) { return yarnrcPath(env, home); }
13
+
14
+ export function preflight() {
15
+ const v = detectVersion("yarn");
16
+ if (v === null) return { ok: true, message: null };
17
+ if (gte(v, minBin)) return { ok: true, version: v.raw, message: null };
18
+ return { ok: true, warn: true, version: v.raw, message: `yarn ${v.raw} < ${minBin.join(".")}: npmMinimalAgeGate is silently ignored. Upgrade yarn (v4.10+) to enforce the cooldown.` };
19
+ }
20
+
21
+ function parseDays(value) {
22
+ if (value === null) return null;
23
+ const m = value.match(/^"?\s*(\d+)\s*(d|days?|w|weeks?)\s*"?$/i);
24
+ if (!m) return null;
25
+ const n = Number(m[1]);
26
+ return /^(w|weeks?)$/i.test(m[2]) ? n * 7 : n;
27
+ }
28
+
29
+ export async function read(env, home) {
30
+ const p = path(env, home);
31
+ const raw = await readSafe(p);
32
+ const value = readKey(raw, key, { sep: SEP });
33
+ return { path: p, configured: value, days: parseDays(value) };
34
+ }
35
+
36
+ export async function write(days, env, home) {
37
+ const p = path(env, home);
38
+ const before = await readSafe(p);
39
+ const after = setKey(before, key, `${key}: "${days}d"`, { sep: SEP });
40
+ await writeAtomic(p, after);
41
+ return { path: p, before, after };
42
+ }
43
+
44
+ export async function unset(env, home) {
45
+ const p = path(env, home);
46
+ const before = await readSafe(p);
47
+ const { text: after, removed } = removeKey(before, key, { sep: SEP });
48
+ if (removed) await writeAtomic(p, after);
49
+ return { path: p, removed };
50
+ }
@@ -0,0 +1,81 @@
1
+ import { mkdir, readFile, writeFile, rename, lstat, unlink, open } from "node:fs/promises";
2
+ import { dirname, resolve, sep } from "node:path";
3
+ import { existsSync } from "node:fs";
4
+ import { spawnSync } from "node:child_process";
5
+ import { randomBytes } from "node:crypto";
6
+ import { homedir } from "node:os";
7
+
8
+ export async function readSafe(path) {
9
+ try { return await readFile(path, "utf8"); }
10
+ catch (e) { if (e.code === "ENOENT") return ""; throw e; }
11
+ }
12
+
13
+ function isPermErr(e) { return e?.code === "EACCES" || e?.code === "EPERM"; }
14
+
15
+ async function isSymlink(p) {
16
+ try { return (await lstat(p)).isSymbolicLink(); }
17
+ catch { return false; }
18
+ }
19
+
20
+ function underHome(p, home) {
21
+ const r = resolve(p);
22
+ const h = resolve(home);
23
+ return r === h || r.startsWith(h + sep);
24
+ }
25
+
26
+ function shellQuote(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
27
+
28
+ async function reclaim(path, home, err) {
29
+ if (process.platform === "win32" || typeof process.getuid !== "function") return false;
30
+ if (!existsSync(path)) return false;
31
+ if (await isSymlink(path)) {
32
+ err.write(`pmsec: refusing to chown symlink ${path}; remove or replace it manually.\n`);
33
+ return false;
34
+ }
35
+ if (!underHome(path, home)) {
36
+ err.write(`pmsec: refusing to chown ${path} outside HOME (${home}); fix ownership manually.\n`);
37
+ return false;
38
+ }
39
+ err.write(`pmsec: ${path} not writable; running \`sudo chown -h $(id -u):$(id -g) ${shellQuote(path)}\`. You may be prompted for your password.\n`);
40
+ const r = spawnSync("sudo", ["chown", "-h", `${process.getuid()}:${process.getgid()}`, path], { stdio: "inherit" });
41
+ return r.status === 0;
42
+ }
43
+
44
+ async function atomicReplace(path, text) {
45
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
46
+ let fh;
47
+ try {
48
+ fh = await open(tmp, "wx", 0o600);
49
+ await fh.writeFile(text, "utf8");
50
+ try { await fh.sync(); } catch {}
51
+ await fh.close();
52
+ fh = null;
53
+ await rename(tmp, path);
54
+ } catch (e) {
55
+ if (fh) { try { await fh.close(); } catch {} }
56
+ try { await unlink(tmp); } catch {}
57
+ throw e;
58
+ }
59
+ }
60
+
61
+ export async function writeAtomic(path, text, { backup = true, home = homedir(), err = process.stderr } = {}) {
62
+ await mkdir(dirname(path), { recursive: true });
63
+ if (existsSync(path) && await isSymlink(path)) {
64
+ const e = new Error(`refusing to write through symlink ${path}`);
65
+ e.code = "ELOOP";
66
+ throw e;
67
+ }
68
+ if (backup && existsSync(path) && !existsSync(path + ".bak")) {
69
+ try { await atomicReplace(path + ".bak", await readFile(path, "utf8")); }
70
+ catch (e) {
71
+ const target = existsSync(path + ".bak") ? path + ".bak" : path;
72
+ if (!isPermErr(e) || !(await reclaim(target, home, err))) throw e;
73
+ await atomicReplace(path + ".bak", await readFile(path, "utf8"));
74
+ }
75
+ }
76
+ try { await atomicReplace(path, text); }
77
+ catch (e) {
78
+ if (!isPermErr(e) || !(await reclaim(path, home, err))) throw e;
79
+ await atomicReplace(path, text);
80
+ }
81
+ }
@@ -0,0 +1,86 @@
1
+ const SECTION = /^\s*\[[^\]]+\]\s*$/;
2
+
3
+ function entryKey(line, sep) {
4
+ const re = sep === ":" ? /^\s*([A-Za-z0-9_.\-]+)\s*:/ : /^\s*([A-Za-z0-9_.\-]+)\s*=/;
5
+ const m = line.match(re);
6
+ return m ? m[1] : null;
7
+ }
8
+
9
+ function rangeForSection(lines, section) {
10
+ if (!section) {
11
+ for (let i = 0; i < lines.length; i++) if (SECTION.test(lines[i])) return [0, i];
12
+ return [0, lines.length];
13
+ }
14
+ const header = `[${section}]`;
15
+ let start = -1;
16
+ for (let i = 0; i < lines.length; i++) {
17
+ if (lines[i].trim() === header) { start = i + 1; break; }
18
+ }
19
+ if (start < 0) return null;
20
+ let end = lines.length;
21
+ for (let i = start; i < lines.length; i++) {
22
+ if (SECTION.test(lines[i])) { end = i; break; }
23
+ }
24
+ return [start, end];
25
+ }
26
+
27
+ function indexOfKey(lines, range, key, sep) {
28
+ for (let i = range[0]; i < range[1]; i++) if (entryKey(lines[i], sep) === key) return i;
29
+ return -1;
30
+ }
31
+
32
+ function indexOfFirstSection(lines) {
33
+ for (let i = 0; i < lines.length; i++) if (SECTION.test(lines[i])) return i;
34
+ return -1;
35
+ }
36
+
37
+ export function readKey(text, key, opts = {}) {
38
+ const sep = opts.sep || "=";
39
+ const section = opts.section || null;
40
+ const lines = text.split(/\r?\n/);
41
+ const range = rangeForSection(lines, section);
42
+ if (range === null) return null;
43
+ const i = indexOfKey(lines, range, key, sep);
44
+ if (i < 0) return null;
45
+ const re = sep === ":" ? /^\s*[A-Za-z0-9_.\-]+\s*:\s*(.*?)\s*$/ : /^\s*[A-Za-z0-9_.\-]+\s*=\s*(.*?)\s*$/;
46
+ const m = lines[i].match(re);
47
+ return m ? m[1] : null;
48
+ }
49
+
50
+ export function setKey(text, key, valueLine, opts = {}) {
51
+ const sep = opts.sep || "=";
52
+ const section = opts.section || null;
53
+ const trailing = text.endsWith("\n") || text === "";
54
+ const lines = text === "" ? [] : text.replace(/\r?\n$/, "").split(/\r?\n/);
55
+ let range = rangeForSection(lines, section);
56
+ if (range === null) {
57
+ if (lines.length && lines[lines.length - 1] !== "") lines.push("");
58
+ lines.push(`[${section}]`, valueLine);
59
+ } else {
60
+ const idx = indexOfKey(lines, range, key, sep);
61
+ if (idx >= 0) {
62
+ lines[idx] = valueLine;
63
+ } else if (section) {
64
+ lines.splice(range[0], 0, valueLine);
65
+ } else {
66
+ const firstSec = indexOfFirstSection(lines);
67
+ if (firstSec < 0) lines.push(valueLine);
68
+ else lines.splice(firstSec, 0, valueLine, "");
69
+ }
70
+ }
71
+ return lines.join("\n") + (trailing || lines.length ? "\n" : "");
72
+ }
73
+
74
+ export function removeKey(text, key, opts = {}) {
75
+ const sep = opts.sep || "=";
76
+ const section = opts.section || null;
77
+ const trailing = text.endsWith("\n");
78
+ const lines = text === "" ? [] : text.replace(/\r?\n$/, "").split(/\r?\n/);
79
+ const range = rangeForSection(lines, section);
80
+ if (range === null) return { text, removed: false };
81
+ const idx = indexOfKey(lines, range, key, sep);
82
+ if (idx < 0) return { text, removed: false };
83
+ lines.splice(idx, 1);
84
+ if (idx < lines.length && lines[idx] === "") lines.splice(idx, 1);
85
+ return { text: lines.join("\n") + (trailing && lines.length ? "\n" : ""), removed: true };
86
+ }
@@ -0,0 +1,39 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ export function npmrcPath(env = process.env, home = homedir()) {
5
+ return env.NPM_CONFIG_USERCONFIG || join(home, ".npmrc");
6
+ }
7
+
8
+ export function uvConfigPath(env = process.env, home = homedir(), platform = process.platform) {
9
+ if (env.UV_CONFIG_FILE) return env.UV_CONFIG_FILE;
10
+ if (platform === "win32") {
11
+ const base = env.APPDATA || join(home, "AppData", "Roaming");
12
+ return join(base, "uv", "uv.toml");
13
+ }
14
+ const base = env.XDG_CONFIG_HOME || join(home, ".config");
15
+ return join(base, "uv", "uv.toml");
16
+ }
17
+
18
+ export function bunConfigPath(env = process.env, home = homedir()) {
19
+ return env.BUN_CONFIG_FILE || join(home, ".bunfig.toml");
20
+ }
21
+
22
+ export function yarnrcPath(env = process.env, home = homedir()) {
23
+ return env.YARN_RC_FILENAME || join(home, ".yarnrc.yml");
24
+ }
25
+
26
+ export function cargoConfigPath(env = process.env, home = homedir()) {
27
+ if (env.CARGO_HOME) return join(env.CARGO_HOME, "config.toml");
28
+ return join(home, ".cargo", "config.toml");
29
+ }
30
+
31
+ export function miseConfigPath(env = process.env, home = homedir(), platform = process.platform) {
32
+ if (env.MISE_GLOBAL_CONFIG_FILE) return env.MISE_GLOBAL_CONFIG_FILE;
33
+ if (platform === "win32") {
34
+ const base = env.LOCALAPPDATA || join(home, "AppData", "Local");
35
+ return join(base, "mise", "config.toml");
36
+ }
37
+ const base = env.XDG_CONFIG_HOME || join(home, ".config");
38
+ return join(base, "mise", "config.toml");
39
+ }
@@ -0,0 +1,19 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ export function detectVersion(bin, args = ["--version"]) {
4
+ try {
5
+ const res = spawnSync(bin, args, { encoding: "utf8" });
6
+ if (res.status !== 0) return null;
7
+ const m = (res.stdout || "").match(/(\d+)\.(\d+)\.(\d+)/);
8
+ if (!m) return null;
9
+ return { major: +m[1], minor: +m[2], patch: +m[3], raw: m[0] };
10
+ } catch { return null; }
11
+ }
12
+
13
+ export function gte(v, target) {
14
+ if (!v) return null;
15
+ const [a, b, c] = target;
16
+ if (v.major !== a) return v.major > a;
17
+ if (v.minor !== b) return v.minor > b;
18
+ return v.patch >= c;
19
+ }