pmsec 0.2.2__tar.gz → 0.4.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.
@@ -8,3 +8,4 @@ dist/
8
8
  build/
9
9
  *.egg-info/
10
10
  .pytest_cache/
11
+ .claude/
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pmsec
3
- Version: 0.2.2
4
- Summary: Inspect and apply install-time cooldown (min-release-age / exclude-newer) for npm and uv.
3
+ Version: 0.4.0
4
+ Summary: Inspect and apply install-time cooldown for npm, pnpm, yarn, bun, cargo, mise, and uv.
5
5
  Project-URL: Homepage, https://github.com/HikaruEgashira/pmsec
6
6
  Project-URL: Repository, https://github.com/HikaruEgashira/pmsec
7
7
  Project-URL: Issues, https://github.com/HikaruEgashira/pmsec/issues
8
8
  Author-email: Hikaru Egashira <ai@egahika.dev>
9
9
  License: MIT
10
- Keywords: cooldown,exclude-newer,min-release-age,npm,pmsec,supply-chain,uv
10
+ Keywords: bun,cargo,cooldown,exclude-newer,min-release-age,mise,npm,pmsec,pnpm,supply-chain,uv,yarn
11
11
  Requires-Python: >=3.10
12
12
  Description-Content-Type: text/markdown
13
13
 
@@ -27,9 +27,9 @@ uvx pmsec unset
27
27
  ```
28
28
 
29
29
  ```bash
30
- npx @hikae/pmsec check --min 7
31
- npx @hikae/pmsec set 7
32
- npx @hikae/pmsec unset
30
+ npx pmsec check --min 7
31
+ npx pmsec set 7
32
+ npx pmsec unset
33
33
  ```
34
34
 
35
35
  If your environment already enforces cooldown (or routes through a proxy
@@ -37,7 +37,7 @@ index), bootstrap pmsec by overriding just for that call:
37
37
 
38
38
  ```bash
39
39
  uvx --index https://pypi.org/simple --exclude-newer-package pmsec=2099-01-01 pmsec check
40
- npx --registry=https://registry.npmjs.org/ --min-release-age=0 @hikae/pmsec check
40
+ npx --registry=https://registry.npmjs.org/ --min-release-age=0 pmsec check
41
41
  ```
42
42
 
43
43
  ## Supported tools
@@ -49,8 +49,9 @@ npm, pnpm, yarn 4+, bun, cargo (RFC #3801), mise, uv
49
49
  | Command | Description |
50
50
  | --- | --- |
51
51
  | `pmsec check [--min N]` | Read each tool's config; exit 1 if any tool is below `N` days or unset |
52
- | `pmsec set <DAYS> [--force]` | Write `DAYS`-day cooldown to every selected tool |
52
+ | `pmsec set <DAYS>` | Write `DAYS`-day cooldown to every selected tool |
53
53
  | `pmsec unset` | Remove only the cooldown key from each config (other keys preserved) |
54
+ | `pmsec --version` | Print the installed pmsec version |
54
55
 
55
56
  Options: `--tool npm,pnpm,yarn,bun,cargo,mise,uv`, `--json`.
56
57
 
@@ -14,9 +14,9 @@ uvx pmsec unset
14
14
  ```
15
15
 
16
16
  ```bash
17
- npx @hikae/pmsec check --min 7
18
- npx @hikae/pmsec set 7
19
- npx @hikae/pmsec unset
17
+ npx pmsec check --min 7
18
+ npx pmsec set 7
19
+ npx pmsec unset
20
20
  ```
21
21
 
22
22
  If your environment already enforces cooldown (or routes through a proxy
@@ -24,7 +24,7 @@ index), bootstrap pmsec by overriding just for that call:
24
24
 
25
25
  ```bash
26
26
  uvx --index https://pypi.org/simple --exclude-newer-package pmsec=2099-01-01 pmsec check
27
- npx --registry=https://registry.npmjs.org/ --min-release-age=0 @hikae/pmsec check
27
+ npx --registry=https://registry.npmjs.org/ --min-release-age=0 pmsec check
28
28
  ```
29
29
 
30
30
  ## Supported tools
