lighthouse-cli 0.16.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 (49) hide show
  1. lighthouse/__init__.py +6 -0
  2. lighthouse/__main__.py +9 -0
  3. lighthouse/commands/__init__.py +0 -0
  4. lighthouse/commands/agents.py +310 -0
  5. lighthouse/commands/builds.py +85 -0
  6. lighthouse/commands/collectors.py +147 -0
  7. lighthouse/commands/connect.py +717 -0
  8. lighthouse/commands/connections.py +201 -0
  9. lighthouse/commands/evals.py +198 -0
  10. lighthouse/commands/kb.py +224 -0
  11. lighthouse/commands/language.py +56 -0
  12. lighthouse/commands/limits.py +61 -0
  13. lighthouse/commands/login.py +207 -0
  14. lighthouse/commands/models.py +61 -0
  15. lighthouse/commands/runs.py +157 -0
  16. lighthouse/commands/spend.py +84 -0
  17. lighthouse/commands/stack.py +103 -0
  18. lighthouse/commands/terms.py +50 -0
  19. lighthouse/commands/whoami.py +80 -0
  20. lighthouse/config.py +141 -0
  21. lighthouse/credentials.py +233 -0
  22. lighthouse/device.py +124 -0
  23. lighthouse/exits.py +78 -0
  24. lighthouse/flags.py +61 -0
  25. lighthouse/http.py +355 -0
  26. lighthouse/i18n.py +250 -0
  27. lighthouse/locales/ar.json +343 -0
  28. lighthouse/locales/de.json +343 -0
  29. lighthouse/locales/en.json +343 -0
  30. lighthouse/locales/es.json +343 -0
  31. lighthouse/locales/fr.json +343 -0
  32. lighthouse/locales/ga.json +343 -0
  33. lighthouse/locales/it.json +343 -0
  34. lighthouse/locales/ja.json +343 -0
  35. lighthouse/locales/nl.json +343 -0
  36. lighthouse/locales/pt.json +343 -0
  37. lighthouse/locales/zh-Hans.json +343 -0
  38. lighthouse/locales/zh-Hant.json +343 -0
  39. lighthouse/main.py +229 -0
  40. lighthouse/out.py +169 -0
  41. lighthouse/py.typed +0 -0
  42. lighthouse/sealing.py +448 -0
  43. lighthouse_cli-0.16.0.dist-info/METADATA +23 -0
  44. lighthouse_cli-0.16.0.dist-info/RECORD +49 -0
  45. lighthouse_cli-0.16.0.dist-info/WHEEL +5 -0
  46. lighthouse_cli-0.16.0.dist-info/entry_points.txt +2 -0
  47. lighthouse_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
  48. lighthouse_cli-0.16.0.dist-info/licenses/NOTICE +2 -0
  49. lighthouse_cli-0.16.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,103 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """`lighthouse stack`: Get my stack, as files in a directory.
4
+
5
+ lighthouse stack # what the stack holds
6
+ lighthouse stack --out ./lighthouse # every file, written there
7
+ lighthouse stack --out ./lh --cloud onprem --profile always_on --deployment edge-1
8
+
9
+ Reads `GET /api/v1/run-targets/stack`, as the person: the stack is a client
10
+ administrator's, at their own keyboard, so the request goes with the
11
+ person's own portal session (`cloudflared access login https://<host>`),
12
+ never a token. With `--out`, each file is written where the portal says it
13
+ goes (`gateway/policy.yaml`, `secrets/aal-reader-public-key`, ...), scripts
14
+ executable; then, on your own servers, `./lighthouse-start.sh` starts it.
15
+
16
+ **It overwrites nothing** unless `--force`, and it never touches a file the
17
+ stack does not name: the secrets `lighthouse-start.sh` made, and the ones
18
+ you filled in, stay as they are when you fetch a newer stack over an older
19
+ one with `--force`. Nothing it writes is a secret: Atlantic's two public keys
20
+ are public halves.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import os
26
+ import stat
27
+ from pathlib import Path, PurePosixPath
28
+ from typing import Any
29
+
30
+ from lighthouse.commands import connect as _connect
31
+ from lighthouse.exits import USAGE, CliError
32
+ from lighthouse.main import Command, Context
33
+ from lighthouse.out import Out
34
+
35
+
36
+ def _configure(ap: argparse.ArgumentParser) -> None:
37
+ ap.add_argument("--cloud", help="aws, azure, gcp or onprem (default: the one chosen)")
38
+ ap.add_argument("--region", help="the region (default: the one chosen)")
39
+ ap.add_argument("--profile", help="always_on or ephemeral (default: the one chosen)")
40
+ ap.add_argument("--deployment",
41
+ help="which of your active deployments the files are for")
42
+ ap.add_argument("--out", help="write every file into this directory")
43
+ ap.add_argument("--force", action="store_true",
44
+ help="overwrite files the stack names that already exist there")
45
+
46
+
47
+ def _where(out: Path, name: str) -> Path:
48
+ """The file's path under `out`. A name is relative and stays inside: the
49
+ service's answer is trusted to describe files, never to place them."""
50
+ rel = PurePosixPath(name)
51
+ if rel.is_absolute() or ".." in rel.parts or not rel.parts:
52
+ raise CliError(f"the stack names a file outside its directory: {name}", error="refused")
53
+ return out.joinpath(*rel.parts)
54
+
55
+
56
+ def stack(ctx: Context) -> dict[str, Any]:
57
+ a = ctx.args
58
+ host = ctx.host
59
+ params = {k: getattr(a, k) for k in ("cloud", "region", "profile", "deployment")
60
+ if getattr(a, k)}
61
+ client = ctx.new_client(host, None, session=_connect.person_session(host))
62
+ try:
63
+ body = client.request("GET", "/api/v1/run-targets/stack", params=params) or {}
64
+ finally:
65
+ client.close()
66
+ files = body.get("files") or []
67
+ rows = [{"name": f.get("name"), "format": f.get("format"), "what": f.get("what"),
68
+ "written": False} for f in files]
69
+ out = Path(a.out) if a.out else None
70
+ if out is not None:
71
+ targets = [_where(out, str(f.get("name") or "")) for f in files]
72
+ exists = [str(t) for t in targets if t.exists()]
73
+ if exists and not a.force:
74
+ raise CliError(f"{len(exists)} of the stack's files already exist in {out}",
75
+ code=USAGE, error="exists",
76
+ hint="--force overwrites the files the stack names, and nothing else")
77
+ for f, target, row in zip(files, targets, rows, strict=True):
78
+ target.parent.mkdir(parents=True, exist_ok=True)
79
+ if target.parent.name == "secrets":
80
+ os.chmod(target.parent, 0o700)
81
+ target.write_text(str(f.get("content") or ""), encoding="utf-8")
82
+ if f.get("format") == "shell":
83
+ target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
84
+ row["written"] = True
85
+ start = next((r["name"] for r in rows if r["name"] == "lighthouse-start.sh"), None)
86
+ return {"ok": True, "cloud": body.get("cloud"), "region": body.get("region"),
87
+ "profile": body.get("profile"), "infra_ref": body.get("infra_ref"),
88
+ "out": str(out) if out is not None else None, "files": rows,
89
+ "next": (f"cd {out} && ./{start}" if out is not None and start else
90
+ body.get("applies"))}
91
+
92
+
93
+ def _show(out: Out, body: dict[str, Any]) -> None:
94
+ out.pairs([("where", f"{body['cloud']} {body['region']}"), ("profile", body["profile"]),
95
+ ("infrastructure", body["infra_ref"]), ("written to", body["out"])])
96
+ out.table(["file", "kind", "written"],
97
+ [[r["name"], r["format"], "yes" if r["written"] else "no"] for r in body["files"]])
98
+ if body.get("next"):
99
+ out.note(str(body["next"]))
100
+
101
+
102
+ COMMANDS = (Command("stack", "Get my stack: the files that run LightHouse where you chose",
103
+ _configure, stack, _show),)
@@ -0,0 +1,50 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """`lighthouse terms`: the commercial terms in force for this tenant.
4
+
5
+ lighthouse terms
6
+ lighthouse terms --json
7
+
8
+ Reads `GET /api/v1/billing/terms`: the payment model,
9
+ the credit limit, the payment terms, the margins, the suspension notice, and
10
+ for each one whether it is AAL's published default or something agreed for this
11
+ client, with the version and when it took effect.
12
+
13
+ **Read only.** Accepting a term is a person's act, recorded against them with
14
+ the version and the time; a token never accepts anything
15
+ (`docs/adr/0012-delegated-credentials.md`). This command exists because a
16
+ developer writing a billing integration needs to read the terms their code must
17
+ honour, not because terms should be changed from a shell.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ from typing import Any
23
+
24
+ from lighthouse.main import Command, Context
25
+ from lighthouse.out import Out
26
+
27
+
28
+ def _configure(ap: argparse.ArgumentParser) -> None:
29
+ ap.add_argument("--term", help="one term by its key")
30
+
31
+
32
+ def terms(ctx: Context) -> dict[str, Any]:
33
+ body = ctx.client().get("/api/v1/billing/terms")
34
+ rows = body.get("terms") or []
35
+ if ctx.args.term:
36
+ rows = [r for r in rows if r.get("key") == ctx.args.term]
37
+ return {"ok": True, **body, "terms": rows}
38
+
39
+
40
+ def _show(out: Out, body: dict[str, Any]) -> None:
41
+ out.table(["term", "meaning", "set by", "direction", "version", "in force from"],
42
+ [[r.get("key"), r.get("label"), (r.get("in_force") or {}).get("set_by"),
43
+ (r.get("in_force") or {}).get("direction"),
44
+ (r.get("in_force") or {}).get("version"),
45
+ (r.get("in_force") or {}).get("effective_from")] for r in body["terms"]])
46
+ out.note("accepting a term is a person's act, recorded against them in the portal")
47
+
48
+
49
+ COMMANDS = (Command("terms", "the commercial terms in force for this tenant",
50
+ _configure, terms, _show),)
@@ -0,0 +1,80 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """`lighthouse whoami`: who the CLI is, where, and with what reach.
4
+
5
+ lighthouse whoami
6
+ lighthouse whoami --json
7
+
8
+ The first thing anybody runs when a pipeline misbehaves, so it answers the four
9
+ questions that matter — which tenant, which person, which role, which scopes —
10
+ and where the credential came from (`keychain` or `environment`) and what kind it
11
+ is (a delegated token or a machine key). It makes one
12
+ request, `GET /api/v1/me`, and exits 3 when there is no credential, so
13
+ `lighthouse whoami >/dev/null || lighthouse login` is a sound idiom.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ from typing import Any
19
+
20
+ from lighthouse import i18n
21
+ from lighthouse.i18n import t
22
+ from lighthouse.main import Command, Context
23
+ from lighthouse.out import Out
24
+
25
+
26
+ def _configure(ap: argparse.ArgumentParser) -> None:
27
+ ap.add_argument("--profiles", action="store_true",
28
+ help="list every host signed in on this machine as well")
29
+
30
+
31
+ def whoami(ctx: Context) -> dict[str, Any]:
32
+ host = ctx.host
33
+ credential = ctx.store.require(host)
34
+ me = ctx.client().get("/api/v1/me")
35
+ # The viewer's effective language (contract 1.29), kept so the next run
36
+ # speaks it without a request (i18n.py).
37
+ i18n.remember(ctx.config.path.parent, host, ((me.get("locale") or {}).get("language")))
38
+ viewer = me.get("viewer") or {}
39
+ client = me.get("client") or {}
40
+ out: dict[str, Any] = {
41
+ "ok": True, "host": host,
42
+ "tenant": {"name": client.get("name"), "tenant_id": client.get("tenant_id"),
43
+ "portal_url": client.get("portal_url")},
44
+ "viewer": {"email": viewer.get("email"), "name": viewer.get("name"),
45
+ "role": viewer.get("role"), "kind": viewer.get("kind"),
46
+ "provider": (viewer.get("provider") or {}).get("name")},
47
+ "credential": credential.redacted(),
48
+ "contract_revision": me.get("contract_revision"),
49
+ }
50
+ if getattr(ctx.args, "profiles", False):
51
+ out["profiles"] = [{"name": p.name, "host": p.host,
52
+ "current": p.name == ctx.config.current}
53
+ for p in sorted(ctx.config.profiles.values(), key=lambda p: p.name)]
54
+ return out
55
+
56
+
57
+ def _show(out: Out, body: dict[str, Any]) -> None:
58
+ credential = body["credential"]
59
+ out.pairs([("tenant", body["tenant"]["name"]),
60
+ ("host", body["host"]),
61
+ ("person", body["viewer"]["email"]),
62
+ ("role", body["viewer"]["role"]),
63
+ ("signed in with", body["viewer"]["provider"]),
64
+ ("scopes", " ".join(credential["scopes"]) or t("whoami.all_reach")),
65
+ ("credential from", credential["source"]),
66
+ ("credential kind", t("whoami.machine_key") if credential.get("kind") == "machine"
67
+ else t("whoami.delegated_token")),
68
+ ("expires", credential["expires_at"] or (
69
+ t("whoami.never_by_time") if credential.get("kind") == "machine" else None))])
70
+ if credential["source"] == "environment":
71
+ if credential.get("kind") == "machine":
72
+ out.note(t("whoami.note_machine_key"))
73
+ else:
74
+ out.note(t("whoami.note_delegated_token"))
75
+ for profile in body.get("profiles") or []:
76
+ out.say(f" {'*' if profile['current'] else ' '} {profile['name']} {profile['host']}")
77
+
78
+
79
+ COMMANDS = (Command("whoami", "who this CLI is signed in as, and with what reach",
80
+ _configure, whoami, _show),)
lighthouse/config.py ADDED
@@ -0,0 +1,141 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Which tenant a command talks to, and the profile file that remembers it.
4
+
5
+ A LightHouse tenant *is* a hostname: `https://<tenant id>.atlanticagenticlabs.com`
6
+ (`docs/adr/0001-opaque-tenant-ids.md`). So every command needs a host, and
7
+ gets one from the first of these that is set:
8
+
9
+ 1. `--host` on the command line;
10
+ 2. `LIGHTHOUSE_HOST` in the environment;
11
+ 3. the current profile in `~/.config/lighthouse/config.toml`
12
+ (`$XDG_CONFIG_HOME` is honoured where it is set);
13
+ 4. nothing — and the command says so and stops, rather than guessing.
14
+
15
+ **The file holds no secrets**: host names, a profile name, and which profile is
16
+ current. Credentials are in the OS keychain (credentials.py). It is written
17
+ with mode 0600 all the same, because it names a company's tenants.
18
+
19
+ Written as TOML and read with the standard library's `tomllib`, so the CLI
20
+ carries no parser of its own for a file a person may edit by hand.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import re
26
+ import tomllib
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from lighthouse.exits import USAGE, CliError
32
+ from lighthouse.i18n import t
33
+
34
+ ENV_HOST = "LIGHTHOUSE_HOST"
35
+ ENV_CONFIG = "LIGHTHOUSE_CONFIG"
36
+ DEFAULT_BASE = "atlanticagenticlabs.com"
37
+ # A hostname, nothing else: no scheme, no path, no port. A command that took a
38
+ # URL would let a mistyped one send a credential somewhere unintended.
39
+ _HOST = re.compile(r"^[a-z0-9]([a-z0-9-]{0,62}\.)+[a-z]{2,}$")
40
+
41
+
42
+ class NoHost(CliError):
43
+ def __init__(self) -> None:
44
+ super().__init__(t("err.no_host"), code=USAGE, error="no_host", hint=t("hint.no_host"))
45
+
46
+
47
+ def clean_host(given: str) -> str:
48
+ """A bare hostname from what a person typed. A URL is accepted and reduced
49
+ to its host, because everyone pastes one; anything else is refused."""
50
+ value = (given or "").strip().lower()
51
+ value = re.sub(r"^https?://", "", value).split("/")[0].split("?")[0]
52
+ value = value.rsplit(":", 1)[0] if value.count(":") == 1 else value
53
+ if "." not in value:
54
+ value = f"{value}.{DEFAULT_BASE}" # a bare tenant id
55
+ if not _HOST.match(value):
56
+ raise CliError(t("err.bad_host", given=repr(given)), code=USAGE, error="bad_host",
57
+ hint=t("hint.bad_host"))
58
+ return value
59
+
60
+
61
+ @dataclass
62
+ class Profile:
63
+ name: str
64
+ host: str
65
+
66
+
67
+ @dataclass
68
+ class Config:
69
+ path: Path
70
+ current: str | None = None
71
+ profiles: dict[str, Profile] = field(default_factory=dict)
72
+
73
+ # -- reading ---------------------------------------------------------------
74
+
75
+ @classmethod
76
+ def directory(cls, env: dict[str, str]) -> Path:
77
+ if env.get(ENV_CONFIG):
78
+ return Path(env[ENV_CONFIG]).expanduser().parent
79
+ base = env.get("XDG_CONFIG_HOME") or str(Path(env.get("HOME", "~")) / ".config")
80
+ return Path(base).expanduser() / "lighthouse"
81
+
82
+ @classmethod
83
+ def load(cls, env: dict[str, str] | None = None) -> Config:
84
+ env = env if env is not None else dict(os.environ)
85
+ path = Path(env[ENV_CONFIG]).expanduser() if env.get(ENV_CONFIG) \
86
+ else cls.directory(env) / "config.toml"
87
+ if not path.exists():
88
+ return cls(path)
89
+ try:
90
+ data: dict[str, Any] = tomllib.loads(path.read_text())
91
+ except (OSError, tomllib.TOMLDecodeError) as e:
92
+ raise CliError(t("err.bad_config", path=path, error=e), error="bad_config",
93
+ hint=t("hint.bad_config")) from None
94
+ profiles = {}
95
+ for name, row in (data.get("profiles") or {}).items():
96
+ if isinstance(row, dict) and row.get("host"):
97
+ profiles[str(name)] = Profile(str(name), clean_host(str(row["host"])))
98
+ current = data.get("current")
99
+ return cls(path, str(current) if current else None, profiles)
100
+
101
+ # -- writing ---------------------------------------------------------------
102
+
103
+ def save(self) -> None:
104
+ self.path.parent.mkdir(parents=True, exist_ok=True)
105
+ lines = []
106
+ if self.current:
107
+ lines.append(f'current = "{self.current}"\n')
108
+ for name in sorted(self.profiles):
109
+ lines.append(f'\n[profiles."{name}"]\nhost = "{self.profiles[name].host}"\n')
110
+ self.path.write_text("# Written by `lighthouse`. Host names only: credentials are in "
111
+ "the operating system keychain.\n" + "".join(lines))
112
+ os.chmod(self.path, 0o600)
113
+
114
+ def remember(self, host: str, *, name: str | None = None, make_current: bool = True) -> Profile:
115
+ profile = Profile(name or host.split(".")[0], host)
116
+ self.profiles[profile.name] = profile
117
+ if make_current or self.current is None:
118
+ self.current = profile.name
119
+ self.save()
120
+ return profile
121
+
122
+ def forget(self, host: str) -> None:
123
+ for name in [n for n, p in self.profiles.items() if p.host == host]:
124
+ del self.profiles[name]
125
+ if self.current == name:
126
+ self.current = next(iter(sorted(self.profiles)), None)
127
+ self.save()
128
+
129
+ # -- resolution ---------------------------------------------------------------
130
+
131
+ def host_for(self, given: str | None, env: dict[str, str] | None = None) -> str:
132
+ env = env if env is not None else {}
133
+ if given:
134
+ return clean_host(given)
135
+ if env.get(ENV_HOST):
136
+ return clean_host(env[ENV_HOST])
137
+ if self.current and self.current in self.profiles:
138
+ return self.profiles[self.current].host
139
+ if len(self.profiles) == 1:
140
+ return next(iter(self.profiles.values())).host
141
+ raise NoHost()
@@ -0,0 +1,233 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Where the credential lives: the operating system's keychain, and nowhere else.
4
+
5
+ `lighthouse login` stores the access and refresh tokens through `keyring`, so
6
+ on macOS they are in the login Keychain, on Linux in the Secret Service
7
+ (GNOME Keyring, KWallet) and on Windows in the Credential Locker. **No token is
8
+ ever written to a file**, in the repository or out of it: a dotfile of
9
+ credentials is the thing that ends up committed, copied into a container image,
10
+ or read by every process on a shared machine.
11
+
12
+ One entry per host, under the service name `lighthouse` and the account name
13
+ `<host>`, so a developer can be signed in to several tenants at once and
14
+ `lighthouse logout --all` can find them all. The profile file
15
+ (`~/.config/lighthouse/config.toml`, config.py) records *which hosts exist* and
16
+ which is current — names, never secrets.
17
+
18
+ **In CI there is no keychain**, and a pipeline must not be asked to unlock one.
19
+ So `LIGHTHOUSE_TOKEN` is read first and, when it is set, nothing is read from
20
+ or written to the keychain at all. That is the documented way to use this CLI
21
+ in a pipeline: put a token in a masked variable, and the CLI never touches a
22
+ credential store.
23
+
24
+ **A machine key is the CI credential.** `LIGHTHOUSE_TOKEN` takes either a
25
+ delegated token (`lhat-...`, eight hours, never refreshed under the variable) or
26
+ a machine key (`aalcp-...` from the portal's Keys page: no refresh, and no
27
+ expiry by time). Because every tenant host is behind Cloudflare Access except
28
+ the OAuth endpoints, a pipeline also sets `LIGHTHOUSE_ACCESS_CLIENT_ID` and
29
+ `LIGHTHOUSE_ACCESS_CLIENT_SECRET`, the tenant's Access service token. `login`
30
+ can keep that pair in the keychain beside the delegated token.
31
+
32
+ If the keychain is unreachable (a headless Linux box with no Secret Service,
33
+ say), the CLI says so and names the environment variable, rather than falling
34
+ back to a file. A silent fallback to disk would be the one behaviour nobody
35
+ would notice and everybody would regret.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import logging
41
+ from dataclasses import dataclass, field, replace
42
+ from datetime import UTC, datetime, timedelta
43
+ from typing import Any
44
+
45
+ from lighthouse.exits import USAGE, CliError, NotSignedIn
46
+ from lighthouse.i18n import t
47
+
48
+ log = logging.getLogger(__name__)
49
+
50
+ SERVICE = "lighthouse"
51
+ ENV_TOKEN = "LIGHTHOUSE_TOKEN" # noqa: S105 # nosec B105 - a variable name
52
+ # The tenant's Cloudflare Access service token. Every tenant host sits behind
53
+ # Cloudflare Access except the OAuth endpoints, so every other request carries
54
+ # these two as `CF-Access-Client-Id` and `CF-Access-Client-Secret`.
55
+ ENV_ACCESS_ID = "LIGHTHOUSE_ACCESS_CLIENT_ID"
56
+ ENV_ACCESS_SECRET = "LIGHTHOUSE_ACCESS_CLIENT_SECRET" # noqa: S105 # nosec B105 - a variable name
57
+ MACHINE_PREFIX = "aalcp-"
58
+ # Refresh this long before expiry rather than on the first 401, so a long
59
+ # pipeline does not fail at the moment the token lapses.
60
+ EARLY = timedelta(minutes=5)
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Credential:
65
+ host: str
66
+ access_token: str
67
+ refresh_token: str | None
68
+ expires_at: datetime | None
69
+ email: str | None
70
+ scopes: tuple[str, ...]
71
+ source: str = "keychain" # keychain | environment
72
+ # The tenant's Cloudflare Access service token, when one is configured.
73
+ access_client_id: str | None = None
74
+ access_client_secret: str | None = field(default=None, repr=False)
75
+
76
+ @property
77
+ def kind(self) -> str:
78
+ """`machine` for a key from the portal's Keys page (`aalcp-...`, never
79
+ expires by time, never refreshed); `delegated` for a token a person
80
+ approved (`lhat-...`, eight hours)."""
81
+ return "machine" if self.access_token.startswith(MACHINE_PREFIX) else "delegated"
82
+
83
+ def access(self) -> tuple[str, str] | None:
84
+ if self.access_client_id and self.access_client_secret:
85
+ return self.access_client_id, self.access_client_secret
86
+ return None
87
+
88
+ def stale(self, now: datetime | None = None) -> bool:
89
+ if self.expires_at is None:
90
+ return False
91
+ return self.expires_at - EARLY <= (now or datetime.now(UTC))
92
+
93
+ def redacted(self) -> dict[str, Any]:
94
+ """Everything about the credential except the credential."""
95
+ return {"host": self.host, "email": self.email, "scopes": list(self.scopes),
96
+ "kind": self.kind, "access_service_token": self.access() is not None,
97
+ "expires_at": self.expires_at.isoformat() if self.expires_at else None,
98
+ "source": self.source, "refreshable": self.refresh_token is not None}
99
+
100
+
101
+ class KeychainUnavailable(CliError):
102
+ def __init__(self, detail: str) -> None:
103
+ super().__init__(f"the operating system keychain is not available: {detail}",
104
+ error="keychain_unavailable",
105
+ hint=f"in CI, set {ENV_TOKEN} to a machine key instead; "
106
+ f"this CLI never writes a credential to a file")
107
+
108
+
109
+ def access_from_env(env: dict[str, str]) -> tuple[str, str] | None:
110
+ """The Access service token named by the environment, or None.
111
+
112
+ Both or neither: one without the other can never authenticate, and a CI job
113
+ that sets half of it should hear so now rather than from Cloudflare later.
114
+ """
115
+ client_id = (env.get(ENV_ACCESS_ID) or "").strip()
116
+ secret = (env.get(ENV_ACCESS_SECRET) or "").strip()
117
+ if client_id and secret:
118
+ return client_id, secret
119
+ if client_id or secret:
120
+ missing = ENV_ACCESS_SECRET if client_id else ENV_ACCESS_ID
121
+ raise CliError(t("err.access_token_incomplete", missing=missing),
122
+ code=USAGE, error="access_token_incomplete",
123
+ hint=t("hint.access_token_incomplete", id=ENV_ACCESS_ID, secret=ENV_ACCESS_SECRET))
124
+ return None
125
+
126
+
127
+ class Store:
128
+ """The keychain, or the environment. `backend` is injected by the tests;
129
+ nothing in the test suite touches a real keychain."""
130
+
131
+ def __init__(self, backend: Any = None, env: dict[str, str] | None = None) -> None:
132
+ self._backend = backend
133
+ self.env = env if env is not None else {}
134
+
135
+ # -- the backend ---------------------------------------------------------------
136
+
137
+ @property
138
+ def backend(self) -> Any:
139
+ if self._backend is None:
140
+ try:
141
+ import keyring
142
+ except Exception as e: # pragma: no cover - import failure
143
+ raise KeychainUnavailable(str(e)) from None
144
+ self._backend = keyring
145
+ return self._backend
146
+
147
+ def _get(self, host: str) -> str | None:
148
+ try:
149
+ got = self.backend.get_password(SERVICE, host)
150
+ return str(got) if got is not None else None
151
+ except Exception as e:
152
+ raise KeychainUnavailable(type(e).__name__) from None
153
+
154
+ def _set(self, host: str, blob: str) -> None:
155
+ try:
156
+ self.backend.set_password(SERVICE, host, blob)
157
+ except Exception as e:
158
+ raise KeychainUnavailable(type(e).__name__) from None
159
+
160
+ def _delete(self, host: str) -> None:
161
+ try:
162
+ self.backend.delete_password(SERVICE, host)
163
+ except Exception as e: # noqa: S110 # nosec B110 - see below
164
+ # Deleting what is not there is success, and every backend raises a
165
+ # different exception for it. `logout` must not fail because the
166
+ # entry had already gone: the point of the command is that nothing
167
+ # is left, and nothing is.
168
+ log.debug("nothing to delete from the keychain: %s", type(e).__name__)
169
+
170
+ # -- reading and writing ---------------------------------------------------------
171
+
172
+ def load(self, host: str) -> Credential | None:
173
+ """The credential for `host`: the environment first, then the keychain.
174
+
175
+ `LIGHTHOUSE_TOKEN` wins and short-circuits: when it is set the keychain
176
+ is not consulted at all, so a pipeline never prompts for one and a
177
+ developer can override what is stored for one command.
178
+ """
179
+ from_env = access_from_env(self.env)
180
+ token = (self.env.get(ENV_TOKEN) or "").strip()
181
+ if token:
182
+ return Credential(host, token, None, None, None, (), source="environment",
183
+ access_client_id=from_env[0] if from_env else None,
184
+ access_client_secret=from_env[1] if from_env else None)
185
+ blob = self._get(host)
186
+ if not blob:
187
+ return None
188
+ try:
189
+ data = json.loads(blob)
190
+ except ValueError:
191
+ log.debug("the stored credential is not readable; treating it as absent")
192
+ return None
193
+ expires = data.get("expires_at")
194
+ cred = Credential(
195
+ host, data.get("access_token", ""), data.get("refresh_token"),
196
+ datetime.fromisoformat(expires) if expires else None,
197
+ data.get("email"), tuple(data.get("scopes") or ()),
198
+ access_client_id=data.get("access_client_id"),
199
+ access_client_secret=data.get("access_client_secret"))
200
+ if from_env: # the environment overrides what was stored
201
+ cred = replace(cred, access_client_id=from_env[0], access_client_secret=from_env[1])
202
+ return cred
203
+
204
+ def save(self, host: str, token: dict[str, Any], *, email: str | None = None,
205
+ now: datetime | None = None, access_client_id: str | None = None,
206
+ access_client_secret: str | None = None) -> Credential:
207
+ """Store what the token endpoint returned. `expires_in` is turned into
208
+ an absolute time here, so a credential read tomorrow is not believed."""
209
+ now = now or datetime.now(UTC)
210
+ expires = now + timedelta(seconds=int(token.get("expires_in") or 0)) \
211
+ if token.get("expires_in") else None
212
+ cred = Credential(host, str(token["access_token"]), token.get("refresh_token"),
213
+ expires, email, tuple((token.get("scope") or "").split()),
214
+ access_client_id=access_client_id,
215
+ access_client_secret=access_client_secret)
216
+ blob = {
217
+ "access_token": cred.access_token, "refresh_token": cred.refresh_token,
218
+ "expires_at": cred.expires_at.isoformat() if cred.expires_at else None,
219
+ "email": cred.email, "scopes": list(cred.scopes)}
220
+ if cred.access():
221
+ blob["access_client_id"] = cred.access_client_id
222
+ blob["access_client_secret"] = cred.access_client_secret
223
+ self._set(host, json.dumps(blob))
224
+ return cred
225
+
226
+ def forget(self, host: str) -> None:
227
+ self._delete(host)
228
+
229
+ def require(self, host: str) -> Credential:
230
+ cred = self.load(host)
231
+ if cred is None or not cred.access_token:
232
+ raise NotSignedIn(host)
233
+ return cred