rengo-cli 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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .mypy_cache/
6
+ dist/
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.5
2
+ Name: rengo-cli
3
+ Version: 0.1.0
4
+ Summary: Public Rengo developer authentication and package credential helper
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: cryptography<51,>=44
7
+ Requires-Dist: httpx<0.29,>=0.28
8
+ Requires-Dist: keyring==25.7.0
9
+ Requires-Dist: platformdirs<5,>=4
10
+ Requires-Dist: portalocker<4,>=3
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "rengo-cli"
7
+ version = "0.1.0"
8
+ description = "Public Rengo developer authentication and package credential helper"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "cryptography>=44,<51",
12
+ "httpx>=0.28,<0.29",
13
+ "keyring==25.7.0",
14
+ "platformdirs>=4,<5",
15
+ "portalocker>=3,<4",
16
+ ]
17
+
18
+ [project.scripts]
19
+ rengo-auth = "rengo_cli.main:main"
20
+ # pnpm tokenHelper launcher; other gateways reach it through per-origin links
21
+ # (rengo_cli.npm).
22
+ rengo-pnpm-token = "rengo_cli.npm:main"
23
+ # uv's subprocess keyring provider runs whatever `keyring` is on PATH, and a
24
+ # dependency's executables are not linked by `uv tool install`, so the CLI
25
+ # ships the one whose environment carries the Rengo backend. Other services
26
+ # still reach the OS store through keyring's chainer.
27
+ keyring = "keyring.cli:main"
28
+
29
+ [project.entry-points."keyring.backends"]
30
+ rengo = "rengo_cli.keyring_backend"
31
+
32
+ [dependency-groups]
33
+ dev = ["pytest>=8,<10", "ruff>=0.12", "mypy>=1.15"]
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/rengo_cli"]
37
+
38
+ # The public sdist carries the code and what builds it, not the test suite.
39
+ [tool.hatch.build.targets.sdist]
40
+ exclude = ["tests"]
41
+
42
+ [tool.ruff]
43
+ target-version = "py311"
44
+ line-length = 100
45
+
46
+ [tool.ruff.lint]
47
+ select = ["E", "F", "I", "UP", "B"]
48
+
49
+ [tool.mypy]
50
+ python_version = "3.11"
51
+ strict = true
52
+ ignore_missing_imports = true
53
+ files = ["src/rengo_cli"]
54
+
55
+ [tool.pytest.ini_options]
56
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ """Rengo package credentials for uv and pnpm."""
@@ -0,0 +1,133 @@
1
+ import math
2
+ import time
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from .config import OAuthConfig, login_hint
7
+ from .errors import AuthError, ConnectionUnavailable, LoginRequired, OAuthRejected
8
+ from .oauth import Provider, authorize, token_bundle
9
+ from .storage import Store
10
+
11
+
12
+ class Auth:
13
+ def __init__(self, store: Store, provider: Provider | None = None):
14
+ self.store = store
15
+ self.provider = provider or Provider()
16
+
17
+ def _session(self) -> dict[str, Any]:
18
+ value = self.store.read()
19
+ if not value or value.get("logged_out"):
20
+ raise LoginRequired(f"No Rengo login. Run {login_hint(self.store.origin)}.")
21
+ if value.get("pending"):
22
+ raise LoginRequired(
23
+ f"Previous token exchange is uncertain; run {login_hint(self.store.origin)} again."
24
+ )
25
+ try:
26
+ for field in ("issued_at", "expires_at"):
27
+ if not isinstance(value[field], (int, float)) or not math.isfinite(value[field]):
28
+ raise ValueError
29
+ if not 120 < value["expires_at"] - value["issued_at"] <= 86400:
30
+ raise ValueError
31
+ for field in ("access_token", "refresh_token"):
32
+ if not isinstance(value[field], str) or not value[field]:
33
+ raise ValueError
34
+ OAuthConfig.parse(value["oauth"], self.store.origin)
35
+ except (KeyError, TypeError, ValueError):
36
+ raise LoginRequired(
37
+ f"Invalid credential state; run {login_hint(self.store.origin)} again."
38
+ ) from None
39
+ return value
40
+
41
+ def access_token(self) -> str:
42
+ with self.store.lock():
43
+ value = self._session()
44
+ now = time.time()
45
+ if value["expires_at"] > now + 120:
46
+ return str(value["access_token"])
47
+ config = OAuthConfig.parse(value["oauth"], self.store.origin)
48
+ # Commit before sending: process death and uncertain network responses must
49
+ # never cause a successor process to replay a possibly consumed refresh token.
50
+ # The provider rotates refresh tokens and detects reuse: replaying a consumed one
51
+ # revokes the whole grant, live successor included, forcing a browser login.
52
+ value["pending"] = True
53
+ self.store.write(value, value["storage"])
54
+ try:
55
+ raw = self.provider.exchange(
56
+ config, {"grant_type": "refresh_token", "refresh_token": value["refresh_token"]}
57
+ )
58
+ except ConnectionUnavailable:
59
+ value["pending"] = False
60
+ self.store.write(value, value["storage"])
61
+ raise
62
+ except OAuthRejected as error:
63
+ # The service answered definitively, so no unknown outcome can have
64
+ # consumed this refresh token. A definitive 4xx on a refresh_token grant
65
+ # means the stored grant cannot be used again, whatever the provider
66
+ # names the error: some answer invalid_request, not invalid_grant.
67
+ self.store.clear()
68
+ detail = f" ({error.code})" if error.code else ""
69
+ raise LoginRequired(
70
+ f"Rengo login is no longer valid{detail}; "
71
+ f"run {login_hint(self.store.origin)} again."
72
+ ) from None
73
+ successor = token_bundle(raw, config, now)
74
+ self.store.write(successor, value["storage"])
75
+ return str(successor["access_token"])
76
+
77
+ def login(
78
+ self,
79
+ present: Callable[[str], None],
80
+ *,
81
+ storage: str = "native",
82
+ timeout: float = 180,
83
+ port: int = 0,
84
+ ) -> None:
85
+ # Prove storage is available before the browser creates a new grant.
86
+ with self.store.lock():
87
+ self.store.prepare(storage)
88
+ config = self.provider.discover(self.store.origin)
89
+ code, verifier, redirect = authorize(config, present, timeout=timeout, port=port)
90
+ # Holding the same lock through exchange and commit makes final mutations linearizable.
91
+ with self.store.lock():
92
+ issued_at = time.time()
93
+ raw = self.provider.exchange(
94
+ config,
95
+ {
96
+ "grant_type": "authorization_code",
97
+ "code": code,
98
+ "code_verifier": verifier,
99
+ "redirect_uri": redirect,
100
+ },
101
+ )
102
+ bundle = token_bundle(raw, config, issued_at)
103
+ self.store.write(bundle, storage)
104
+
105
+ def logout(self) -> bool:
106
+ with self.store.lock():
107
+ revoked = True
108
+ try:
109
+ value = self.store.read()
110
+ if value and not value.get("logged_out"):
111
+ config = OAuthConfig.parse(value["oauth"], self.store.origin)
112
+ self.provider.revoke(config, value["refresh_token"])
113
+ revoked = not value.get("pending", False)
114
+ except ConnectionUnavailable:
115
+ # Clearing here would destroy the only copy of an unrevoked refresh token.
116
+ raise AuthError(
117
+ "Cannot reach the OAuth service to revoke this login; retry when online."
118
+ ) from None
119
+ except (AuthError, KeyError):
120
+ revoked = False
121
+ self.store.clear()
122
+ return revoked
123
+
124
+ def status(self) -> dict[str, Any]:
125
+ with self.store.lock():
126
+ value = self._session()
127
+ return {
128
+ "origin": self.store.origin,
129
+ "environment": value["oauth"]["environment"],
130
+ "storage": value["storage"],
131
+ "access_expires_at": value["expires_at"],
132
+ "refresh_needed": value["expires_at"] <= time.time() + 120,
133
+ }
@@ -0,0 +1,105 @@
1
+ """The browser login a credential lookup starts itself when no usable grant is stored.
2
+
3
+ uv (through the keyring backend) and pnpm (through the token helper) ask for a
4
+ credential as subprocesses whose stdout they capture, so everything said to the
5
+ person goes to the controlling terminal instead. With no terminal, in CI, or
6
+ with RENGO_AUTH_NO_AUTO_LOGIN set, the lookup fails exactly as it would without
7
+ this module, and the error names the login command.
8
+
9
+ Only `LoginRequired` starts a login: an offline refresh, a busy lock or a locked
10
+ OS store is not fixed by a new grant, and a login would throw away a refresh
11
+ token that still works.
12
+ """
13
+
14
+ import contextlib
15
+ import os
16
+ import sys
17
+ import webbrowser
18
+ from collections.abc import Iterator
19
+ from typing import TextIO
20
+
21
+ from .auth import Auth
22
+ from .errors import AuthError, LoginRequired
23
+ from .storage import Store
24
+
25
+ OPT_OUT_ENV = "RENGO_AUTH_NO_AUTO_LOGIN"
26
+ LOGIN_TIMEOUT = 180.0
27
+
28
+
29
+ def terminal() -> TextIO | None:
30
+ """The person's terminal, or None when nobody is there to finish a login."""
31
+ if os.name == "nt" or os.environ.get("CI") or os.environ.get(OPT_OUT_ENV):
32
+ return None
33
+ try:
34
+ return open("/dev/tty", "w")
35
+ except OSError:
36
+ return None
37
+
38
+
39
+ @contextlib.contextmanager
40
+ def stdout_to_stderr() -> Iterator[None]:
41
+ """Keep a browser launcher's output off stdout, which pnpm takes as the header."""
42
+ sys.stdout.flush()
43
+ saved = os.dup(1)
44
+ try:
45
+ os.dup2(2, 1)
46
+ yield
47
+ finally:
48
+ sys.stdout.flush()
49
+ os.dup2(saved, 1)
50
+ os.close(saved)
51
+
52
+
53
+ def access_token(store: Store, *, configure_pnpm: bool) -> str:
54
+ """The stored access token, logging in through the browser first if none is usable."""
55
+ try:
56
+ return Auth(store).access_token()
57
+ except LoginRequired:
58
+ tty = terminal()
59
+ if tty is None:
60
+ raise
61
+ with tty:
62
+ try:
63
+ return _login(store, tty, configure_pnpm=configure_pnpm)
64
+ except KeyboardInterrupt:
65
+ raise LoginRequired("Rengo login cancelled.") from None
66
+
67
+
68
+ def _login(store: Store, tty: TextIO, *, configure_pnpm: bool) -> str:
69
+ # uv and pnpm may look up several URLs at once; the first lookup logs in and
70
+ # the rest find its grant once the lock is theirs.
71
+ with store.login_lock(LOGIN_TIMEOUT + 30):
72
+ try:
73
+ return Auth(store).access_token()
74
+ except LoginRequired:
75
+ pass
76
+ print(
77
+ f"No usable Rengo login for {store.origin}; opening your browser to log in "
78
+ "(Ctrl-C cancels).",
79
+ file=tty,
80
+ flush=True,
81
+ )
82
+
83
+ def present(url: str) -> None:
84
+ # URL has PKCE challenge/state, never a token or authorization code.
85
+ with stdout_to_stderr():
86
+ opened = webbrowser.open(url)
87
+ if not opened:
88
+ print("Open this authorization URL in your browser:\n" + url, file=tty, flush=True)
89
+
90
+ auth = Auth(store)
91
+ auth.login(present, timeout=LOGIN_TIMEOUT)
92
+ print(f"Logged in to Rengo ({store.origin}).", file=tty, flush=True)
93
+ if configure_pnpm:
94
+ from . import npm
95
+
96
+ try:
97
+ path = npm.configure(store.origin)
98
+ print(
99
+ f"pnpm: {path} routes {npm.registry_url(store.origin)} through the CLI.",
100
+ file=tty,
101
+ flush=True,
102
+ )
103
+ except AuthError as error:
104
+ print(f"pnpm: not configured. {error}", file=tty, flush=True)
105
+ return auth.access_token()
@@ -0,0 +1,208 @@
1
+ import tomllib
2
+ from dataclasses import asdict, dataclass
3
+ from pathlib import Path
4
+ from typing import Any
5
+ from urllib.parse import urlsplit
6
+
7
+ from .errors import AuthError
8
+
9
+ # The gateway used outside an estate checkout. Inside one, the checkout's own
10
+ # package files name the gateway, and everything else about it is learned from
11
+ # that gateway's discovery document.
12
+ DEFAULT_GATEWAY = "https://packages.rengoai.com"
13
+ DEFAULT_AUTHORIZATION_ENDPOINT = "https://app.rengoai.com/oauth/authorize"
14
+
15
+
16
+ def canonical_origin(value: Any) -> str:
17
+ """`https://host`, lower-cased: no userinfo, path, query, fragment or port but 443."""
18
+ if not isinstance(value, str) or any(c.isspace() for c in value) or "\\" in value:
19
+ raise AuthError("Expected a bare HTTPS origin such as https://host.")
20
+ parsed = urlsplit(value)
21
+ try:
22
+ port = parsed.port
23
+ except ValueError:
24
+ raise AuthError("Expected a bare HTTPS origin such as https://host.") from None
25
+ if (
26
+ parsed.scheme != "https"
27
+ or not parsed.hostname
28
+ or parsed.username is not None
29
+ or parsed.password is not None
30
+ or parsed.path not in ("", "/")
31
+ or parsed.query
32
+ or parsed.fragment
33
+ or port not in (None, 443)
34
+ ):
35
+ raise AuthError("Expected a bare HTTPS origin such as https://host.")
36
+ return "https://" + parsed.hostname.lower()
37
+
38
+
39
+ def origin_host(origin: str) -> str:
40
+ return canonical_origin(origin).removeprefix("https://")
41
+
42
+
43
+ def _declared_gateway(url: object, suffix: str) -> str | None:
44
+ """The gateway origin a committed index or registry URL points at, or None."""
45
+ if not isinstance(url, str) or not url.endswith(suffix):
46
+ return None
47
+ parsed = urlsplit(url)
48
+ # uv index URLs carry the `__token__@` username the keyring answers for.
49
+ return "https://" + (parsed.hostname or "") + (f":{parsed.port}" if parsed.port else "")
50
+
51
+
52
+ def project_gateway(start: Path | None = None) -> tuple[str, Path] | None:
53
+ """The gateway the estate checkout around `start` declares, or None outside one.
54
+
55
+ An estate commits its gateway twice: the `[[tool.uv.index]]` URL in
56
+ pyproject.toml and the `@rengoai:registry=` line in .npmrc. Whichever
57
+ directory up from `start` first holds either file decides, so a command
58
+ run anywhere inside a checkout acts on that estate's gateway.
59
+ """
60
+ for directory in [start or Path.cwd(), *(start or Path.cwd()).parents]:
61
+ pyproject, npmrc = directory / "pyproject.toml", directory / ".npmrc"
62
+ if not pyproject.is_file() and not npmrc.is_file():
63
+ continue
64
+ declared: set[str | None] = set()
65
+ if pyproject.is_file():
66
+ try:
67
+ indexes = tomllib.loads(pyproject.read_text())["tool"]["uv"]["index"]
68
+ except (OSError, tomllib.TOMLDecodeError, KeyError, TypeError):
69
+ indexes = []
70
+ for index in indexes if isinstance(indexes, list) else []:
71
+ if isinstance(index, dict):
72
+ declared.add(_declared_gateway(index.get("url"), "/pypi/simple/"))
73
+ if npmrc.is_file():
74
+ for line in npmrc.read_text(errors="replace").splitlines():
75
+ if line.strip().startswith("@rengoai:registry="):
76
+ declared.add(_declared_gateway(line.split("=", 1)[1].strip(), "/npm/"))
77
+ declared.discard(None)
78
+ if not declared:
79
+ return None
80
+ if len(declared) > 1:
81
+ raise AuthError(f"{directory} declares more than one package gateway.")
82
+ return canonical_origin(declared.pop()), directory
83
+ return None
84
+
85
+
86
+ def resolve_gateway() -> tuple[str, str]:
87
+ """The gateway a command acts on and where the choice came from.
88
+
89
+ Inside an estate checkout, the gateway that checkout declares, taken as
90
+ written; anywhere else, the default.
91
+ """
92
+ declared = project_gateway()
93
+ if declared is None:
94
+ return DEFAULT_GATEWAY, "default"
95
+ return declared[0], f"project {declared[1]}"
96
+
97
+
98
+ def login_hint(origin: str) -> str:
99
+ if origin == DEFAULT_GATEWAY:
100
+ return "rengo-auth login"
101
+ return f"rengo-auth login inside an estate checkout for {origin}"
102
+
103
+
104
+ def service_origin(service: str) -> str | None:
105
+ """The origin uv or keyring is asking about, or None if it is not an https origin.
106
+
107
+ uv passes either the bare hostname or the full index URL. Only the https
108
+ authority on port 443 with no userinfo counts; `https://host:8443/` must
109
+ not inherit the credentials of `https://host/`.
110
+ """
111
+ candidate = service if "://" in service else "https://" + service
112
+ parsed = urlsplit(candidate)
113
+ if parsed.scheme != "https" or not parsed.hostname or parsed.query or parsed.fragment:
114
+ return None
115
+ try:
116
+ return canonical_origin("https://" + parsed.netloc)
117
+ except AuthError:
118
+ return None
119
+
120
+
121
+ def https_url(value: Any) -> str:
122
+ if not isinstance(value, str) or any(c.isspace() for c in value):
123
+ raise AuthError("Invalid public OAuth configuration.")
124
+ parsed = urlsplit(value)
125
+ if (
126
+ parsed.scheme != "https"
127
+ or not parsed.hostname
128
+ or parsed.username
129
+ or parsed.password
130
+ or parsed.port not in (None, 443)
131
+ or parsed.query
132
+ or parsed.fragment
133
+ or "\\" in value
134
+ ):
135
+ raise AuthError("Invalid public OAuth configuration.")
136
+ return value
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class OAuthConfig:
141
+ version: int
142
+ environment: str
143
+ registry_url: str
144
+ client_id: str
145
+ issuer: str
146
+ authorization_endpoint: str
147
+ token_endpoint: str
148
+ revocation_endpoint: str
149
+ scopes: list[str]
150
+
151
+ @classmethod
152
+ def parse(cls, raw: Any, origin: str) -> "OAuthConfig":
153
+ """Validate a discovery document for the gateway it describes.
154
+
155
+ `origin` is the gateway the document was fetched from, or the origin a
156
+ stored bundle was bound to at login: a refresh is never routed by
157
+ rediscovery.
158
+ """
159
+ try:
160
+ origin = canonical_origin(origin)
161
+ config = cls(**{name: raw[name] for name in cls.__dataclass_fields__})
162
+ if config.version != 1 or type(config.version) is not int:
163
+ raise ValueError
164
+ # A label for `status`; it decides nothing.
165
+ if not isinstance(config.environment, str) or not config.environment:
166
+ raise ValueError
167
+ # Both registries have one fixed shape under the origin; a gateway
168
+ # that advertises the npm one must agree.
169
+ if config.registry_url != origin + "/pypi/simple/":
170
+ raise ValueError
171
+ npm_registry = raw.get("npm_registry_url") if isinstance(raw, dict) else None
172
+ if npm_registry is not None and npm_registry != origin + "/npm/":
173
+ raise ValueError
174
+ # Where the browser is sent. The default gateway pins it; another
175
+ # gateway's document is trusted for the page it names.
176
+ authorization = https_url(config.authorization_endpoint)
177
+ if origin == DEFAULT_GATEWAY and authorization != DEFAULT_AUTHORIZATION_ENDPOINT:
178
+ raise ValueError
179
+ if urlsplit(authorization).path != "/oauth/authorize":
180
+ raise ValueError
181
+ if not isinstance(config.client_id, str) or not 1 <= len(config.client_id) <= 256:
182
+ raise ValueError
183
+ issuer = https_url(config.issuer)
184
+ if urlsplit(issuer).path not in ("", "/"):
185
+ raise ValueError
186
+ # The trusted gateway delegates its OAuth authority to this issuer.
187
+ # Persist this binding with the grant; never rediscover to route a refresh.
188
+ for endpoint, path in (
189
+ (config.token_endpoint, "/v1/oauth2/token"),
190
+ (config.revocation_endpoint, "/v1/oauth2/revoke"),
191
+ ):
192
+ if https_url(endpoint) != issuer.rstrip("/") + path:
193
+ raise ValueError
194
+ if (
195
+ not isinstance(config.scopes, list)
196
+ or not all(
197
+ isinstance(s, str) and s and not any(c.isspace() for c in s)
198
+ for s in config.scopes
199
+ )
200
+ or not {"packages:read", "offline_access"}.issubset(config.scopes)
201
+ ):
202
+ raise ValueError
203
+ return config
204
+ except (TypeError, KeyError, ValueError, AttributeError, AuthError):
205
+ raise AuthError("Invalid public OAuth configuration; contact Rengo support.") from None
206
+
207
+ def public_dict(self) -> dict[str, Any]:
208
+ return asdict(self)