@@ -36,8 +36,9 @@ npm, pnpm, yarn 4+, bun, cargo (RFC #3801), mise, uv
36
36
  | Command | Description |
37
37
  | --- | --- |
38
38
  | `pmsec check [--min N]` | Read each tool's config; exit 1 if any tool is below `N` days or unset |
39
- | `pmsec set <DAYS> [--force]` | Write `DAYS`-day cooldown to every selected tool |
39
+ | `pmsec set <DAYS>` | Write `DAYS`-day cooldown to every selected tool |
40
40
  | `pmsec unset` | Remove only the cooldown key from each config (other keys preserved) |
41
+ | `pmsec --version` | Print the installed pmsec version |
41
42
 
42
43
  Options: `--tool npm,pnpm,yarn,bun,cargo,mise,uv`, `--json`.
43
44
 
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "pmsec"
3
- version = "0.2.2"
4
- description = "Inspect and apply install-time cooldown (min-release-age / exclude-newer) for npm and uv."
3
+ version = "0.4.0"
4
+ description = "Inspect and apply install-time cooldown for npm, pnpm, yarn, bun, cargo, mise, and uv."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  license = { text = "MIT" }
8
8
  authors = [{ name = "Hikaru Egashira", email = "ai@egahika.dev" }]
9
- keywords = ["supply-chain", "cooldown", "min-release-age", "exclude-newer", "npm", "uv", "pmsec"]
9
+ keywords = ["supply-chain", "cooldown", "min-release-age", "exclude-newer", "npm", "pnpm", "yarn", "bun", "cargo", "mise", "uv", "pmsec"]
10
10
 
11
11
  [project.urls]
12
12
  Homepage = "https://github.com/HikaruEgashira/pmsec"
@@ -0,0 +1,8 @@
1
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
2
+
3
+ try:
4
+ __version__ = _pkg_version("pmsec")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.4.0"
7
+
8
+ __all__ = ["cli", "__version__"]
@@ -6,6 +6,7 @@ import shlex
6
6
  import sys
7
7
  from pathlib import Path
8
8
 
9
+ from pmsec import __version__
9
10
  from pmsec.tools import bun, cargo, mise, npm, pnpm, uv, yarn
10
11
  from pmsec.util.paths import current_platform
11
12
 
@@ -27,6 +28,7 @@ def _parser() -> argparse.ArgumentParser:
27
28
  epilog=USAGE_EPILOG,
28
29
  formatter_class=argparse.RawDescriptionHelpFormatter,
29
30
  )
31
+ p.add_argument("-V", "--version", action="version", version=f"pmsec {__version__}")
30
32
  sub = p.add_subparsers(dest="command", required=True)
31
33
 
32
34
  common = argparse.ArgumentParser(add_help=False)
@@ -67,7 +69,9 @@ def _gather(targets, env, home, platform):
67
69
  rows = []
68
70
  for t in targets:
69
71
  r = t.read(env, home, platform)
70
- rows.append({"tool": t.NAME, "key": t.KEY, "warn": _preflight_warn(t), **r})
72
+ row = {"tool": t.NAME, "key": t.KEY, "warn": _preflight_warn(t), **r}
73
+ row.setdefault("extras", [])
74
+ rows.append(row)
71
75
  return rows
72
76
 
73
77
 
@@ -83,20 +87,31 @@ def _render_human(rows, min_days):
83
87
  out.append(f"{status} {r['tool']:<4} {r['key']} = {r['configured'] or '(unset)'} [{r['path']}]")
84
88
  if r.get("warn"):
85
89
  out.append(f" ⚠ {r['warn']}")
90
+ for e in r.get("extras", []):
91
+ if e["configured"] is None:
92
+ ex_status = "MISSING"
93
+ elif e["ok"]:
94
+ ex_status = "OK "
95
+ else:
96
+ ex_status = "STALE "
97
+ out.append(f"{ex_status} {r['tool']:<4} {e['key']} = {e['configured'] or '(unset)'} [{r['path']}]")
86
98
  return "\n".join(out) + "\n"
87
99
 
88
100
 
89
101
  def _check(args, targets, env, home, platform, out, err):
90
102
  rows = _gather(targets, env, home, platform)
