pmsec 0.2.0__tar.gz → 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pmsec
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Inspect and apply install-time cooldown (min-release-age / exclude-newer) for npm and uv.
5
5
  Project-URL: Homepage, https://github.com/HikaruEgashira/pmsec
6
6
  Project-URL: Repository, https://github.com/HikaruEgashira/pmsec
@@ -26,6 +26,20 @@ uvx pmsec set 7
26
26
  uvx pmsec unset
27
27
  ```
28
28
 
29
+ ```bash
30
+ npx @hikae/pmsec check --min 7
31
+ npx @hikae/pmsec set 7
32
+ npx @hikae/pmsec unset
33
+ ```
34
+
35
+ If your environment already enforces cooldown (or routes through a proxy
36
+ index), bootstrap pmsec by overriding just for that call:
37
+
38
+ ```bash
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
41
+ ```
42
+
29
43
  ## Supported tools
30
44
 
31
45
  npm, pnpm, yarn 4+, bun, cargo (RFC #3801), mise, uv
@@ -13,6 +13,20 @@ uvx pmsec set 7
13
13
  uvx pmsec unset
14
14
  ```
15
15
 
16
+ ```bash
17
+ npx @hikae/pmsec check --min 7
18
+ npx @hikae/pmsec set 7
19
+ npx @hikae/pmsec unset
20
+ ```
21
+
22
+ If your environment already enforces cooldown (or routes through a proxy
23
+ index), bootstrap pmsec by overriding just for that call:
24
+
25
+ ```bash
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
28
+ ```
29
+
16
30
  ## Supported tools
17
31
 
