pmsec 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __all__ = ["cli"]
pmsec/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from pmsec.cli import main
2
+
3
+ raise SystemExit(main())
pmsec/cli.py ADDED
@@ -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
pmsec/tools/bun.py ADDED
@@ -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}
pmsec/tools/cargo.py ADDED
@@ -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}
pmsec/tools/mise.py ADDED
@@ -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}
pmsec/tools/npm.py ADDED
@@ -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}
pmsec/tools/pnpm.py ADDED
@@ -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}
pmsec/tools/uv.py ADDED
@@ -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}
pmsec/tools/yarn.py ADDED
@@ -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}
pmsec/util/__init__.py ADDED
File without changes
pmsec/util/lines.py ADDED
@@ -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
pmsec/util/paths.py ADDED
@@ -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"
pmsec/util/version.py ADDED
@@ -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,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
@@ -0,0 +1,19 @@
1
+ pmsec/__init__.py,sha256=BHXC_FXUTtmWTWbcFFFS0Rt9fjlgdIcRHx53tOuJqX0,18
2
+ pmsec/__main__.py,sha256=27Ftmxdy-_jjt1NmH7EHa7imZFE6ym7zmdygUWnqBl8,53
3
+ pmsec/cli.py,sha256=Qm6mmZ--1q5_m7TcwX5PnhO23qX1cI8akuw7_e1LpWg,5275
4
+ pmsec/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ pmsec/tools/bun.py,sha256=u3-vdLWt6VqmdRRR27Zp0BaszhLCjbNxMMBOs9ArqWk,2373
6
+ pmsec/tools/cargo.py,sha256=PpSEOe70fbI0o1RTF2SzTVadv-B45b0x_QeJl1NSxYU,2014
7
+ pmsec/tools/mise.py,sha256=htU3JXhrvMEpGxdRPgF6vEpvuO4lUPaXgqhuvyH5I0c,2240
8
+ pmsec/tools/npm.py,sha256=uwDZxxHl0TLDIyO7uMTExOF0O-rMXdzQy_i9iHCubYQ,2147
9
+ pmsec/tools/pnpm.py,sha256=OPP2e0ufg6DGmvjkKGjB7X6W3nh3nXebpL0c1uRrHTs,2304
10
+ pmsec/tools/uv.py,sha256=JvyffI9nxON-gisgqpdSceeeOOpDe7BNGbberzDVm6g,2510
11
+ pmsec/tools/yarn.py,sha256=by3lwQDnxnEgaP6ERN-Ehjvg-oHqa3X4bplTyzLxLyw,2513
12
+ pmsec/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ pmsec/util/lines.py,sha256=NoZO0fVY_hf6IVGItfrtxAgsWvSa3R4BgQKtgclekiI,3340
14
+ pmsec/util/paths.py,sha256=r58iBruRSSJZo-tcgpG93oMUjdVDlc4vXbKtBFKsIUA,1777
15
+ pmsec/util/version.py,sha256=dEHwSKQbR0AVNXYZ5Ew268XHrJS-9CpgG4pnLjVQQP0,871
16
+ pmsec-0.1.0.dist-info/METADATA,sha256=VOXbEYmIST-LHVKwUv18QgSL_pNDV-wESvKzuY6ZPPU,1320
17
+ pmsec-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
18
+ pmsec-0.1.0.dist-info/entry_points.txt,sha256=2n1aOJwofBQrgqOdhrlnna-P0blB2R8qLEm8oJIlKOc,41
19
+ pmsec-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pmsec = pmsec.cli:main