91
- failing = [r for r in rows if r["days"] is None or r["days"] < args.min]
103
+ failing_primary = [r for r in rows if r["days"] is None or r["days"] < args.min]
104
+ failing_extras = [e for r in rows for e in r.get("extras", []) if not e["ok"]]
105
+ ok = not failing_primary and not failing_extras
92
106
  if args.json:
93
- out.write(json.dumps({"min": args.min, "rows": rows, "ok": not failing}, indent=2) + "\n")
107
+ out.write(json.dumps({"min": args.min, "rows": rows, "ok": ok}, indent=2) + "\n")
94
108
  else:
95
109
  out.write(_render_human(rows, args.min))
96
- if failing:
97
- err.write(f"pmsec: {len(failing)} tool(s) below {args.min} days\n")
98
- return 1
99
- return 0
110
+ if failing_primary:
111
+ err.write(f"pmsec: {len(failing_primary)} tool(s) below {args.min} days\n")
112
+ if failing_extras:
113
+ err.write(f"pmsec: {len(failing_extras)} hardening setting(s) not at safe value\n")
114
+ return 0 if ok else 1
100
115
 
101
116
 
102
117
  def _explain_fs_error(exc: BaseException, tool: str) -> str:
@@ -116,7 +131,8 @@ def _explain_fs_error(exc: BaseException, tool: str) -> str:
116
131
 
117
132
  def _set(args, targets, env, home, platform, out, err):
118
133
  if args.days <= 0:
119
- raise SystemExit("pmsec: set requires DAYS > 0")
134
+ err.write("pmsec: set requires integer DAYS > 0\n")
135
+ return 2
120
136
  results = []
121
137
  failures = []
122
138
  warnings = []
@@ -12,6 +12,7 @@ KEY = "minimumReleaseAge"
12
12
  SECTION = "install"
13
13
  DOCS = "https://bun.com/docs/runtime/bunfig#install"
14
14
  MIN_BIN = (1, 3, 0)
15
+ EXTRAS: list[dict] = []
15
16
 
16
17
 
17
18
  def path(env: dict[str, str], home: Path, platform: str) -> Path:
@@ -42,7 +43,7 @@ def read(env: dict[str, str], home: Path, platform: str) -> dict:
42
43
  except ValueError:
43
44
  seconds = None
44
45
  days = None if seconds is None else seconds // 86400
45
- return {"path": str(p), "configured": value, "days": days}
46
+ return {"path": str(p), "configured": value, "days": days, "extras": []}
46
47
 
47
48
 
48
49
  def write(days: int, env: dict[str, str], home: Path, platform: str) -> dict:
@@ -11,6 +11,7 @@ NAME = "cargo"
11
11
  KEY = "minimum-release-age"
12
12
  SECTION = "install"
13
13
  DOCS = "https://rust-lang.github.io/rfcs/3801-package-cooldown.html"
14
+ EXTRAS: list[dict] = []
14
15
 
15
16
 
16
17
  def path(env: dict[str, str], home: Path, platform: str) -> Path:
@@ -31,7 +32,7 @@ def read(env: dict[str, str], home: Path, platform: str) -> dict:
31
32
  p = path(env, home, platform)
32
33
  raw = p.read_text("utf-8") if p.exists() else ""
33
34
  value = read_key(raw, KEY, section=SECTION)
34
- return {"path": str(p), "configured": value, "days": _parse_days(value)}
35
+ return {"path": str(p), "configured": value, "days": _parse_days(value), "extras": []}
35
36
 
36
37
 
37
38
  def write(days: int, env: dict[str, str], home: Path, platform: str) -> dict:
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import re
4
4
  from pathlib import Path
5
5
 
6
+ from pmsec.util.extras import apply_extras, read_extras, remove_extras
6
7
  from pmsec.util.io import write_atomic
7
8
  from pmsec.util.lines import read_key, remove_key, set_key
8
9
  from pmsec.util.paths import mise_config_path
@@ -13,6 +14,9 @@ KEY = "minimum_release_age"
13
14
  SECTION = "settings"
14
15
  DOCS = "https://mise.jdx.dev/configuration/settings.html#minimum_release_age"