18
32
  npm, pnpm, yarn 4+, bun, cargo (RFC #3801), mise, uv
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pmsec"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Inspect and apply install-time cooldown (min-release-age / exclude-newer) for npm and uv."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import json
5
+ import shlex
5
6
  import sys
6
7
  from pathlib import Path
7
8
 
@@ -37,7 +38,6 @@ def _parser() -> argparse.ArgumentParser:
37
38
 
38
39
  s = sub.add_parser("set", parents=[common], help="apply cooldown")
39
40
  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
41
 
42
42
  sub.add_parser("unset", parents=[common], help="remove cooldown")
43
43
 
@@ -102,10 +102,11 @@ def _check(args, targets, env, home, platform, out, err):
102
102
  def _explain_fs_error(exc: BaseException, tool: str) -> str:
103
103
  if isinstance(exc, PermissionError):
104
104
  path = getattr(exc, "filename", "") or ""
105
+ q = shlex.quote(path) if path else ""
105
106
  return (
106
107
  f"{tool}: cannot write {path} (PermissionError). "
107
- f"Check file ownership: `ls -la {path}` — if owned by root, "
108
- f"run `sudo chown $(id -u):$(id -g) {path}`."
108
+ f"Check file ownership: `ls -la {q}` — if owned by root, "
109
+ f"run `sudo chown -h $(id -u):$(id -g) {q}`."
109
110
  )
110
111
  if isinstance(exc, OSError) and exc.errno == 30:
111
112
  path = getattr(exc, "filename", "") or ""
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import secrets
5
+ import shlex
6
+ import stat
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ def _is_perm_err(exc: BaseException) -> bool:
13
+ return isinstance(exc, PermissionError) or (isinstance(exc, OSError) and exc.errno in (1, 13))
14
+
15
+
16
+ def _is_symlink(path: Path) -> bool:
17
+ try:
18
+ return stat.S_ISLNK(path.lstat().st_mode)
19
+ except FileNotFoundError:
20
+ return False
21
+
22
+
23
+ def _under_home(path: Path, home: Path) -> bool:
24
+ try:
25
+ target = path.resolve(strict=False)
26
+ root = home.resolve(strict=False)
27
+ except OSError:
28
+ return False
29
+ try:
30
+ target.relative_to(root)
31
+ return True
32
+ except ValueError:
33
+ return False
34
+
35
+
36
+ def _reclaim(path: Path, home: Path, err) -> bool:
37
+ if sys.platform == "win32" or not hasattr(os, "geteuid"):
38
+ return False
39
+ if not path.exists():
40
+ return False
41
+ if _is_symlink(path):
42
+ err.write(f"pmsec: refusing to chown symlink {path}; remove or replace it manually.\n")
43
+ return False
44
+ if not _under_home(path, home):
45
+ err.write(f"pmsec: refusing to chown {path} outside HOME ({home}); fix ownership manually.\n")
46
+ return False
47
+ quoted = shlex.quote(str(path))
48
+ err.write(
49
+ f"pmsec: {path} not writable; running `sudo chown -h $(id -u):$(id -g) {quoted}`. "
50
+ "You may be prompted for your password.\n"
51
+ )
52
+ err.flush()
53
+ r = subprocess.run(["sudo", "chown", "-h", f"{os.geteuid()}:{os.getegid()}", str(path)])
54
+ return r.returncode == 0
55
+
56
+
57
+ def _atomic_replace(path: Path, text: str) -> None:
58
+ parent = path.parent
59
+ tmp = parent / f".{path.name}.{os.getpid()}.{secrets.token_hex(4)}.tmp"
60
+ try:
61
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
62
+ try:
63
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
64
+ f.write(text)
65
+ f.flush()
66
+ try:
67
+ os.fsync(f.fileno())
68
+ except OSError:
69
+ pass
70
+ except BaseException:
71
+ try:
72
+ tmp.unlink()
73
+ except FileNotFoundError:
74
+ pass
75
+ raise
76
+ os.replace(tmp, path)
77
+ except BaseException:
78
+ try:
79
+ tmp.unlink()
80
+ except FileNotFoundError:
81
+ pass
82
+ raise
83
+
84
+
85
+ def write_atomic(path: Path, text: str, *, backup: bool = True, home: Path | None = None, err=sys.stderr) -> None:
86
+ home = Path.home() if home is None else home
87
+ path.parent.mkdir(parents=True, exist_ok=True)
88
+ if path.exists() and _is_symlink(path):
89
+ raise OSError(f"refusing to write through symlink {path}")
90
+ if backup and path.exists():
91
+ bak = path.with_suffix(path.suffix + ".bak")
92
+ if not bak.exists():
93
+ try:
94
+ _atomic_replace(bak, path.read_text("utf-8"))
95
+ except OSError as exc:
96
+ if not _is_perm_err(exc) or not _reclaim(bak if bak.exists() else path, home, err):
97
+ raise
98
+ _atomic_replace(bak, path.read_text("utf-8"))
99
+ try:
100
+ _atomic_replace(path, text)
101
+ except OSError as exc:
102
+ if not _is_perm_err(exc) or not _reclaim(path, home, err):
103
+ raise
104
+ _atomic_replace(path, text)
@@ -4,5 +4,5 @@ requires-python = ">=3.10"
4
4
 
5
5
  [[package]]
6
6
  name = "pmsec"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  source = { editable = "." }
@@ -1,47 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- import subprocess
5
- import sys
6
- from pathlib import Path
7
-
8
-
9
- def _is_perm_err(exc: BaseException) -> bool:
10
- return isinstance(exc, PermissionError) or (isinstance(exc, OSError) and exc.errno in (1, 13))
11
-
12
-
13
- def _reclaim(path: Path, err) -> bool:
14
- if sys.platform == "win32" or not hasattr(os, "geteuid"):
15
- return False
16
- target = path if path.exists() else path.parent
17
- err.write(
18
- f"pmsec: {path} not writable; running `sudo chown $(id -u):$(id -g) {target}`. "
19
- "You may be prompted for your password.\n"
20
- )
21
- err.flush()
22
- r = subprocess.run(["sudo", "chown", f"{os.geteuid()}:{os.getegid()}", str(target)])
23
- return r.returncode == 0
24
-
25
-
26
- def write_atomic(path: Path, text: str, *, backup: bool = True, err=sys.stderr) -> None:
27
- try:
28
- path.parent.mkdir(parents=True, exist_ok=True)
29
- except OSError as exc:
30
- if not _is_perm_err(exc) or not _reclaim(path.parent, err):
31
- raise
32
- path.parent.mkdir(parents=True, exist_ok=True)
33
- if backup and path.exists():
34
- bak = path.with_suffix(path.suffix + ".bak")
35
- if not bak.exists():
36
- try:
37
- bak.write_text(path.read_text("utf-8"), "utf-8")
38
- except OSError as exc:
39
- if not _is_perm_err(exc) or not _reclaim(bak, err):
40
- raise
41
- bak.write_text(path.read_text("utf-8"), "utf-8")
42
- try:
43
- path.write_text(text, "utf-8")
44
- except OSError as exc:
45
- if not _is_perm_err(exc) or not _reclaim(path, err):
46
- raise
47
- path.write_text(text, "utf-8")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes