devlift-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. devlift_cli/MANUAL.md +1066 -0
  2. devlift_cli/__init__.py +3 -0
  3. devlift_cli/__main__.py +4 -0
  4. devlift_cli/api/__init__.py +0 -0
  5. devlift_cli/api/approvals.py +53 -0
  6. devlift_cli/api/catalog.py +96 -0
  7. devlift_cli/api/client.py +125 -0
  8. devlift_cli/api/context.py +21 -0
  9. devlift_cli/api/deployments.py +37 -0
  10. devlift_cli/api/infra.py +94 -0
  11. devlift_cli/api/infra_list.py +61 -0
  12. devlift_cli/api/kong.py +29 -0
  13. devlift_cli/api/services.py +106 -0
  14. devlift_cli/api/vpc.py +24 -0
  15. devlift_cli/app.py +163 -0
  16. devlift_cli/auth/__init__.py +0 -0
  17. devlift_cli/auth/oauth.py +270 -0
  18. devlift_cli/auth/session.py +64 -0
  19. devlift_cli/auth/storage.py +135 -0
  20. devlift_cli/commands/__init__.py +0 -0
  21. devlift_cli/commands/approval.py +51 -0
  22. devlift_cli/commands/auth.py +180 -0
  23. devlift_cli/commands/catalog.py +187 -0
  24. devlift_cli/commands/clusters.py +108 -0
  25. devlift_cli/commands/deployment.py +77 -0
  26. devlift_cli/commands/dynamodb.py +121 -0
  27. devlift_cli/commands/eks.py +326 -0
  28. devlift_cli/commands/kong.py +145 -0
  29. devlift_cli/commands/languages.py +40 -0
  30. devlift_cli/commands/manual.py +82 -0
  31. devlift_cli/commands/repositories.py +49 -0
  32. devlift_cli/commands/request.py +89 -0
  33. devlift_cli/commands/s3.py +198 -0
  34. devlift_cli/commands/sqs.py +229 -0
  35. devlift_cli/config.py +94 -0
  36. devlift_cli/context.py +97 -0
  37. devlift_cli/data/placement/vance.json +16 -0
  38. devlift_cli/errors.py +52 -0
  39. devlift_cli/ops/__init__.py +0 -0
  40. devlift_cli/ops/approvals.py +343 -0
  41. devlift_cli/ops/eks.py +877 -0
  42. devlift_cli/ops/kong.py +343 -0
  43. devlift_cli/ops/placement.py +128 -0
  44. devlift_cli/ops/resources.py +418 -0
  45. devlift_cli/ops/status.py +152 -0
  46. devlift_cli/ops/wait.py +82 -0
  47. devlift_cli/render/__init__.py +0 -0
  48. devlift_cli/render/output.py +75 -0
  49. devlift_cli/resolve/__init__.py +0 -0
  50. devlift_cli/resolve/allowlist.py +192 -0
  51. devlift_cli/resolve/names.py +179 -0
  52. devlift_cli-0.1.0.dist-info/METADATA +106 -0
  53. devlift_cli-0.1.0.dist-info/RECORD +56 -0
  54. devlift_cli-0.1.0.dist-info/WHEEL +5 -0
  55. devlift_cli-0.1.0.dist-info/entry_points.txt +3 -0
  56. devlift_cli-0.1.0.dist-info/top_level.txt +1 -0
devlift_cli/app.py ADDED
@@ -0,0 +1,163 @@
1
+ """Command tree root and process entry point.
2
+
3
+ devlift [--profile P] [-o table|json|yaml] [--yes] [--no-input]
4
+ [--endpoint-url URL] [--debug] <group> <operation> [flags]
5
+
6
+ Every command receives an Invocation (profile, output format, confirmation
7
+ policy, API client) through ctx.obj, and fails by raising CliError, which
8
+ main() prints once and turns into the documented exit code.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+
15
+ import typer
16
+ from rich.markup import escape
17
+
18
+ from devlift_cli import __version__
19
+ from devlift_cli.commands import approval as approval_cmds
20
+ from devlift_cli.commands import auth as auth_cmds
21
+ from devlift_cli.commands import catalog as catalog_cmds
22
+ from devlift_cli.commands import clusters as clusters_cmds
23
+ from devlift_cli.commands import deployment as deployment_cmds
24
+ from devlift_cli.commands import dynamodb as dynamodb_cmds
25
+ from devlift_cli.commands import eks as eks_cmds
26
+ from devlift_cli.commands import kong as kong_cmds
27
+ from devlift_cli.commands import languages as languages_cmds
28
+ from devlift_cli.commands import manual as manual_cmds
29
+ from devlift_cli.commands import repositories as repositories_cmds
30
+ from devlift_cli.commands import request as request_cmds
31
+ from devlift_cli.commands import s3 as s3_cmds
32
+ from devlift_cli.commands import sqs as sqs_cmds
33
+ from devlift_cli.config import load_profile
34
+ from devlift_cli.context import Invocation
35
+ from devlift_cli.errors import CliError
36
+ from devlift_cli.render import output
37
+
38
+ app = typer.Typer(
39
+ name="devlift",
40
+ help="DevLift from the terminal: create, change, review and deploy your services and resources.",
41
+ epilog="Full manual: devlift man (or devlift manual \\[topic]). Start with: devlift configure, devlift login, devlift whoami.",
42
+ no_args_is_help=True,
43
+ pretty_exceptions_enable=False,
44
+ rich_markup_mode="rich",
45
+ context_settings={"help_option_names": ["-h", "--help"]},
46
+ )
47
+
48
+
49
+ def _version(value: bool):
50
+ if value:
51
+ print(f"devlift {__version__}")
52
+ raise typer.Exit()
53
+
54
+
55
+ @app.callback()
56
+ def root(
57
+ ctx: typer.Context,
58
+ profile: str | None = typer.Option(None, "--profile", envvar="DEVLIFT_PROFILE", help="Profile from `devlift configure`."),
59
+ out: str | None = typer.Option(None, "--output", "-o", help="table | json | yaml (json when piped)."),
60
+ yes: bool = typer.Option(False, "--yes", "-y", help="Answer yes to confirmations."),
61
+ no_input: bool = typer.Option(False, "--no-input", help="Never prompt; fail when input is missing."),
62
+ endpoint_url: str | None = typer.Option(None, "--endpoint-url", envvar="DEVLIFT_BASE_URL", help="Override the backend URL for this call."),
63
+ debug: bool = typer.Option(False, "--debug", help="Log every HTTP call to stderr."),
64
+ version: bool = typer.Option(False, "--version", "-V", callback=_version, is_eager=True, help="Print the version and exit."),
65
+ ):
66
+ if out and out not in output.FORMATS:
67
+ raise typer.BadParameter(f"--output must be one of {', '.join(output.FORMATS)}")
68
+ prof = load_profile(profile, base_url_override=endpoint_url)
69
+ ctx.obj = Invocation(
70
+ profile=prof,
71
+ output=output.resolve_format(out, prof.output),
72
+ yes=yes,
73
+ no_input=no_input,
74
+ debug=debug,
75
+ )
76
+ ctx.call_on_close(ctx.obj.close)
77
+
78
+
79
+ # ── auth ─────────────────────────────────────────────────────────────────
80
+ app.command("login")(auth_cmds.login)
81
+ app.command("logout")(auth_cmds.logout)
82
+ app.command("whoami")(auth_cmds.whoami)
83
+ app.command("configure")(auth_cmds.configure)
84
+ app.add_typer(auth_cmds.profile_app, name="profile")
85
+ app.command("manual")(manual_cmds.manual)
86
+ # `devlift man` — the name every terminal user reaches for first. Hidden from
87
+ # the command list so the manual appears once there, under its full name.
88
+ app.command("man", hidden=True)(manual_cmds.manual)
89
+
90
+ # ── catalog ──────────────────────────────────────────────────────────────
91
+ app.add_typer(catalog_cmds.applications_app, name="applications")
92
+ app.add_typer(catalog_cmds.environments_app, name="environments")
93
+ app.add_typer(catalog_cmds.regions_app, name="regions")
94
+ app.add_typer(catalog_cmds.resource_types_app, name="resource-types")
95
+ app.add_typer(catalog_cmds.resource_groups_app, name="resource-groups")
96
+ app.add_typer(catalog_cmds.services_app, name="services")
97
+ app.add_typer(repositories_cmds.repositories_app, name="repositories")
98
+ app.add_typer(languages_cmds.languages_app, name="languages")
99
+ app.add_typer(clusters_cmds.clusters_app, name="clusters")
100
+
101
+ # ── resources ────────────────────────────────────────────────────────────
102
+ app.add_typer(s3_cmds.s3_app, name="s3")
103
+ app.add_typer(sqs_cmds.sqs_app, name="sqs")
104
+ app.add_typer(dynamodb_cmds.dynamodb_app, name="dynamodb")
105
+ app.add_typer(eks_cmds.eks_app, name="eks")
106
+ app.add_typer(kong_cmds.kong_app, name="kong")
107
+ app.add_typer(request_cmds.request_app, name="request")
108
+ app.add_typer(approval_cmds.approval_app, name="approval")
109
+ app.add_typer(deployment_cmds.deployment_app, name="deployment")
110
+ app.add_typer(deployment_cmds.queue_app, name="queue")
111
+
112
+
113
+ _GLOBAL_WITH_VALUE = {"--profile", "--output", "-o", "--endpoint-url"}
114
+ _GLOBAL_FLAGS = {"--yes", "-y", "--no-input", "--debug"}
115
+
116
+
117
+ def _hoist_global_options(argv: list[str]) -> list[str]:
118
+ """Let global options sit anywhere, like `aws s3 ls -o json`.
119
+
120
+ Click only reads group options before the subcommand name, so anything
121
+ from the global set found later in argv is moved to the front. Stops at
122
+ `--` and leaves everything else in order.
123
+ """
124
+ front: list[str] = []
125
+ rest: list[str] = []
126
+ i = 0
127
+ while i < len(argv):
128
+ arg = argv[i]
129
+ if arg == "--":
130
+ rest.extend(argv[i:])
131
+ break
132
+ name, eq, _ = arg.partition("=")
133
+ if name in _GLOBAL_WITH_VALUE:
134
+ if eq:
135
+ front.append(arg)
136
+ elif i + 1 < len(argv):
137
+ front.extend(argv[i : i + 2])
138
+ i += 1
139
+ else:
140
+ rest.append(arg)
141
+ elif name in _GLOBAL_FLAGS and not eq:
142
+ front.append(arg)
143
+ else:
144
+ rest.append(arg)
145
+ i += 1
146
+ return front + rest
147
+
148
+
149
+ def main() -> None:
150
+ try:
151
+ app(args=_hoist_global_options(sys.argv[1:]))
152
+ except CliError as exc:
153
+ output.err_console.print(f"[red]error:[/red] {escape(exc.message)}")
154
+ if exc.hint:
155
+ output.err_console.print(f"[dim]{escape(exc.hint)}[/dim]")
156
+ sys.exit(exc.code)
157
+ except KeyboardInterrupt:
158
+ output.err_console.print("\n[dim]Interrupted.[/dim]")
159
+ sys.exit(130)
160
+
161
+
162
+ if __name__ == "__main__":
163
+ main()
File without changes
@@ -0,0 +1,270 @@
1
+ """Browser login against DevLift's OAuth 2.1 server.
2
+
3
+ The server publishes RFC 8414 metadata at
4
+ `{base_url}/.well-known/oauth-authorization-server/devlift-mcp`. The flow is
5
+ the standard native-app one: dynamic client registration (RFC 7591) with a
6
+ loopback redirect, PKCE S256, a short-lived local HTTP server that catches
7
+ the redirect, then the code exchange. Refresh tokens rotate on every use.
8
+
9
+ The access token is an opaque string that obs_tool's REST API accepts as a
10
+ bearer token, so this is the only login the CLI needs.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import hashlib
17
+ import secrets
18
+ import socket
19
+ import threading
20
+ import time
21
+ import webbrowser
22
+ from http.server import BaseHTTPRequestHandler, HTTPServer
23
+ from urllib.parse import parse_qs, urlencode, urlparse
24
+
25
+ import httpx
26
+
27
+ from devlift_cli.auth.storage import Credentials
28
+ from devlift_cli.config import OAUTH_METADATA_PATH
29
+ from devlift_cli.errors import AuthError, CliError
30
+
31
+ CLIENT_NAME = "devlift-cli"
32
+ LOGIN_TIMEOUT_SECONDS = 300
33
+ _TIMEOUT = httpx.Timeout(30.0, connect=10.0)
34
+
35
+ _SUCCESS_PAGE = """<!doctype html><html><head><meta charset="utf-8"><title>DevLift CLI</title>
36
+ <style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f172a;color:#e2e8f0}
37
+ main{text-align:center}h1{font-weight:600}p{color:#94a3b8}</style></head>
38
+ <body><main><h1>Signed in to DevLift</h1><p>You can close this tab and return to the terminal.</p></main></body></html>"""
39
+
40
+ _FAILURE_PAGE = """<!doctype html><html><head><meta charset="utf-8"><title>DevLift CLI</title>
41
+ <style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f172a;color:#e2e8f0}
42
+ main{text-align:center}h1{font-weight:600;color:#f87171}p{color:#94a3b8}</style></head>
43
+ <body><main><h1>Sign-in failed</h1><p>%s</p></main></body></html>"""
44
+
45
+
46
+ def _pkce_pair() -> tuple[str, str]:
47
+ verifier = secrets.token_urlsafe(64)
48
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
49
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
50
+ return verifier, challenge
51
+
52
+
53
+ def _free_port() -> int:
54
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
55
+ s.bind(("127.0.0.1", 0))
56
+ return s.getsockname()[1]
57
+
58
+
59
+ def _port_is_free(port: int) -> bool:
60
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
61
+ try:
62
+ s.bind(("127.0.0.1", port))
63
+ return True
64
+ except OSError:
65
+ return False
66
+
67
+
68
+ def _redirect_uri(port: int) -> str:
69
+ return f"http://127.0.0.1:{port}/callback"
70
+
71
+
72
+ def discover(base_url: str) -> dict:
73
+ url = base_url.rstrip("/") + OAUTH_METADATA_PATH
74
+ try:
75
+ resp = httpx.get(url, timeout=_TIMEOUT)
76
+ resp.raise_for_status()
77
+ return resp.json()
78
+ except httpx.HTTPError as exc:
79
+ raise CliError(
80
+ f"Could not read the sign-in configuration from {url}: {exc}",
81
+ hint="Is the DevLift backend running at that address? Use --endpoint-url or `devlift configure`.",
82
+ ) from exc
83
+
84
+
85
+ def register_client(metadata: dict, port: int) -> tuple[str, str | None]:
86
+ endpoint = metadata.get("registration_endpoint")
87
+ if not endpoint:
88
+ raise CliError("The DevLift server does not offer client registration.")
89
+ body = {
90
+ "client_name": CLIENT_NAME,
91
+ "redirect_uris": [_redirect_uri(port)],
92
+ "grant_types": ["authorization_code", "refresh_token"],
93
+ "response_types": ["code"],
94
+ "token_endpoint_auth_method": "none",
95
+ }
96
+ resp = httpx.post(endpoint, json=body, timeout=_TIMEOUT)
97
+ if resp.status_code >= 300:
98
+ raise CliError(f"Client registration failed ({resp.status_code}): {resp.text[:300]}")
99
+ data = resp.json()
100
+ return data["client_id"], data.get("client_secret")
101
+
102
+
103
+ class _CallbackHandler(BaseHTTPRequestHandler):
104
+ """Catches exactly one redirect and stores its query on the server object."""
105
+
106
+ def do_GET(self): # noqa: N802
107
+ parsed = urlparse(self.path)
108
+ if parsed.path != "/callback":
109
+ self.send_response(404)
110
+ self.end_headers()
111
+ return
112
+ query = {k: v[0] for k, v in parse_qs(parsed.query).items()}
113
+ self.server.result = query # type: ignore[attr-defined]
114
+ if "error" in query:
115
+ body = _FAILURE_PAGE % (query.get("error_description") or query["error"])
116
+ else:
117
+ body = _SUCCESS_PAGE
118
+ payload = body.encode("utf-8")
119
+ self.send_response(200)
120
+ self.send_header("Content-Type", "text/html; charset=utf-8")
121
+ self.send_header("Content-Length", str(len(payload)))
122
+ self.end_headers()
123
+ self.wfile.write(payload)
124
+
125
+ def log_message(self, *_args): # silence the default stderr access log
126
+ return
127
+
128
+
129
+ def _wait_for_callback(port: int, timeout: float) -> dict:
130
+ server = HTTPServer(("127.0.0.1", port), _CallbackHandler)
131
+ server.result = None # type: ignore[attr-defined]
132
+ server.timeout = 1.0
133
+ deadline = time.time() + timeout
134
+ thread = threading.Thread(target=_serve_until, args=(server, deadline), daemon=True)
135
+ thread.start()
136
+ thread.join()
137
+ server.server_close()
138
+ result = server.result # type: ignore[attr-defined]
139
+ if result is None:
140
+ raise AuthError("Timed out waiting for the browser sign-in.", hint="Run `devlift login` again.")
141
+ return result
142
+
143
+
144
+ def _serve_until(server: HTTPServer, deadline: float) -> None:
145
+ while server.result is None and time.time() < deadline: # type: ignore[attr-defined]
146
+ server.handle_request()
147
+
148
+
149
+ def _exchange(metadata: dict, form: dict) -> dict:
150
+ resp = httpx.post(metadata["token_endpoint"], data=form, timeout=_TIMEOUT)
151
+ if resp.status_code >= 300:
152
+ try:
153
+ detail = resp.json()
154
+ message = detail.get("error_description") or detail.get("error") or resp.text
155
+ except ValueError:
156
+ message = resp.text
157
+ raise AuthError(f"Token request failed ({resp.status_code}): {str(message)[:300]}", hint="Run `devlift login`.")
158
+ return resp.json()
159
+
160
+
161
+ def _to_credentials(base_url: str, metadata: dict, client_id: str, client_secret: str | None, port: int, token: dict) -> Credentials:
162
+ expires_in = float(token.get("expires_in") or 3600)
163
+ scope = token.get("scope") or ""
164
+ return Credentials(
165
+ issuer=metadata["issuer"],
166
+ client_id=client_id,
167
+ base_url=base_url.rstrip("/"),
168
+ client_secret=client_secret,
169
+ redirect_port=port,
170
+ access_token=token["access_token"],
171
+ refresh_token=token.get("refresh_token"),
172
+ expires_at=time.time() + expires_in,
173
+ scopes=scope.split() if scope else [],
174
+ )
175
+
176
+
177
+ def login(base_url: str, existing: Credentials | None = None, open_browser: bool = True, echo=print) -> Credentials:
178
+ """Run the whole browser flow and return fresh credentials.
179
+
180
+ Reuses the stored client registration when its redirect port is still
181
+ free, so the server sees one client per machine rather than one per
182
+ login; otherwise registers a new one.
183
+ """
184
+ metadata = discover(base_url)
185
+
186
+ client_id = client_secret = None
187
+ port = 0
188
+ if existing and existing.issuer == metadata.get("issuer") and existing.redirect_port and _port_is_free(existing.redirect_port):
189
+ client_id, client_secret, port = existing.client_id, existing.client_secret, existing.redirect_port
190
+ if not client_id:
191
+ port = _free_port()
192
+ client_id, client_secret = register_client(metadata, port)
193
+
194
+ verifier, challenge = _pkce_pair()
195
+ state = secrets.token_urlsafe(24)
196
+ params = {
197
+ "response_type": "code",
198
+ "client_id": client_id,
199
+ "redirect_uri": _redirect_uri(port),
200
+ "code_challenge": challenge,
201
+ "code_challenge_method": "S256",
202
+ "state": state,
203
+ }
204
+ url = metadata["authorization_endpoint"] + "?" + urlencode(params)
205
+
206
+ echo("Opening your browser to sign in to DevLift…")
207
+ echo(f"If it does not open, visit:\n {url}")
208
+ if open_browser:
209
+ try:
210
+ webbrowser.open(url, new=2)
211
+ except Exception:
212
+ pass
213
+
214
+ result = _wait_for_callback(port, LOGIN_TIMEOUT_SECONDS)
215
+ if "error" in result:
216
+ raise AuthError(f"Sign-in refused: {result.get('error_description') or result['error']}", hint=None)
217
+ if result.get("state") != state:
218
+ raise AuthError("Sign-in response did not match this login attempt (state mismatch).", hint="Run `devlift login` again.")
219
+ code = result.get("code")
220
+ if not code:
221
+ raise AuthError("Sign-in response carried no authorization code.", hint="Run `devlift login` again.")
222
+
223
+ form = {
224
+ "grant_type": "authorization_code",
225
+ "code": code,
226
+ "redirect_uri": _redirect_uri(port),
227
+ "client_id": client_id,
228
+ "code_verifier": verifier,
229
+ }
230
+ if client_secret:
231
+ form["client_secret"] = client_secret
232
+ token = _exchange(metadata, form)
233
+ return _to_credentials(base_url, metadata, client_id, client_secret, port, token)
234
+
235
+
236
+ def refresh(base_url: str, creds: Credentials) -> Credentials:
237
+ """Rotate the token pair. Raises AuthError when the refresh token is gone."""
238
+ if not creds.refresh_token:
239
+ raise AuthError("Your session has expired.")
240
+ metadata = discover(base_url)
241
+ form = {
242
+ "grant_type": "refresh_token",
243
+ "refresh_token": creds.refresh_token,
244
+ "client_id": creds.client_id,
245
+ }
246
+ if creds.client_secret:
247
+ form["client_secret"] = creds.client_secret
248
+ token = _exchange(metadata, form)
249
+ fresh = _to_credentials(base_url, metadata, creds.client_id, creds.client_secret, creds.redirect_port, token)
250
+ if not fresh.refresh_token:
251
+ fresh.refresh_token = creds.refresh_token
252
+ return fresh
253
+
254
+
255
+ def revoke(base_url: str, creds: Credentials) -> None:
256
+ """Best effort: tell the server to forget the tokens. Never raises."""
257
+ try:
258
+ metadata = discover(base_url)
259
+ endpoint = metadata.get("revocation_endpoint")
260
+ if not endpoint:
261
+ return
262
+ for token in (creds.refresh_token, creds.access_token):
263
+ if not token:
264
+ continue
265
+ form = {"token": token, "client_id": creds.client_id}
266
+ if creds.client_secret:
267
+ form["client_secret"] = creds.client_secret
268
+ httpx.post(endpoint, data=form, timeout=_TIMEOUT)
269
+ except Exception:
270
+ return
@@ -0,0 +1,64 @@
1
+ """The token a command actually sends.
2
+
3
+ Precedence: DEVLIFT_TOKEN (scripts, CI) → stored credentials for the profile,
4
+ refreshed transparently when expired. A refresh that fails clears nothing;
5
+ the user is told to run `devlift login`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ from devlift_cli.auth import oauth, storage
13
+ from devlift_cli.config import Profile
14
+ from devlift_cli.errors import AuthError
15
+
16
+
17
+ class TokenSource:
18
+ def __init__(self, profile: Profile):
19
+ self.profile = profile
20
+ self._creds: storage.Credentials | None = None
21
+ self._env_token = os.environ.get("DEVLIFT_TOKEN") or None
22
+
23
+ @property
24
+ def from_environment(self) -> bool:
25
+ return self._env_token is not None
26
+
27
+ def current(self) -> str:
28
+ if self._env_token:
29
+ return self._env_token
30
+ creds = self._load()
31
+ if creds.expired:
32
+ creds = self._refresh(creds)
33
+ return creds.access_token
34
+
35
+ def force_refresh(self) -> str | None:
36
+ """After a 401: rotate once. None means there is nothing to rotate with."""
37
+ if self._env_token:
38
+ return None
39
+ try:
40
+ return self._refresh(self._load()).access_token
41
+ except AuthError:
42
+ return None
43
+
44
+ def _load(self) -> storage.Credentials:
45
+ if self._creds is None:
46
+ self._creds = storage.load(self.profile.name)
47
+ if self._creds is None:
48
+ raise AuthError(f"Not signed in (profile '{self.profile.name}').")
49
+ # A token is bound to the backend that issued it. --endpoint-url or
50
+ # DEVLIFT_BASE_URL can point the same profile elsewhere; never send
51
+ # one backend's token to another.
52
+ if self._creds.base_url and self._creds.base_url != self.profile.base_url:
53
+ raise AuthError(
54
+ f"Profile '{self.profile.name}' is signed in to {self._creds.base_url}, "
55
+ f"but this command targets {self.profile.base_url}.",
56
+ hint="Run `devlift login` for this backend, or drop --endpoint-url / DEVLIFT_BASE_URL.",
57
+ )
58
+ return self._creds
59
+
60
+ def _refresh(self, creds: storage.Credentials) -> storage.Credentials:
61
+ fresh = oauth.refresh(self.profile.base_url, creds)
62
+ storage.save(self.profile.name, fresh)
63
+ self._creds = fresh
64
+ return fresh
@@ -0,0 +1,135 @@
1
+ """Where the credentials live.
2
+
3
+ Primary: the OS keyring (service "devlift-cli", one entry per profile).
4
+ Fallback: ~/.config/devlift/credentials.json with mode 0600, for machines
5
+ without a keyring backend (headless Linux, CI). Which one is in use is
6
+ reported by `whoami --debug`; the data shape is the same in both.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import time
13
+ from dataclasses import asdict, dataclass, field
14
+ from pathlib import Path
15
+
16
+ from devlift_cli.config import CONFIG_DIR
17
+
18
+ KEYRING_SERVICE = "devlift-cli"
19
+ FALLBACK_FILE = CONFIG_DIR / "credentials.json"
20
+
21
+
22
+ @dataclass
23
+ class Credentials:
24
+ issuer: str
25
+ client_id: str
26
+ base_url: str = "" # the backend this login was made against
27
+ client_secret: str | None = None
28
+ redirect_port: int = 0
29
+ access_token: str = ""
30
+ refresh_token: str | None = None
31
+ expires_at: float = 0.0
32
+ scopes: list[str] = field(default_factory=list)
33
+
34
+ @property
35
+ def expired(self) -> bool:
36
+ # A minute of slack so a token does not die mid-command.
37
+ return bool(self.expires_at) and time.time() > self.expires_at - 60
38
+
39
+ def to_json(self) -> str:
40
+ return json.dumps(asdict(self))
41
+
42
+ @classmethod
43
+ def from_json(cls, raw: str) -> "Credentials":
44
+ data = json.loads(raw)
45
+ return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
46
+
47
+
48
+ def _keyring():
49
+ try:
50
+ import keyring
51
+ from keyring.errors import NoKeyringError # noqa: F401
52
+ except Exception:
53
+ return None
54
+ try:
55
+ backend = keyring.get_keyring()
56
+ # The "fail" backend raises on every call; treat it as absent.
57
+ if backend.__class__.__module__.startswith("keyring.backends.fail"):
58
+ return None
59
+ return keyring
60
+ except Exception:
61
+ return None
62
+
63
+
64
+ def _read_fallback() -> dict:
65
+ try:
66
+ return json.loads(FALLBACK_FILE.read_text())
67
+ except (FileNotFoundError, ValueError):
68
+ return {}
69
+
70
+
71
+ def _write_fallback(data: dict) -> None:
72
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
73
+ tmp = FALLBACK_FILE.with_suffix(".tmp")
74
+ tmp.write_text(json.dumps(data, indent=2) + "\n")
75
+ tmp.chmod(0o600)
76
+ tmp.replace(FALLBACK_FILE)
77
+
78
+
79
+ def backend_name() -> str:
80
+ kr = _keyring()
81
+ return kr.get_keyring().__class__.__name__ if kr else f"file ({FALLBACK_FILE})"
82
+
83
+
84
+ def load(profile: str) -> Credentials | None:
85
+ kr = _keyring()
86
+ raw = None
87
+ if kr:
88
+ try:
89
+ raw = kr.get_password(KEYRING_SERVICE, profile)
90
+ except Exception:
91
+ raw = None
92
+ if raw is None:
93
+ raw = _read_fallback().get(profile)
94
+ if isinstance(raw, dict):
95
+ raw = json.dumps(raw)
96
+ if not raw:
97
+ return None
98
+ try:
99
+ return Credentials.from_json(raw)
100
+ except (ValueError, TypeError):
101
+ return None
102
+
103
+
104
+ def save(profile: str, creds: Credentials) -> None:
105
+ kr = _keyring()
106
+ if kr:
107
+ try:
108
+ kr.set_password(KEYRING_SERVICE, profile, creds.to_json())
109
+ return
110
+ except Exception:
111
+ pass
112
+ data = _read_fallback()
113
+ data[profile] = json.loads(creds.to_json())
114
+ _write_fallback(data)
115
+
116
+
117
+ def clear(profile: str) -> bool:
118
+ removed = False
119
+ kr = _keyring()
120
+ if kr:
121
+ try:
122
+ kr.delete_password(KEYRING_SERVICE, profile)
123
+ removed = True
124
+ except Exception:
125
+ pass
126
+ data = _read_fallback()
127
+ if profile in data:
128
+ del data[profile]
129
+ _write_fallback(data)
130
+ removed = True
131
+ return removed
132
+
133
+
134
+ def fallback_path() -> Path:
135
+ return FALLBACK_FILE
File without changes
@@ -0,0 +1,51 @@
1
+ """devlift approval … — the reviewer's side of the review lane."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from devlift_cli.commands.manual import section_command
8
+ from devlift_cli.api import approvals as approvals_api
9
+ from devlift_cli.context import Invocation
10
+ from devlift_cli.ops.approvals import run_verb
11
+ from devlift_cli.render.output import rows_table
12
+ from devlift_cli.resolve.names import Resolver
13
+
14
+ approval_app = typer.Typer(help="Review change requests: inbox, approve, reject, request changes, revoke.", no_args_is_help=True)
15
+
16
+ _REF = typer.Argument(..., help="Queue code (queue-…) or service name.")
17
+ _ENV = typer.Option(None, "--env", help="Environment, when the service is configured in several.")
18
+ _REGION = typer.Option(None, "--region", help="Region, when the service runs in several.")
19
+
20
+
21
+ @approval_app.command("list")
22
+ def approval_list(ctx: typer.Context):
23
+ """Services with requests waiting for you (submitted or approved)."""
24
+ inv: Invocation = ctx.obj
25
+ rows = approvals_api.services_awaiting(inv.api)
26
+ inv.emit(rows, lambda d: rows_table(
27
+ ["Service", "Environment", "Pending", "Approved", "Latest request", "Configuration"],
28
+ [(r.get("service_name"), r.get("environment"), r.get("pending_count"), r.get("approved_count"),
29
+ (r.get("latest_requested_at") or "")[:16].replace("T", " "), r.get("resource_code")) for r in d],
30
+ ))
31
+
32
+
33
+ def _verb_command(verb: str, doc: str, comment_required: bool):
34
+ comment_opt = typer.Option(..., "--comment", help="Why — recorded for the author.") if comment_required else \
35
+ typer.Option("", "--comment", help="Optional note recorded on the request.")
36
+
37
+ def command(ctx: typer.Context, ref: str = _REF, env: str | None = _ENV, region: str | None = _REGION, comment: str = comment_opt):
38
+ inv: Invocation = ctx.obj
39
+ result = run_verb(inv, Resolver(inv.api, inv.profile.name), verb, ref, env=env, region=region, comment=comment)
40
+ inv.emit(result.to_dict(), lambda d: rows_table(["Request", "Kind", "Status"], [(r["code"], r["kind"], r["status"]) for r in d["requests"]], title=f"{d['service']}: {verb}"))
41
+ command.__doc__ = doc
42
+ return command
43
+
44
+
45
+ approval_app.command("approve")(_verb_command("approve", "Approve a submitted request (settings and routes together). It becomes deployable.", False))
46
+ approval_app.command("request-changes")(_verb_command("request-changes", "Send a submitted request back to its author as a draft, with a comment.", True))
47
+ approval_app.command("reject")(_verb_command("reject", "Reject a submitted request. Terminal — use request-changes unless it should never happen.", True))
48
+ approval_app.command("revoke")(_verb_command("revoke", "Take an approval back; the request waits for a decision again. Not possible once a deploy has started.", False))
49
+
50
+
51
+ approval_app.command("manual")(section_command("request, approval"))