15
16
  MIN_BIN = (2026, 4, 22)
17
+ EXTRAS = [
18
+ {"key": "paranoid", "expected": "true", "line": "paranoid = true", "section": SECTION},
19
+ ]
16
20
 
17
21
 
18
22
  def preflight() -> dict:
@@ -59,13 +63,14 @@ def read(env: dict[str, str], home: Path, platform: str) -> dict:
59
63
  p = path(env, home, platform)
60
64
  raw = p.read_text("utf-8") if p.exists() else ""
61
65
  value = read_key(raw, KEY, section=SECTION)
62
- return {"path": str(p), "configured": value, "days": _parse_days(value)}
66
+ return {"path": str(p), "configured": value, "days": _parse_days(value), "extras": read_extras(raw, EXTRAS)}
63
67
 
64
68
 
65
69
  def write(days: int, env: dict[str, str], home: Path, platform: str) -> dict:
66
70
  p = path(env, home, platform)
67
71
  before = p.read_text("utf-8") if p.exists() else ""
68
72
  after = set_key(before, KEY, f'{KEY} = "{days}d"', section=SECTION)
73
+ after = apply_extras(after, EXTRAS)
69
74
  write_atomic(p, after)
70
75
  return {"path": str(p), "before": before, "after": after}
71
76
 
@@ -75,7 +80,9 @@ def unset(env: dict[str, str], home: Path, platform: str) -> dict:
75
80
  if not p.exists():
76
81
  return {"path": str(p), "removed": False}
77
82
  before = p.read_text("utf-8")
78
- after, removed = remove_key(before, KEY, section=SECTION)
83
+ after, removed_cooldown = remove_key(before, KEY, section=SECTION)
84
+ after, removed_extras = remove_extras(after, EXTRAS)
85
+ removed = removed_cooldown or removed_extras
79
86
  if removed:
80
87
  write_atomic(p, after)
81
88
  return {"path": str(p), "removed": removed}
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
4
 
5
+ from pmsec.util.extras import apply_extras, read_extras, remove_extras
5
6
  from pmsec.util.io import write_atomic
6
7
  from pmsec.util.lines import read_key, remove_key, set_key
7
8
  from pmsec.util.paths import npmrc_path
@@ -11,6 +12,9 @@ NAME = "npm"
11
12
  KEY = "min-release-age"
12
13
  DOCS = "https://docs.npmjs.com/cli/v11/using-npm/config#min-release-age"
13
14
  MIN_BIN = (11, 10, 0)
15
+ EXTRAS = [
16
+ {"key": "audit-level", "expected": "high", "line": "audit-level=high"},
17
+ ]
14
18
 
15
19
 
16
20
  def preflight() -> dict:
@@ -35,13 +39,14 @@ def read(env: dict[str, str], home: Path, platform: str) -> dict:
35
39
  raw = p.read_text("utf-8") if p.exists() else ""
36
40
  value = read_key(raw, KEY)
37
41
  days = None if value is None else int(value)
38
- return {"path": str(p), "configured": value, "days": days}
42
+ return {"path": str(p), "configured": value, "days": days, "extras": read_extras(raw, EXTRAS)}
39
43
 
40
44
 
41
45
  def write(days: int, env: dict[str, str], home: Path, platform: str) -> dict:
42
46
  p = path(env, home, platform)
43
47
  before = p.read_text("utf-8") if p.exists() else ""
44
48
  after = set_key(before, KEY, f"{KEY}={days}")
49
+ after = apply_extras(after, EXTRAS)
45
50
  write_atomic(p, after)
46
51
  return {"path": str(p), "before": before, "after": after}
47
52
 
@@ -51,7 +56,9 @@ def unset(env: dict[str, str], home: Path, platform: str) -> dict:
51
56
  if not p.exists():
52
57
  return {"path": str(p), "removed": False}
53
58
  before = p.read_text("utf-8")
54
- after, removed = remove_key(before, KEY)
59
+ after, removed_cooldown = remove_key(before, KEY)
60
+ after, removed_extras = remove_extras(after, EXTRAS)
61
+ removed = removed_cooldown or removed_extras
55
62
  if removed:
