pmsec 0.1.0__tar.gz

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.
pmsec-0.1.0/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ node_modules/
2
+ *.tgz
3
+ .venv/
4
+ __pycache__/
5
+ *.pyc
6
+ *.bak
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .pytest_cache/
pmsec-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.4
2
+ Name: pmsec
3
+ Version: 0.1.0
4
+ Summary: Inspect and apply install-time cooldown (min-release-age / exclude-newer) for npm and uv.
5
+ Author-email: Hikaru Egashira <ai@egahika.dev>
6
+ License: MIT
7
+ Keywords: cooldown,exclude-newer,min-release-age,npm,pmsec,supply-chain,uv
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+
11
+ # pmsec (Python)
12
+
13
+ `pmsec` is a cross-platform CLI that inspects and applies install-time cooldown
14
+ settings (e.g. npm `min-release-age`, uv `exclude-newer`) to mitigate
15
+ supply-chain attacks where malicious packages are typically detected and
16
+ removed within hours to days of publication.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ uvx pmsec check --min 7
22
+ uvx pmsec set 7
23
+ uvx pmsec unset
24
+ ```
25
+
26
+ ## Supported tools
27
+
28
+ npm, pnpm, yarn 4+, bun, cargo (RFC #3801), mise, uv
29
+
30
+ ## Commands
31
+
32
+ | Command | Description |
33
+ | --- | --- |
34
+ | `pmsec check [--min N]` | Read each tool's config; exit 1 if any tool is below `N` days or unset |
35
+ | `pmsec set <DAYS> [--force]` | Write `DAYS`-day cooldown to every selected tool |
36
+ | `pmsec unset` | Remove only the cooldown key from each config (other keys preserved) |
37
+
38
+ Options: `--tool npm,pnpm,yarn,bun,cargo,mise,uv`, `--json`.
39
+
40
+ See the [project README](https://github.com/HikaruEgashira/pmsec) for the full
41
+ table of keys, units, paths, and overrides.
42
+
43
+ ## License
44
+
45
+ MIT
pmsec-0.1.0/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # pmsec (Python)
2
+
3
+ `pmsec` is a cross-platform CLI that inspects and applies install-time cooldown
4
+ settings (e.g. npm `min-release-age`, uv `exclude-newer`) to mitigate
5
+ supply-chain attacks where malicious packages are typically detected and
6
+ removed within hours to days of publication.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ uvx pmsec check --min 7
12
+ uvx pmsec set 7
13
+ uvx pmsec unset
14
+ ```
15
+
16
+ ## Supported tools
17
+
18
+ npm, pnpm, yarn 4+, bun, cargo (RFC #3801), mise, uv
19
+
20
+ ## Commands
21
+
22
+ | Command | Description |
23
+ | --- | --- |
24
+ | `pmsec check [--min N]` | Read each tool's config; exit 1 if any tool is below `N` days or unset |
25
+ | `pmsec set <DAYS> [--force]` | Write `DAYS`-day cooldown to every selected tool |
26
+ | `pmsec unset` | Remove only the cooldown key from each config (other keys preserved) |
27
+
28
+ Options: `--tool npm,pnpm,yarn,bun,cargo,mise,uv`, `--json`.
29
+
30
+ See the [project README](https://github.com/HikaruEgashira/pmsec) for the full
31
+ table of keys, units, paths, and overrides.
32
+
33
+ ## License
34
+
35
+ MIT
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "pmsec"
3
+ version = "0.1.0"
4
+ description = "Inspect and apply install-time cooldown (min-release-age / exclude-newer) for npm and uv."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Hikaru Egashira", email = "ai@egahika.dev" }]
9
+ keywords = ["supply-chain", "cooldown", "min-release-age", "exclude-newer", "npm", "uv", "pmsec"]
10
+
11
+ [project.scripts]
12
+ pmsec = "pmsec.cli:main"
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/pmsec"]
@@ -0,0 +1 @@
1
+ __all__ = ["cli"]
@@ -0,0 +1,3 @@
1
+ from pmsec.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from pmsec.tools import bun, cargo, mise, npm, pnpm, uv, yarn
9
+ from pmsec.util.paths import current_platform
10
+
11
+ TOOLS = [npm, pnpm, yarn, bun, cargo, mise, uv]
12
+ DEFAULT_MIN = 7
13
+
14
+ USAGE_EPILOG = """\
15
+ examples:
16
+ uvx pmsec check --min 7
17
+ uvx pmsec set 7
18
+ uvx pmsec unset --tool npm
19
+ """
20
+
21
+
22
+ def _parser() -> argparse.ArgumentParser:
23
+ p = argparse.ArgumentParser(
24
+ prog="pmsec",
25
+ description="Inspect and apply install-time cooldown for npm, pnpm, yarn, bun, mise, and uv.",
26
+ epilog=USAGE_EPILOG,
27
+ formatter_class=argparse.RawDescriptionHelpFormatter,
28
+ )
29
+ sub = p.add_subparsers(dest="command", required=True)
30
+
31
+ common = argparse.ArgumentParser(add_help=False)
32
+ common.add_argument("--tool", help="comma-separated subset of tools (npm,pnpm,yarn,bun,cargo,mise,uv)")
33
+ common.add_argument("--json", action="store_true", help="emit JSON output")
34
+
35
+ c = sub.add_parser("check", parents=[common], help="inspect cooldown settings")
36
+ c.add_argument("--min", type=int, default=DEFAULT_MIN, help=f"minimum days (default {DEFAULT_MIN})")
37
+
38
+ s = sub.add_parser("set", parents=[common], help="apply cooldown")
39
+ s.add_argument("days", type=int, help="cooldown in days (must be > 0)")
40
+ s.add_argument("--force", action="store_true", help="apply even if a tool's installed version is too old")
41
+
42
+ sub.add_parser("unset", parents=[common], help="remove cooldown")
43
+
44
+ return p
45
+
46
+
47
+ def _select(only: str | None) -> list:
48
+ if not only:
49
+ return TOOLS
50
+ names = [n.strip() for n in only.split(",") if n.strip()]
51
+ found = [t for t in TOOLS if t.NAME in names]
52
+ missing = [n for n in names if not any(t.NAME == n for t in TOOLS)]
53
+ if missing:
54
+ raise SystemExit(f"pmsec: unknown tool(s): {','.join(missing)}")
55
+ return found
56
+
57
+
58
+ def _gather(targets, env, home, platform):
59
+ rows = []
60
+ for t in targets:
61
+ r = t.read(env, home, platform)
62
+ rows.append({"tool": t.NAME, "key": t.KEY, **r})
63
+ return rows
64
+
65
+
66
+ def _render_human(rows, min_days):
67
+ out = []
68
+ for r in rows:
69
+ if r["days"] is None:
70
+ status = "MISSING"
71
+ elif r["days"] < min_days:
72
+ status = "STALE "
73
+ else:
74
+ status = "OK "
75
+ out.append(f"{status} {r['tool']:<4} {r['key']} = {r['configured'] or '(unset)'} [{r['path']}]")
76
+ return "\n".join(out) + "\n"
77
+
78
+
79
+ def _check(args, targets, env, home, platform, out, err):
80
+ rows = _gather(targets, env, home, platform)
81
+ failing = [r for r in rows if r["days"] is None or r["days"] < args.min]
82
+ if args.json:
83
+ out.write(json.dumps({"min": args.min, "rows": rows, "ok": not failing}, indent=2) + "\n")
84
+ else:
85
+ out.write(_render_human(rows, args.min))
86
+ if failing:
87
+ err.write(f"pmsec: {len(failing)} tool(s) below {args.min} days\n")
88
+ return 1
89
+ return 0
90
+
91
+
92
+ def _set(args, targets, env, home, platform, out, err):
93
+ if args.days <= 0:
94
+ raise SystemExit("pmsec: set requires DAYS > 0")
95
+ for t in targets:
96
+ pf = getattr(t, "preflight", None)
97
+ if pf is None:
98
+ continue
99
+ result = pf()
100
+ if result["ok"] and result.get("warn"):
101
+ err.write(f"pmsec: {t.NAME}: {result['message']}\n")
102
+ if result["ok"]:
103
+ continue
104
+ if not args.force:
105
+ raise SystemExit(f"pmsec: {t.NAME}: {result['message']}")
106
+ err.write(f"pmsec: {t.NAME}: {result['message']} (continuing due to --force)\n")
107
+ results = []
108
+ for t in targets:
109
+ r = t.write(args.days, env, home, platform)
110
+ results.append({"tool": t.NAME, "path": r["path"], "days": args.days})
111
+ if args.json:
112
+ out.write(json.dumps({"set": args.days, "results": results}, indent=2) + "\n")
113
+ else:
114
+ for r in results:
115
+ out.write(f"set {r['tool']:<4} {r['days']} days [{r['path']}]\n")
116
+ return 0
117
+
118
+
119
+ def _unset(args, targets, env, home, platform, out):
120
+ results = []
121
+ for t in targets:
122
+ r = t.unset(env, home, platform)
123
+ results.append({"tool": t.NAME, "path": r["path"], "removed": r["removed"]})
124
+ if args.json:
125
+ out.write(json.dumps({"results": results}, indent=2) + "\n")
126
+ else:
127
+ for r in results:
128
+ tag = "rm " if r["removed"] else "skip"
129
+ out.write(f"{tag} {r['tool']:<4} [{r['path']}]\n")
130
+ return 0
131
+
132
+
133
+ def main(
134
+ argv: list[str] | None = None,
135
+ *,
136
+ env: dict[str, str] | None = None,
137
+ home: Path | None = None,
138
+ platform: str | None = None,
139
+ out=None,
140
+ err=None,
141
+ ) -> int:
142
+ import os
143
+
144
+ env = dict(os.environ) if env is None else env
145
+ home = Path.home() if home is None else home
146
+ platform = current_platform() if platform is None else platform
147
+ out = sys.stdout if out is None else out
148
+ err = sys.stderr if err is None else err
149
+
150
+ args = _parser().parse_args(argv)
151
+ targets = _select(args.tool)
152
+ if args.command == "check":
153
+ return _check(args, targets, env, home, platform, out, err)
154
+ if args.command == "set":
155
+ return _set(args, targets, env, home, platform, out, err)
156
+ if args.command == "unset":
157
+ return _unset(args, targets, env, home, platform, out)
158
+ return 2
File without changes
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from pmsec.util.lines import read_key, remove_key, set_key
6
+ from pmsec.util.paths import bun_config_path
7
+ from pmsec.util.version import detect_version, gte
8
+
9
+ NAME = "bun"
10
+ KEY = "minimumReleaseAge"
11
+ SECTION = "install"
12
+ DOCS = "https://bun.com/docs/runtime/bunfig#install"
13
+ MIN_BIN = (1, 3, 0)
14
+
15
+
16
+ def path(env: dict[str, str], home: Path, platform: str) -> Path:
17
+ return bun_config_path(env, home)
18
+
19
+
20
+ def preflight() -> dict:
21
+ v = detect_version("bun")
22
+ if v is None:
23
+ return {"ok": True, "message": None}
24
+ if gte(v, MIN_BIN):
25
+ return {"ok": True, "version": v[3], "message": None}
26
+ msg = (
27
+ f"bun {v[3]} < {'.'.join(str(n) for n in MIN_BIN)}: "
28
+ "minimumReleaseAge is silently ignored. Upgrade bun to enforce the cooldown."
29
+ )
30
+ return {"ok": True, "warn": True, "version": v[3], "message": msg}
31
+
32
+
33
+ def read(env: dict[str, str], home: Path, platform: str) -> dict:
34
+ p = path(env, home, platform)
35
+ raw = p.read_text("utf-8") if p.exists() else ""
36
+ value = read_key(raw, KEY, section=SECTION)
37
+ seconds = None
38
+ if value is not None:
39
+ try:
40
+ seconds = int(value)
41
+ except ValueError:
42
+ seconds = None
43
+ days = None if seconds is None else seconds // 86400
44
+ return {"path": str(p), "configured": value, "days": days}
45
+
46
+
47
+ def write(days: int, env: dict[str, str], home: Path, platform: str) -> dict:
48
+ p = path(env, home, platform)
49
+ before = p.read_text("utf-8") if p.exists() else ""
50
+ after = set_key(before, KEY, f"{KEY} = {days * 86400}", section=SECTION)
51
+ p.parent.mkdir(parents=True, exist_ok=True)
52
+ bak = p.with_suffix(p.suffix + ".bak")
53
+ if p.exists() and not bak.exists():
54
+ bak.write_text(before, "utf-8")
55
+ p.write_text(after, "utf-8")
56
+ return {"path": str(p), "before": before, "after": after}
57
+
58
+
59
+ def unset(env: dict[str, str], home: Path, platform: str) -> dict:
60
+ p = path(env, home, platform)
61
+ if not p.exists():
62
+ return {"path": str(p), "removed": False}
63
+ before = p.read_text("utf-8")
64
+ after, removed = remove_key(before, KEY, section=SECTION)
65
+ if removed:
66
+ bak = p.with_suffix(p.suffix + ".bak")
67
+ if not bak.exists():
68
+ bak.write_text(before, "utf-8")
69
+ p.write_text(after, "utf-8")
70
+ return {"path": str(p), "removed": removed}
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from pmsec.util.lines import read_key, remove_key, set_key
7
+ from pmsec.util.paths import cargo_config_path
8
+
9
+ NAME = "cargo"
10
+ KEY = "minimum-release-age"
11
+ SECTION = "install"
12
+ DOCS = "https://rust-lang.github.io/rfcs/3801-package-cooldown.html"
13
+
14
+
15
+ def path(env: dict[str, str], home: Path, platform: str) -> Path:
16
+ return cargo_config_path(env, home)
17
+
18
+
19
+ def _parse_days(value: str | None) -> int | None:
20
+ if value is None:
21
+ return None
22
+ m = re.match(r'^"?\s*(\d+)\s*(d|days?|w|weeks?)\s*"?$', value, re.IGNORECASE)
23
+ if not m:
24
+ return None
25
+ n = int(m.group(1))
26
+ return n * 7 if re.match(r"^(w|weeks?)$", m.group(2), re.IGNORECASE) else n
27
+
28
+
29
+ def read(env: dict[str, str], home: Path, platform: str) -> dict:
30
+ p = path(env, home, platform)
31
+ raw = p.read_text("utf-8") if p.exists() else ""
32
+ value = read_key(raw, KEY, section=SECTION)
33
+ return {"path": str(p), "configured": value, "days": _parse_days(value)}
34
+
35
+
36
+ def write(days: int, env: dict[str, str], home: Path, platform: str) -> dict:
37
+ p = path(env, home, platform)
38
+ before = p.read_text("utf-8") if p.exists() else ""
39
+ after = set_key(before, KEY, f'{KEY} = "{days}d"', section=SECTION)
40
+ p.parent.mkdir(parents=True, exist_ok=True)
41
+ bak = p.with_suffix(p.suffix + ".bak")
42
+ if p.exists() and not bak.exists():
43
+ bak.write_text(before, "utf-8")
44
+ p.write_text(after, "utf-8")
45
+ return {"path": str(p), "before": before, "after": after}
46
+
47
+
48
+ def unset(env: dict[str, str], home: Path, platform: str) -> dict:
49
+ p = path(env, home, platform)
50
+ if not p.exists():
51
+ return {"path": str(p), "removed": False}
52
+ before = p.read_text("utf-8")
53
+ after, removed = remove_key(before, KEY, section=SECTION)
54
+ if removed:
55
+ bak = p.with_suffix(p.suffix + ".bak")
56
+ if not bak.exists():
57
+ bak.write_text(before, "utf-8")
58
+ p.write_text(after, "utf-8")
59
+ return {"path": str(p), "removed": removed}
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from pmsec.util.lines import read_key, remove_key, set_key
7
+ from pmsec.util.paths import mise_config_path
8
+
9
+ NAME = "mise"
10
+ KEY = "minimum_release_age"
11
+ SECTION = "settings"
12
+ DOCS = "https://mise.jdx.dev/configuration/settings.html#minimum_release_age"
13
+
14
+ _DURATION = re.compile(
15
+ r'^"?\s*(\d+)\s*(d|days?|w|weeks?|m|months?|y|years?)\s*"?$',
16
+ re.IGNORECASE,
17
+ )
18
+
19
+
20
+ def path(env: dict[str, str], home: Path, platform: str) -> Path:
21
+ return mise_config_path(env, home, platform)
22
+
23
+
24
+ def _parse_days(value: str | None) -> int | None:
25
+ if value is None:
26
+ return None
27
+ m = _DURATION.match(value)
28
+ if not m:
29
+ return None
30
+ n = int(m.group(1))
31
+ unit = m.group(2).lower()
32
+ if unit in ("w", "week", "weeks"):
33
+ return n * 7
34
+ if unit in ("m", "month", "months"):
35
+ return n * 30
36
+ if unit in ("y", "year", "years"):
37
+ return n * 365
38
+ return n
39
+
40
+
41
+ def read(env: dict[str, str], home: Path, platform: str) -> dict:
42
+ p = path(env, home, platform)
43
+ raw = p.read_text("utf-8") if p.exists() else ""
44
+ value = read_key(raw, KEY, section=SECTION)
45
+ return {"path": str(p), "configured": value, "days": _parse_days(value)}
46
+
47
+
48
+ def write(days: int, env: dict[str, str], home: Path, platform: str) -> dict:
49
+ p = path(env, home, platform)
50
+ before = p.read_text("utf-8") if p.exists() else ""
51
+ after = set_key(before, KEY, f'{KEY} = "{days}d"', section=SECTION)
52
+ p.parent.mkdir(parents=True, exist_ok=True)
53
+ bak = p.with_suffix(p.suffix + ".bak")
54
+ if p.exists() and not bak.exists():
55
+ bak.write_text(before, "utf-8")
56
+ p.write_text(after, "utf-8")
57
+ return {"path": str(p), "before": before, "after": after}
58
+
59
+
60
+ def unset(env: dict[str, str], home: Path, platform: str) -> dict:
61
+ p = path(env, home, platform)
62
+ if not p.exists():
63
+ return {"path": str(p), "removed": False}
64
+ before = p.read_text("utf-8")
65
+ after, removed = remove_key(before, KEY, section=SECTION)
66
+ if removed:
67
+ bak = p.with_suffix(p.suffix + ".bak")
68
+ if not bak.exists():
69
+ bak.write_text(before, "utf-8")
70
+ p.write_text(after, "utf-8")
71
+ return {"path": str(p), "removed": removed}
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from pmsec.util.lines import read_key, remove_key, set_key
6
+ from pmsec.util.paths import npmrc_path
7
+ from pmsec.util.version import detect_version, gte
8
+
9
+ NAME = "npm"
10
+ KEY = "min-release-age"
11
+ DOCS = "https://docs.npmjs.com/cli/v11/using-npm/config#min-release-age"
12
+ MIN_BIN = (11, 10, 0)
13
+
14
+
15
+ def preflight() -> dict:
16
+ v = detect_version("npm")
17
+ if v is None:
18
+ return {"ok": True, "message": None}
19
+ if gte(v, MIN_BIN):
20
+ return {"ok": True, "version": v[3], "message": None}
21
+ msg = (
22
+ f"npm {v[3]} < {'.'.join(str(n) for n in MIN_BIN)}: "
23
+ "min-release-age is silently ignored. Upgrade npm to enforce the cooldown."
24
+ )
25
+ return {"ok": True, "warn": True, "version": v[3], "message": msg}
26
+
27
+
28
+ def path(env: dict[str, str], home: Path, platform: str) -> Path:
29
+ return npmrc_path(env, home)
30
+
31
+
32
+ def read(env: dict[str, str], home: Path, platform: str) -> dict:
33
+ p = path(env, home, platform)
34
+ raw = p.read_text("utf-8") if p.exists() else ""
35
+ value = read_key(raw, KEY)
36
+ days = None if value is None else int(value)
37
+ return {"path": str(p), "configured": value, "days": days}
38
+
39
+
40
+ def write(days: int, env: dict[str, str], home: Path, platform: str) -> dict:
41
+ p = path(env, home, platform)
42
+ before = p.read_text("utf-8") if p.exists() else ""
43
+ after = set_key(before, KEY, f"{KEY}={days}")
44
+ p.parent.mkdir(parents=True, exist_ok=True)
45
+ bak = p.with_suffix(p.suffix + ".bak")
46
+ if p.exists() and not bak.exists():
47
+ bak.write_text(before, "utf-8")
48
+ p.write_text(after, "utf-8")
49
+ return {"path": str(p), "before": before, "after": after}
50
+
51
+
52
+ def unset(env: dict[str, str], home: Path, platform: str) -> dict:
53
+ p = path(env, home, platform)
54
+ if not p.exists():
55
+ return {"path": str(p), "removed": False}
56
+ before = p.read_text("utf-8")
57
+ after, removed = remove_key(before, KEY)
58
+ if removed:
59
+ bak = p.with_suffix(p.suffix + ".bak")
60
+ if not bak.exists():
61
+ bak.write_text(before, "utf-8")
62
+ p.write_text(after, "utf-8")
63
+ return {"path": str(p), "removed": removed}
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from pmsec.util.lines import read_key, remove_key, set_key
6
+ from pmsec.util.paths import npmrc_path
7
+ from pmsec.util.version import detect_version, gte
8
+
9
+ NAME = "pnpm"
10
+ KEY = "minimum-release-age"
11
+ DOCS = "https://pnpm.io/settings#minimumreleaseage"
12
+ MIN_BIN = (10, 6, 0)
13
+
14
+
15
+ def path(env: dict[str, str], home: Path, platform: str) -> Path:
16
+ return npmrc_path(env, home)
17
+
18
+
19
+ def preflight() -> dict:
20
+ v = detect_version("pnpm")
21
+ if v is None:
22
+ return {"ok": True, "message": None}
23
+ if gte(v, MIN_BIN):
24
+ return {"ok": True, "version": v[3], "message": None}
25
+ msg = (
26
+ f"pnpm {v[3]} < {'.'.join(str(n) for n in MIN_BIN)}: "
27
+ "minimum-release-age is silently ignored. Upgrade pnpm to enforce the cooldown."
28
+ )
29
+ return {"ok": True, "warn": True, "version": v[3], "message": msg}
30
+
31
+
32
+ def read(env: dict[str, str], home: Path, platform: str) -> dict:
33
+ p = path(env, home, platform)
34
+ raw = p.read_text("utf-8") if p.exists() else ""
35
+ value = read_key(raw, KEY)
36
+ minutes = None
37
+ if value is not None:
38
+ try:
39
+ minutes = int(value)
40
+ except ValueError:
41
+ minutes = None
42
+ days = None if minutes is None else minutes // (60 * 24)
43
+ return {"path": str(p), "configured": value, "days": days}
44
+
45
+
46
+ def write(days: int, env: dict[str, str], home: Path, platform: str) -> dict:
47
+ p = path(env, home, platform)
48
+ before = p.read_text("utf-8") if p.exists() else ""
49
+ after = set_key(before, KEY, f"{KEY}={days * 24 * 60}")
50
+ p.parent.mkdir(parents=True, exist_ok=True)
51
+ bak = p.with_suffix(p.suffix + ".bak")
52
+ if p.exists() and not bak.exists():
53
+ bak.write_text(before, "utf-8")
54
+ p.write_text(after, "utf-8")
55
+ return {"path": str(p), "before": before, "after": after}
56
+
57
+
58
+ def unset(env: dict[str, str], home: Path, platform: str) -> dict:
59
+ p = path(env, home, platform)
60
+ if not p.exists():
61
+ return {"path": str(p), "removed": False}
62
+ before = p.read_text("utf-8")
63
+ after, removed = remove_key(before, KEY)
64
+ if removed:
65
+ bak = p.with_suffix(p.suffix + ".bak")
66
+ if not bak.exists():
67
+ bak.write_text(before, "utf-8")
68
+ p.write_text(after, "utf-8")
69
+ return {"path": str(p), "removed": removed}
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from pmsec.util.lines import read_key, remove_key, set_key
7
+ from pmsec.util.paths import uv_config_path
8
+ from pmsec.util.version import detect_version, gte
9
+
10
+ NAME = "uv"
11
+ KEY = "exclude-newer"
12
+ DOCS = "https://docs.astral.sh/uv/reference/settings/#exclude-newer"
13
+ MIN_BIN = (0, 9, 17)
14
+
15
+
16
+ def preflight() -> dict:
17
+ v = detect_version("uv")
18
+ if v is None:
19
+ return {"ok": True, "message": None}
20
+ if gte(v, MIN_BIN):
21
+ return {"ok": True, "version": v[3], "message": None}
22
+ msg = (
23
+ f"uv {v[3]} < {'.'.join(str(n) for n in MIN_BIN)}: writing "
24
+ f'exclude-newer = "N days" will break this uv. '
25
+ "Upgrade uv (uv self update) or rerun with --force."
26
+ )
27
+ return {"ok": False, "version": v[3], "message": msg}
28
+
29
+ _DURATION = re.compile(r'^"\s*(\d+)\s*(day|days|d|week|weeks|w)\s*"$', re.IGNORECASE)
30
+
31
+
32
+ def path(env: dict[str, str], home: Path, platform: str) -> Path:
33
+ return uv_config_path(env, home, platform)
34
+
35
+
36
+ def _parse_days(value: str | None) -> int | None:
37
+ if value is None:
38
+ return None
39
+ m = _DURATION.match(value)
40
+ if not m:
41
+ return None
42
+ n = int(m.group(1))
43
+ return n * 7 if m.group(2).lower() in ("week", "weeks", "w") else n
44
+
45
+
46
+ def read(env: dict[str, str], home: Path, platform: str) -> dict:
47
+ p = path(env, home, platform)
48
+ raw = p.read_text("utf-8") if p.exists() else ""
49
+ value = read_key(raw, KEY)
50
+ return {"path": str(p), "configured": value, "days": _parse_days(value)}
51
+
52
+
53
+ def write(days: int, env: dict[str, str], home: Path, platform: str) -> dict:
54
+ p = path(env, home, platform)
55
+ before = p.read_text("utf-8") if p.exists() else ""
56
+ after = set_key(before, KEY, f'{KEY} = "{days} days"')
57
+ p.parent.mkdir(parents=True, exist_ok=True)
58
+ bak = p.with_suffix(p.suffix + ".bak")
59
+ if p.exists() and not bak.exists():
60
+ bak.write_text(before, "utf-8")
61
+ p.write_text(after, "utf-8")
62
+ return {"path": str(p), "before": before, "after": after}
63
+
64
+
65
+ def unset(env: dict[str, str], home: Path, platform: str) -> dict:
66
+ p = path(env, home, platform)
67
+ if not p.exists():
68
+ return {"path": str(p), "removed": False}
69
+ before = p.read_text("utf-8")
70
+ after, removed = remove_key(before, KEY)
71
+ if removed:
72
+ bak = p.with_suffix(p.suffix + ".bak")
73
+ if not bak.exists():
74
+ bak.write_text(before, "utf-8")
75
+ p.write_text(after, "utf-8")
76
+ return {"path": str(p), "removed": removed}
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from pmsec.util.lines import read_key, remove_key, set_key
7
+ from pmsec.util.paths import yarnrc_path
8
+ from pmsec.util.version import detect_version, gte
9
+
10
+ NAME = "yarn"
11
+ KEY = "npmMinimalAgeGate"
12
+ DOCS = "https://yarnpkg.com/configuration/yarnrc#npmMinimalAgeGate"
13
+ MIN_BIN = (4, 10, 0)
14
+ SEP = ":"
15
+
16
+ _DURATION = re.compile(r'^"?\s*(\d+)\s*(d|days?|w|weeks?)\s*"?$', re.IGNORECASE)
17
+
18
+
19
+ def path(env: dict[str, str], home: Path, platform: str) -> Path:
20
+ return yarnrc_path(env, home)
21
+
22
+
23
+ def preflight() -> dict:
24
+ v = detect_version("yarn")
25
+ if v is None:
26
+ return {"ok": True, "message": None}
27
+ if gte(v, MIN_BIN):
28
+ return {"ok": True, "version": v[3], "message": None}
29
+ msg = (
30
+ f"yarn {v[3]} < {'.'.join(str(n) for n in MIN_BIN)}: "
31
+ "npmMinimalAgeGate is silently ignored. Upgrade yarn (v4.10+) to enforce the cooldown."
32
+ )
33
+ return {"ok": True, "warn": True, "version": v[3], "message": msg}
34
+
35
+
36
+ def _parse_days(value: str | None) -> int | None:
37
+ if value is None:
38
+ return None
39
+ m = _DURATION.match(value)
40
+ if not m:
41
+ return None
42
+ n = int(m.group(1))
43
+ return n * 7 if m.group(2).lower() in ("w", "week", "weeks") else n
44
+
45
+
46
+ def read(env: dict[str, str], home: Path, platform: str) -> dict:
47
+ p = path(env, home, platform)
48
+ raw = p.read_text("utf-8") if p.exists() else ""
49
+ value = read_key(raw, KEY, sep=SEP)
50
+ return {"path": str(p), "configured": value, "days": _parse_days(value)}
51
+
52
+
53
+ def write(days: int, env: dict[str, str], home: Path, platform: str) -> dict:
54
+ p = path(env, home, platform)
55
+ before = p.read_text("utf-8") if p.exists() else ""
56
+ after = set_key(before, KEY, f'{KEY}: "{days}d"', sep=SEP)
57
+ p.parent.mkdir(parents=True, exist_ok=True)
58
+ bak = p.with_suffix(p.suffix + ".bak")
59
+ if p.exists() and not bak.exists():
60
+ bak.write_text(before, "utf-8")
61
+ p.write_text(after, "utf-8")
62
+ return {"path": str(p), "before": before, "after": after}
63
+
64
+
65
+ def unset(env: dict[str, str], home: Path, platform: str) -> dict:
66
+ p = path(env, home, platform)
67
+ if not p.exists():
68
+ return {"path": str(p), "removed": False}
69
+ before = p.read_text("utf-8")
70
+ after, removed = remove_key(before, KEY, sep=SEP)
71
+ if removed:
72
+ bak = p.with_suffix(p.suffix + ".bak")
73
+ if not bak.exists():
74
+ bak.write_text(before, "utf-8")
75
+ p.write_text(after, "utf-8")
76
+ return {"path": str(p), "removed": removed}
File without changes
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ _SECTION = re.compile(r"^\s*\[[^\]]+\]\s*$")
6
+ _KEY_EQ = re.compile(r"^\s*([A-Za-z0-9_.\-]+)\s*=")
7
+ _KEY_COLON = re.compile(r"^\s*([A-Za-z0-9_.\-]+)\s*:")
8
+ _FULL_EQ = re.compile(r"^\s*[A-Za-z0-9_.\-]+\s*=\s*(.*?)\s*$")
9
+ _FULL_COLON = re.compile(r"^\s*[A-Za-z0-9_.\-]+\s*:\s*(.*?)\s*$")
10
+
11
+
12
+ def _line_key(line: str, sep: str) -> str | None:
13
+ pattern = _KEY_COLON if sep == ":" else _KEY_EQ
14
+ m = pattern.match(line)
15
+ return m.group(1) if m else None
16
+
17
+
18
+ def _range_for_section(lines: list[str], section: str | None) -> tuple[int, int] | None:
19
+ if section is None:
20
+ for i, line in enumerate(lines):
21
+ if _SECTION.match(line):
22
+ return 0, i
23
+ return 0, len(lines)
24
+ header = f"[{section}]"
25
+ start = -1
26
+ for i, line in enumerate(lines):
27
+ if line.strip() == header:
28
+ start = i + 1
29
+ break
30
+ if start < 0:
31
+ return None
32
+ end = len(lines)
33
+ for i in range(start, len(lines)):
34
+ if _SECTION.match(lines[i]):
35
+ end = i
36
+ break
37
+ return start, end
38
+
39
+
40
+ def _index_of_key(lines: list[str], rng: tuple[int, int], key: str, sep: str) -> int:
41
+ for i in range(rng[0], rng[1]):
42
+ if _line_key(lines[i], sep) == key:
43
+ return i
44
+ return -1
45
+
46
+
47
+ def _index_first_section(lines: list[str]) -> int:
48
+ for i, line in enumerate(lines):
49
+ if _SECTION.match(line):
50
+ return i
51
+ return -1
52
+
53
+
54
+ def read_key(text: str, key: str, *, sep: str = "=", section: str | None = None) -> str | None:
55
+ lines = text.splitlines()
56
+ rng = _range_for_section(lines, section)
57
+ if rng is None:
58
+ return None
59
+ i = _index_of_key(lines, rng, key, sep)
60
+ if i < 0:
61
+ return None
62
+ pattern = _FULL_COLON if sep == ":" else _FULL_EQ
63
+ m = pattern.match(lines[i])
64
+ return m.group(1) if m else None
65
+
66
+
67
+ def set_key(text: str, key: str, value_line: str, *, sep: str = "=", section: str | None = None) -> str:
68
+ trailing = text.endswith("\n") or text == ""
69
+ lines = [] if text == "" else text.rstrip("\n").split("\n")
70
+ rng = _range_for_section(lines, section)
71
+ if rng is None:
72
+ if lines and lines[-1] != "":
73
+ lines.append("")
74
+ lines.extend([f"[{section}]", value_line])
75
+ else:
76
+ idx = _index_of_key(lines, rng, key, sep)
77
+ if idx >= 0:
78
+ lines[idx] = value_line
79
+ elif section:
80
+ lines[rng[0]:rng[0]] = [value_line]
81
+ else:
82
+ first_sec = _index_first_section(lines)
83
+ if first_sec < 0:
84
+ lines.append(value_line)
85
+ else:
86
+ lines[first_sec:first_sec] = [value_line, ""]
87
+ out = "\n".join(lines)
88
+ if trailing or lines:
89
+ out += "\n"
90
+ return out
91
+
92
+
93
+ def remove_key(text: str, key: str, *, sep: str = "=", section: str | None = None) -> tuple[str, bool]:
94
+ trailing = text.endswith("\n")
95
+ lines = [] if text == "" else text.rstrip("\n").split("\n")
96
+ rng = _range_for_section(lines, section)
97
+ if rng is None:
98
+ return text, False
99
+ idx = _index_of_key(lines, rng, key, sep)
100
+ if idx < 0:
101
+ return text, False
102
+ del lines[idx]
103
+ if idx < len(lines) and lines[idx] == "":
104
+ del lines[idx]
105
+ out = "\n".join(lines)
106
+ if trailing and lines:
107
+ out += "\n"
108
+ return out, True
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def npmrc_path(env: dict[str, str], home: Path) -> Path:
8
+ if "NPM_CONFIG_USERCONFIG" in env:
9
+ return Path(env["NPM_CONFIG_USERCONFIG"])
10
+ return home / ".npmrc"
11
+
12
+
13
+ def bun_config_path(env: dict[str, str], home: Path) -> Path:
14
+ if "BUN_CONFIG_FILE" in env:
15
+ return Path(env["BUN_CONFIG_FILE"])
16
+ return home / ".bunfig.toml"
17
+
18
+
19
+ def yarnrc_path(env: dict[str, str], home: Path) -> Path:
20
+ if "YARN_RC_FILENAME" in env:
21
+ return Path(env["YARN_RC_FILENAME"])
22
+ return home / ".yarnrc.yml"
23
+
24
+
25
+ def cargo_config_path(env: dict[str, str], home: Path) -> Path:
26
+ if "CARGO_HOME" in env:
27
+ return Path(env["CARGO_HOME"]) / "config.toml"
28
+ return home / ".cargo" / "config.toml"
29
+
30
+
31
+ def mise_config_path(env: dict[str, str], home: Path, platform: str) -> Path:
32
+ if "MISE_GLOBAL_CONFIG_FILE" in env:
33
+ return Path(env["MISE_GLOBAL_CONFIG_FILE"])
34
+ if platform == "win32":
35
+ base = Path(env["LOCALAPPDATA"]) if "LOCALAPPDATA" in env else home / "AppData" / "Local"
36
+ return base / "mise" / "config.toml"
37
+ base = Path(env["XDG_CONFIG_HOME"]) if "XDG_CONFIG_HOME" in env else home / ".config"
38
+ return base / "mise" / "config.toml"
39
+
40
+
41
+ def uv_config_path(env: dict[str, str], home: Path, platform: str) -> Path:
42
+ if "UV_CONFIG_FILE" in env:
43
+ return Path(env["UV_CONFIG_FILE"])
44
+ if platform == "win32":
45
+ base = Path(env["APPDATA"]) if "APPDATA" in env else home / "AppData" / "Roaming"
46
+ return base / "uv" / "uv.toml"
47
+ base = Path(env["XDG_CONFIG_HOME"]) if "XDG_CONFIG_HOME" in env else home / ".config"
48
+ return base / "uv" / "uv.toml"
49
+
50
+
51
+ def current_platform() -> str:
52
+ return "win32" if os.name == "nt" else "linux"
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import subprocess
5
+
6
+
7
+ def detect_version(bin_name: str, args: list[str] | None = None) -> tuple[int, int, int, str] | None:
8
+ args = args or ["--version"]
9
+ try:
10
+ out = subprocess.run([bin_name, *args], capture_output=True, text=True, timeout=5)
11
+ except (FileNotFoundError, subprocess.TimeoutExpired):
12
+ return None
13
+ if out.returncode != 0:
14
+ return None
15
+ m = re.search(r"(\d+)\.(\d+)\.(\d+)", out.stdout or "")
16
+ if not m:
17
+ return None
18
+ return int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(0)
19
+
20
+
21
+ def gte(v: tuple[int, int, int, str] | None, target: tuple[int, int, int]) -> bool | None:
22
+ if v is None:
23
+ return None
24
+ if v[0] != target[0]:
25
+ return v[0] > target[0]
26
+ if v[1] != target[1]:
27
+ return v[1] > target[1]
28
+ return v[2] >= target[2]
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
11
+
12
+ from pmsec.cli import main # noqa: E402
13
+
14
+
15
+ def env_for(home: Path) -> dict[str, str]:
16
+ return {"HOME": str(home), "XDG_CONFIG_HOME": str(home / ".config")}
17
+
18
+
19
+ def run(argv, home, platform="linux"):
20
+ out, err = io.StringIO(), io.StringIO()
21
+ code = main(argv, env=env_for(home), home=home, platform=platform, out=out, err=err)
22
+ return code, out.getvalue(), err.getvalue()
23
+
24
+
25
+ def test_set_writes_every_supported_tool_config(tmp_path):
26
+ code, _, _ = run(["set", "7"], tmp_path)
27
+ assert code == 0
28
+ assert "min-release-age=7" in (tmp_path / ".npmrc").read_text()
29
+ assert "minimum-release-age=10080" in (tmp_path / ".npmrc").read_text()
30
+ assert 'exclude-newer = "7 days"' in (tmp_path / ".config" / "uv" / "uv.toml").read_text()
31
+ bunfig = (tmp_path / ".bunfig.toml").read_text()
32
+ assert "[install]" in bunfig
33
+ assert "minimumReleaseAge = 604800" in bunfig
34
+ assert 'npmMinimalAgeGate: "7d"' in (tmp_path / ".yarnrc.yml").read_text()
35
+ mise = (tmp_path / ".config" / "mise" / "config.toml").read_text()
36
+ assert "[settings]" in mise
37
+ assert 'minimum_release_age = "7d"' in mise
38
+
39
+
40
+ def test_check_passes_after_set(tmp_path):
41
+ run(["set", "7"], tmp_path)
42
+ code, _, _ = run(["check", "--min", "7"], tmp_path)
43
+ assert code == 0
44
+
45
+
46
+ def test_check_fails_when_missing(tmp_path):
47
+ code, out, _ = run(["check"], tmp_path)
48
+ assert code == 1
49
+ for tool in ("npm", "pnpm", "yarn", "bun", "cargo", "mise", "uv"):
50
+ assert f"MISSING {tool}" in out
51
+
52
+
53
+ def test_unset_preserves_other_keys(tmp_path):
54
+ (tmp_path / ".npmrc").write_text(
55
+ "registry=https://r/\nmin-release-age=7\nminimum-release-age=10080\n"
56
+ )
57
+ uv_dir = tmp_path / ".config" / "uv"
58
+ uv_dir.mkdir(parents=True)
59
+ (uv_dir / "uv.toml").write_text(
60
+ 'exclude-newer = "7 days"\nindex-strategy = "unsafe-best-match"\n'
61
+ )
62
+ (tmp_path / ".bunfig.toml").write_text(
63
+ '[install]\nminimumReleaseAge = 604800\nregistry = "https://x/"\n'
64
+ )
65
+ (tmp_path / ".yarnrc.yml").write_text(
66
+ 'npmMinimalAgeGate: "7d"\nnpmRegistryServer: "https://r/"\n'
67
+ )
68
+ run(["unset"], tmp_path)
69
+ assert (tmp_path / ".npmrc").read_text() == "registry=https://r/\n"
70
+ assert (uv_dir / "uv.toml").read_text() == 'index-strategy = "unsafe-best-match"\n'
71
+ assert (tmp_path / ".bunfig.toml").read_text() == '[install]\nregistry = "https://x/"\n'
72
+ assert (tmp_path / ".yarnrc.yml").read_text() == 'npmRegistryServer: "https://r/"\n'
73
+
74
+
75
+ def test_set_replaces_existing_value(tmp_path):
76
+ (tmp_path / ".npmrc").write_text("min-release-age=3\nregistry=https://r/\n")
77
+ run(["set", "10", "--tool", "npm"], tmp_path)
78
+ assert (tmp_path / ".npmrc").read_text() == "min-release-age=10\nregistry=https://r/\n"
79
+
80
+
81
+ def test_tool_filter(tmp_path):
82
+ run(["set", "7", "--tool", "npm,bun"], tmp_path)
83
+ assert (tmp_path / ".npmrc").exists()
84
+ assert (tmp_path / ".bunfig.toml").exists()
85
+ assert not (tmp_path / ".config" / "uv" / "uv.toml").exists()
86
+
87
+
88
+ def test_windows_uv_path(tmp_path):
89
+ appdata = tmp_path / "AppData" / "Roaming"
90
+ out, err = io.StringIO(), io.StringIO()
91
+ main(["set", "7", "--tool", "uv"], env={"APPDATA": str(appdata)}, home=tmp_path, platform="win32", out=out, err=err)
92
+ assert (appdata / "uv" / "uv.toml").read_text().splitlines()[0] == 'exclude-newer = "7 days"'
93
+
94
+
95
+ def test_check_json(tmp_path):
96
+ _, out, _ = run(["check", "--json"], tmp_path)
97
+ data = json.loads(out)
98
+ assert data["ok"] is False
99
+ assert len(data["rows"]) == 7
100
+ assert [r["tool"] for r in data["rows"]] == ["npm", "pnpm", "yarn", "bun", "cargo", "mise", "uv"]
101
+
102
+
103
+ def test_set_rejects_zero(tmp_path):
104
+ with pytest.raises(SystemExit):
105
+ run(["set", "0"], tmp_path)
106
+
107
+
108
+ def test_bun_section_insert(tmp_path):
109
+ (tmp_path / ".bunfig.toml").write_text('[install]\nregistry = "https://x/"\n')
110
+ run(["set", "7", "--tool", "bun"], tmp_path)
111
+ text = (tmp_path / ".bunfig.toml").read_text()
112
+ assert text.startswith("[install]\nminimumReleaseAge = 604800\nregistry =")
113
+
114
+
115
+ def test_bun_creates_section_if_missing(tmp_path):
116
+ (tmp_path / ".bunfig.toml").write_text("telemetry = false\n")
117
+ run(["set", "7", "--tool", "bun"], tmp_path)
118
+ text = (tmp_path / ".bunfig.toml").read_text()
119
+ assert text == "telemetry = false\n\n[install]\nminimumReleaseAge = 604800\n"
120
+
121
+
122
+ def test_yarn_check_parses_days(tmp_path):
123
+ (tmp_path / ".yarnrc.yml").write_text('npmMinimalAgeGate: "14d"\n')
124
+ _, out, _ = run(["check", "--json", "--tool", "yarn", "--min", "7"], tmp_path)
125
+ data = json.loads(out)
126
+ assert data["ok"] is True
127
+ assert data["rows"][0]["days"] == 14
128
+
129
+
130
+ def test_pnpm_check_normalizes_minutes(tmp_path):
131
+ (tmp_path / ".npmrc").write_text("minimum-release-age=20160\n")
132
+ _, out, _ = run(["check", "--json", "--tool", "pnpm"], tmp_path)
133
+ data = json.loads(out)
134
+ assert data["rows"][0]["days"] == 14
135
+
136
+
137
+ def test_bak_created_once(tmp_path):
138
+ (tmp_path / ".npmrc").write_text("registry=https://original/\n")
139
+ run(["set", "7", "--tool", "npm"], tmp_path)
140
+ run(["set", "10", "--tool", "npm"], tmp_path)
141
+ assert (tmp_path / ".npmrc.bak").read_text() == "registry=https://original/\n"
pmsec-0.1.0/uv.lock ADDED
@@ -0,0 +1,7 @@
1
+ version = 1
2
+ requires-python = ">=3.10"
3
+
4
+ [[package]]
5
+ name = "pmsec"
6
+ version = "0.1.0"
7
+ source = { editable = "." }