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 +10 -0
- pmsec-0.1.0/PKG-INFO +45 -0
- pmsec-0.1.0/README.md +35 -0
- pmsec-0.1.0/pyproject.toml +19 -0
- pmsec-0.1.0/src/pmsec/__init__.py +1 -0
- pmsec-0.1.0/src/pmsec/__main__.py +3 -0
- pmsec-0.1.0/src/pmsec/cli.py +158 -0
- pmsec-0.1.0/src/pmsec/tools/__init__.py +0 -0
- pmsec-0.1.0/src/pmsec/tools/bun.py +70 -0
- pmsec-0.1.0/src/pmsec/tools/cargo.py +59 -0
- pmsec-0.1.0/src/pmsec/tools/mise.py +71 -0
- pmsec-0.1.0/src/pmsec/tools/npm.py +63 -0
- pmsec-0.1.0/src/pmsec/tools/pnpm.py +69 -0
- pmsec-0.1.0/src/pmsec/tools/uv.py +76 -0
- pmsec-0.1.0/src/pmsec/tools/yarn.py +76 -0
- pmsec-0.1.0/src/pmsec/util/__init__.py +0 -0
- pmsec-0.1.0/src/pmsec/util/lines.py +108 -0
- pmsec-0.1.0/src/pmsec/util/paths.py +52 -0
- pmsec-0.1.0/src/pmsec/util/version.py +28 -0
- pmsec-0.1.0/tests/test_cli.py +141 -0
- pmsec-0.1.0/uv.lock +7 -0
pmsec-0.1.0/.gitignore
ADDED
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,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"
|