56
63
  write_atomic(p, after)
57
64
  return {"path": str(p), "removed": removed}
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
4
 
5
+ from pmsec.util.extras import apply_extras, read_extras, remove_extras
5
6
  from pmsec.util.io import write_atomic
6
7
  from pmsec.util.lines import read_key, remove_key, set_key
7
8
  from pmsec.util.paths import npmrc_path
@@ -11,6 +12,10 @@ NAME = "pnpm"
11
12
  KEY = "minimum-release-age"
12
13
  DOCS = "https://pnpm.io/settings#minimumreleaseage"
13
14
  MIN_BIN = (10, 6, 0)
15
+ EXTRAS = [
16
+ {"key": "trust-policy", "expected": "no-downgrade", "line": "trust-policy=no-downgrade"},
17
+ {"key": "block-exotic-subdeps", "expected": "true", "line": "block-exotic-subdeps=true"},
18
+ ]
14
19
 
15
20
 
16
21
  def path(env: dict[str, str], home: Path, platform: str) -> Path:
@@ -41,13 +46,14 @@ def read(env: dict[str, str], home: Path, platform: str) -> dict:
41
46
  except ValueError:
42
47
  minutes = None
43
48
  days = None if minutes is None else minutes // (60 * 24)
44
- return {"path": str(p), "configured": value, "days": days}
49
+ return {"path": str(p), "configured": value, "days": days, "extras": read_extras(raw, EXTRAS)}
45
50
 
46
51
 
47
52
  def write(days: int, env: dict[str, str], home: Path, platform: str) -> dict:
48
53
  p = path(env, home, platform)
49
54
  before = p.read_text("utf-8") if p.exists() else ""
50
55
  after = set_key(before, KEY, f"{KEY}={days * 24 * 60}")
56
+ after = apply_extras(after, EXTRAS)
51
57
  write_atomic(p, after)
52
58
  return {"path": str(p), "before": before, "after": after}
53
59
 
@@ -57,7 +63,9 @@ def unset(env: dict[str, str], home: Path, platform: str) -> dict:
57
63
  if not p.exists():
58
64
  return {"path": str(p), "removed": False}
59
65
  before = p.read_text("utf-8")
60
- after, removed = remove_key(before, KEY)
66
+ after, removed_cooldown = remove_key(before, KEY)
67
+ after, removed_extras = remove_extras(after, EXTRAS)
68
+ removed = removed_cooldown or removed_extras
61
69
  if removed:
62
70
  write_atomic(p, after)
63
71
  return {"path": str(p), "removed": removed}
@@ -12,6 +12,7 @@ NAME = "uv"
12
12
  KEY = "exclude-newer"
13
13
  DOCS = "https://docs.astral.sh/uv/reference/settings/#exclude-newer"
14
14
  MIN_BIN = (0, 9, 17)
15
+ EXTRAS: list[dict] = []
15
16
 
16
17
 
17
18
  def preflight() -> dict:
@@ -48,7 +49,7 @@ def read(env: dict[str, str], home: Path, platform: str) -> dict:
48
49
  p = path(env, home, platform)
49
50
  raw = p.read_text("utf-8") if p.exists() else ""
50
51
  value = read_key(raw, KEY)
51
- return {"path": str(p), "configured": value, "days": _parse_days(value)}
52
+ return {"path": str(p), "configured": value, "days": _parse_days(value), "extras": []}
52
53
 
53
54
 
54
55
  def write(days: int, env: dict[str, str], home: Path, platform: str) -> dict:
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import re
4
4
  from pathlib import Path
5
5
 
6
+ from pmsec.util.extras import apply_extras, read_extras, remove_extras
6
7
  from pmsec.util.io import write_atomic
7
8
  from pmsec.util.lines import read_key, remove_key, set_key
8
9
  from pmsec.util.paths import yarnrc_path
@@ -13,6 +14,9 @@ KEY = "npmMinimalAgeGate"
13
14
  DOCS = "https://yarnpkg.com/configuration/yarnrc#npmMinimalAgeGate"
14
15
  MIN_BIN = (4, 10, 0)
15
16
  SEP = ":"
17
+ EXTRAS = [
18
+ {"key": "enableHardenedMode", "expected": "true", "line": "enableHardenedMode: true", "sep": SEP},
19
+ ]
16
20
 
17
21
  _DURATION = re.compile(r'^"?\s*(\d+)\s*(d|days?|w|weeks?)\s*"?$', re.IGNORECASE)
18
22
 
@@ -48,13 +52,14 @@ def read(env: dict[str, str], home: Path, platform: str) -> dict:
48
52
  p = path(env, home, platform)
49
53
  raw = p.read_text("utf-8") if p.exists() else ""
50
54
  value = read_key(raw, KEY, sep=SEP)
51
- return {"path": str(p), "configured": value, "days": _parse_days(value)}
55
+ return {"path": str(p), "configured": value, "days": _parse_days(value), "extras": read_extras(raw, EXTRAS)}
52
56
 
53
57
 
54
58
  def write(days: int, env: dict[str, str], home: Path, platform: str) -> dict:
55
59
  p = path(env, home, platform)
56
60
  before = p.read_text("utf-8") if p.exists() else ""
57
61
  after = set_key(before, KEY, f'{KEY}: "{days}d"', sep=SEP)
62
+ after = apply_extras(after, EXTRAS)
58
63
  write_atomic(p, after)
59
64
  return {"path": str(p), "before": before, "after": after}
60
65
 
@@ -64,7 +69,9 @@ def unset(env: dict[str, str], home: Path, platform: str) -> dict:
64
69
  if not p.exists():
65
70
  return {"path": str(p), "removed": False}
66
71
  before = p.read_text("utf-8")
67
- after, removed = remove_key(before, KEY, sep=SEP)
72
+ after, removed_cooldown = remove_key(before, KEY, sep=SEP)
73
+ after, removed_extras = remove_extras(after, EXTRAS)
74
+ removed = removed_cooldown or removed_extras
68
75
  if removed:
69
76
  write_atomic(p, after)
70
77
  return {"path": str(p), "removed": removed}
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from pmsec.util.lines import read_key, remove_key, set_key
4
+
5
+
6
+ def read_extras(raw: str, extras: list[dict]) -> list[dict]:
7
+ rows = []
8
+ for e in extras:
9
+ cur = read_key(raw, e["key"], sep=e.get("sep", "="), section=e.get("section"))
10
+ rows.append({
11
+ "key": e["key"],
12
+ "configured": cur,
13
+ "expected": e["expected"],
14
+ "ok": cur == e["expected"],
15
+ })
16
+ return rows
17
+
18
+
19
+ def apply_extras(text: str, extras: list[dict]) -> str:
20
+ out = text
21
+ for e in extras:
22
+ out = set_key(out, e["key"], e["line"], sep=e.get("sep", "="), section=e.get("section"))
23
+ return out
24
+
25
+
26
+ def remove_extras(text: str, extras: list[dict]) -> tuple[str, bool]:
27
+ out = text
28
+ removed = False
29
+ for e in extras:
30
+ out, r = remove_key(out, e["key"], sep=e.get("sep", "="), section=e.get("section"))
31
+ if r:
32
+ removed = True
33
+ return out, removed
@@ -35,6 +35,12 @@ def test_set_writes_every_supported_tool_config(tmp_path):
35
35
  mise = (tmp_path / ".config" / "mise" / "config.toml").read_text()
36
36
  assert "[settings]" in mise
37
37
  assert 'minimum_release_age = "7d"' in mise
38
+ assert "paranoid = true" in mise
39
+ npmrc = (tmp_path / ".npmrc").read_text()
40
+ assert "audit-level=high" in npmrc
41
+ assert "trust-policy=no-downgrade" in npmrc
42
+ assert "block-exotic-subdeps=true" in npmrc
43
+ assert "enableHardenedMode: true" in (tmp_path / ".yarnrc.yml").read_text()
38
44
 
39
45
 
40
46
  def test_check_passes_after_set(tmp_path):
@@ -75,7 +81,9 @@ def test_unset_preserves_other_keys(tmp_path):
75
81
  def test_set_replaces_existing_value(tmp_path):
76
82
  (tmp_path / ".npmrc").write_text("min-release-age=3\nregistry=https://r/\n")
77
83
  run(["set", "10", "--tool", "npm"], tmp_path)
78
- assert (tmp_path / ".npmrc").read_text() == "min-release-age=10\nregistry=https://r/\n"
84
+ assert (tmp_path / ".npmrc").read_text() == (
85
+ "min-release-age=10\nregistry=https://r/\naudit-level=high\n"
86
+ )
79
87
 
80
88
 
81
89
  def test_tool_filter(tmp_path):
@@ -101,8 +109,9 @@ def test_check_json(tmp_path):
101
109
 
102
110
 
103
111
  def test_set_rejects_zero(tmp_path):
104
- with pytest.raises(SystemExit):
105
- run(["set", "0"], tmp_path)
112
+ code, _, err = run(["set", "0"], tmp_path)
113
+ assert code == 2
114
+ assert "set requires integer DAYS > 0" in err
106
115
 
107
116
 
108
117
  def test_bun_section_insert(tmp_path):
@@ -120,7 +129,9 @@ def test_bun_creates_section_if_missing(tmp_path):
120
129
 
121
130
 
122
131
  def test_yarn_check_parses_days(tmp_path):
123
- (tmp_path / ".yarnrc.yml").write_text('npmMinimalAgeGate: "14d"\n')
132
+ (tmp_path / ".yarnrc.yml").write_text(
133
+ 'npmMinimalAgeGate: "14d"\nenableHardenedMode: true\n'
134
+ )
124
135
  _, out, _ = run(["check", "--json", "--tool", "yarn", "--min", "7"], tmp_path)
125
136
  data = json.loads(out)
126
137
  assert data["ok"] is True
@@ -139,3 +150,37 @@ def test_bak_created_once(tmp_path):
139
150
  run(["set", "7", "--tool", "npm"], tmp_path)
140
151
  run(["set", "10", "--tool", "npm"], tmp_path)
141
152
  assert (tmp_path / ".npmrc.bak").read_text() == "registry=https://original/\n"
153
+
154
+
155
+ def test_hardening_extras_roundtrip(tmp_path):
156
+ (tmp_path / ".npmrc").write_text("minimum-release-age=20160\n")
157
+ code, out, _ = run(["check", "--json", "--tool", "pnpm", "--min", "7"], tmp_path)
158
+ data = json.loads(out)
159
+ assert code == 1
160
+ assert len(data["rows"][0]["extras"]) == 2
161
+ assert all(not e["ok"] for e in data["rows"][0]["extras"])
162
+
163
+ run(["set", "14", "--tool", "pnpm"], tmp_path)
164
+ npmrc = (tmp_path / ".npmrc").read_text()
165
+ assert "trust-policy=no-downgrade" in npmrc
166
+ assert "block-exotic-subdeps=true" in npmrc
167
+
168
+ code, out, _ = run(["check", "--json", "--tool", "pnpm", "--min", "7"], tmp_path)
169
+ assert code == 0
170
+ assert json.loads(out)["ok"] is True
171
+
172
+ run(["unset", "--tool", "pnpm"], tmp_path)
173
+ after = (tmp_path / ".npmrc").read_text()
174
+ assert "trust-policy" not in after
175
+ assert "block-exotic-subdeps" not in after
176
+ assert "minimum-release-age" not in after
177
+
178
+
179
+ @pytest.mark.parametrize("flag", ["--version", "-V"])
180
+ def test_version_flag_prints_package_version(tmp_path, capsys, flag):
181
+ from pmsec import __version__
182
+ with pytest.raises(SystemExit) as exc:
183
+ run([flag], tmp_path)
184
+ assert exc.value.code == 0
185
+ captured = capsys.readouterr()
186
+ assert captured.out.strip() == f"pmsec {__version__}"
@@ -4,5 +4,5 @@ requires-python = ">=3.10"
4
4
 
5
5
  [[package]]
6
6
  name = "pmsec"
7
- version = "0.2.2"
7
+ version = "0.4.0"
8
8
  source = { editable = "." }
@@ -1 +0,0 @@
1
- __all__ = ["cli"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes