terraui 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.
terraui/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """TerraUi — local-first control plane for Terraform & Terragrunt."""
2
+
3
+ __version__ = "0.1.0"
terraui/ai/__init__.py ADDED
File without changes
terraui/ai/chat.py ADDED
@@ -0,0 +1,98 @@
1
+ """AI assistant proxy (BACKEND_SPEC §12).
2
+
3
+ Grounds answers in the user's stacks (drift, locks, run-all, auth) and proxies to
4
+ Claude when ANTHROPIC_API_KEY is set. The model key never touches the browser.
5
+ Falls back to canned, useful answers when no key / SDK is available so the chat
6
+ always works offline.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+
13
+ MODEL = "claude-haiku-4-5" # mirrors the "Haiku" badge in the frontend
14
+
15
+ PERSONA = (
16
+ "You are TerraUi Assistant, an expert platform/SRE engineer specializing in "
17
+ "Terraform and Terragrunt across AWS, GCP and Azure. The user manages many IaC "
18
+ "stacks in one UI (drift, state locking, run-all orchestration, PR-triggered "
19
+ "plans). Answer concisely and practically, with short code/command snippets "
20
+ "where useful. Keep under 170 words. You may read state and run read-only "
21
+ "commands, but never apply changes without explicit human approval."
22
+ )
23
+
24
+
25
+ def _fallback(q: str) -> str:
26
+ low = q.lower()
27
+ if "drift" in low:
28
+ return (
29
+ "Drift means the live resource no longer matches your recorded state — something "
30
+ "changed outside Terraform.\n\nInspect it: open the stack → Drift tab for attribute-level "
31
+ "state-vs-actual. To reconcile, run a plan — Terraform shows the changes needed to bring "
32
+ "reality back in line, then apply.\n\nPrevent it: restrict console/CLI write access in prod "
33
+ "and rely on PR-driven applies."
34
+ )
35
+ if "lock" in low:
36
+ return (
37
+ "State locking stops two applies from corrupting state.\n\n"
38
+ "• AWS (S3 backend): add a DynamoDB table → dynamodb_table = \"acme-tf-locks\".\n"
39
+ "• GCS / azurerm: locking is built in.\n\nIn TerraUi the Lock column shows who holds it. "
40
+ "If a run dies and leaves a stale lock, release it from the stack's Overview "
41
+ "(or terraform force-unlock LOCK_ID)."
42
+ )
43
+ if "run-all" in low or "order" in low or "depend" in low:
44
+ return (
45
+ "terragrunt run-all apply walks every unit and applies them in dependency order (from "
46
+ "dependency{} blocks) — e.g. networking before eks-cluster.\n\nPlain terraform apply only "
47
+ "touches the current module. Use the Dependencies view to preview the run-all order before "
48
+ "you trigger it."
49
+ )
50
+ if "adc" in low or "gcloud" in low or "gcp" in low:
51
+ return (
52
+ "GCP needs two logins:\n\n gcloud auth login # your user identity for gcloud\n"
53
+ " gcloud auth application-default login # ADC, what Terraform uses\n\nThe Terraform google "
54
+ "provider reads ADC, not your gcloud user session — that's why both are required. Set the "
55
+ "project with gcloud config set project."
56
+ )
57
+ if "import" in low:
58
+ return (
59
+ "Use terraform import to bring an existing resource under management:\n\n"
60
+ " terraform import aws_s3_bucket.logs acme-logs-bucket\n\nDefine the resource block first, "
61
+ "import, then run plan to confirm zero diff. For bulk imports, Terraform 1.5+ supports "
62
+ "import{} blocks in config."
63
+ )
64
+ return (
65
+ "I can help with Terraform/Terragrunt: drift, state & locking, run-all ordering, provider auth "
66
+ "(AWS/GCP/Azure), plan/apply flags, imports and module structure. Ask me something specific — "
67
+ 'e.g. "why is eks-cluster drifting?" or "set up remote state locking".'
68
+ )
69
+
70
+
71
+ async def chat(question: str, context: str = "") -> dict:
72
+ """Return {reply, model, grounded} for a single assistant turn."""
73
+ question = (question or "").strip()
74
+ if not question:
75
+ return {"reply": "", "model": "none", "grounded": False}
76
+
77
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
78
+ if not api_key:
79
+ return {"reply": _fallback(question), "model": "fallback", "grounded": False}
80
+
81
+ try:
82
+ import anthropic # optional dependency
83
+ except ImportError:
84
+ return {"reply": _fallback(question), "model": "fallback", "grounded": False}
85
+
86
+ system = PERSONA + (f"\n\nCurrent stack context:\n{context}" if context else "")
87
+ try:
88
+ client = anthropic.AsyncAnthropic(api_key=api_key)
89
+ resp = await client.messages.create(
90
+ model=MODEL,
91
+ max_tokens=1024,
92
+ system=system,
93
+ messages=[{"role": "user", "content": question}],
94
+ )
95
+ text = "".join(b.text for b in resp.content if getattr(b, "type", "") == "text")
96
+ return {"reply": text.strip() or _fallback(question), "model": MODEL, "grounded": bool(context)}
97
+ except Exception:
98
+ return {"reply": _fallback(question), "model": "fallback", "grounded": False}
terraui/cli.py ADDED
@@ -0,0 +1,242 @@
1
+ """TerraUi CLI (BACKEND_SPEC §2): start | scan | setup | server | agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ import threading
8
+ import time
9
+ import webbrowser
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ import typer
14
+
15
+ from . import __version__
16
+
17
+ app = typer.Typer(add_completion=False, help="Local-first control plane for Terraform & Terragrunt.")
18
+
19
+
20
+ def _version_cb(value: bool):
21
+ if value:
22
+ typer.echo(f"terraui {__version__}")
23
+ raise typer.Exit()
24
+
25
+
26
+ @app.callback()
27
+ def main(
28
+ version: bool = typer.Option(False, "--version", callback=_version_cb, is_eager=True, help="Show version and exit."),
29
+ ):
30
+ """TerraUi — inventory, plan/apply, drift, locks and a cloud shell, in one UI."""
31
+
32
+
33
+ # ---- preflight helpers (cloud SDK detect / install / auth) ----------------
34
+ def _clouds_from_stacks(stacks: list[dict]) -> list[str]:
35
+ seen: list[str] = []
36
+ for s in stacks:
37
+ for c in (s.get("clouds") or [s.get("prov")]):
38
+ if c and c not in seen:
39
+ seen.append(c)
40
+ return seen
41
+
42
+
43
+ def _status_line(st) -> str:
44
+ if not st.installed:
45
+ return typer.style(f" ✗ {st.name:6} CLI not installed", fg="yellow")
46
+ if not st.authed:
47
+ return typer.style(f" ⚠ {st.name:6} {st.version} · not authenticated ({st.detail})", fg="yellow")
48
+ return typer.style(f" ✓ {st.name:6} {st.version} · {st.detail}", fg="green")
49
+
50
+
51
+ def _quick_check(provider_ids: list[str]) -> None:
52
+ """Read-only summary printed before serving. Never blocks."""
53
+ from .clouds import preflight
54
+
55
+ if not provider_ids:
56
+ return
57
+ typer.echo("Cloud SDK status (providers used by your stacks):")
58
+ needs_setup = False
59
+ for st in preflight.check_all(provider_ids):
60
+ typer.echo(_status_line(st))
61
+ if not st.installed or not st.authed:
62
+ needs_setup = True
63
+ if needs_setup:
64
+ typer.echo(typer.style(" → run `terraui setup` to install / authenticate (you can skip providers).", fg="cyan"))
65
+
66
+
67
+ def _interactive_setup(provider_ids: list[str], assume_yes: bool = False) -> None:
68
+ """Per-provider install + auth, fully skippable. Shows the exact command first."""
69
+ from .clouds import preflight
70
+
71
+ interactive = sys.stdin.isatty() and not assume_yes
72
+ typer.echo(typer.style(f"\nTerraUi setup — configuring: {', '.join(provider_ids)}\n", bold=True))
73
+
74
+ for pid in provider_ids:
75
+ st = preflight.check(pid)
76
+ typer.echo(typer.style(f"{st.name}", bold=True))
77
+
78
+ # 1) install if missing
79
+ if not st.installed:
80
+ argv, url = preflight.install_command(pid)
81
+ if argv:
82
+ cmd = " ".join(argv)
83
+ typer.echo(f" CLI not found. Install command: {typer.style(cmd, fg='cyan')}")
84
+ do = assume_yes or (interactive and typer.confirm(" Run it now?", default=True))
85
+ if do:
86
+ ok = preflight.run_install(pid)
87
+ typer.echo(" installed." if ok else typer.style(" install failed — see output above.", fg="red"))
88
+ st = preflight.check(pid)
89
+ else:
90
+ typer.echo(f" skipped. Install later: {cmd} (docs: {url})")
91
+ else:
92
+ typer.echo(f" No package manager detected. Install manually: {typer.style(url, fg='cyan')}")
93
+ if not st.installed:
94
+ typer.echo(" → skipping auth (CLI not present). Configure later with `terraui setup`.\n")
95
+ continue
96
+
97
+ # 2) authenticate if needed
98
+ if st.authed:
99
+ typer.echo(typer.style(f" ✓ already authenticated · {st.detail}\n", fg="green"))
100
+ continue
101
+
102
+ typer.echo(f" Not authenticated ({st.detail}).")
103
+ steps = preflight.PROVIDERS[pid].login
104
+ if not interactive:
105
+ cmds = "; ".join(" ".join(a) for _, a in steps)
106
+ typer.echo(f" Authenticate later by running: {cmds}\n")
107
+ continue
108
+ if not typer.confirm(" Authenticate now?", default=True):
109
+ cmds = " | ".join(" ".join(a) for _, a in steps)
110
+ typer.echo(f" skipped. Authenticate later: {cmds}\n")
111
+ continue
112
+ for i, (desc, argv) in enumerate(steps):
113
+ typer.echo(f" → {desc}: {typer.style(' '.join(argv), fg='cyan')}")
114
+ if typer.confirm(" Run this step?", default=True):
115
+ preflight.run_login(pid, i)
116
+ final = preflight.check(pid)
117
+ typer.echo((typer.style(f" ✓ authenticated · {final.detail}\n", fg="green")
118
+ if final.authed else typer.style(" still not authenticated — re-run when ready.\n", fg="yellow")))
119
+
120
+ typer.echo("Setup complete. Skipped providers can be configured anytime with `terraui setup`.\n")
121
+
122
+
123
+ @app.command()
124
+ def setup(
125
+ path: Optional[Path] = typer.Argument(None, help="IaC root — limits setup to the clouds your stacks use."),
126
+ providers: Optional[str] = typer.Option(None, "--providers", help="Comma list to configure: aws,gcp,azure."),
127
+ yes: bool = typer.Option(False, "--yes", help="Non-interactive: install where possible, skip browser logins."),
128
+ ):
129
+ """Detect, install and authenticate the cloud SDKs (gcloud/aws/az). Per-provider, skippable."""
130
+ if providers:
131
+ ids = [p.strip() for p in providers.split(",") if p.strip() in ("aws", "gcp", "azure")]
132
+ else:
133
+ ids = []
134
+ try:
135
+ from .discovery import scan_roots
136
+
137
+ stacks = [s.model_dump() for s in scan_roots([path or Path.cwd()])]
138
+ ids = _clouds_from_stacks(stacks)
139
+ except Exception:
140
+ ids = []
141
+ if not ids:
142
+ ids = ["aws", "gcp", "azure"]
143
+ _interactive_setup(ids, assume_yes=yes)
144
+
145
+
146
+ @app.command()
147
+ def start(
148
+ path: Optional[Path] = typer.Argument(None, help="IaC root to scan (default: cwd)."),
149
+ port: int = typer.Option(8787, help="Port to serve on."),
150
+ host: str = typer.Option("127.0.0.1", help="Bind address (local mode: localhost)."),
151
+ scan: list[Path] = typer.Option(None, "--scan", help="Extra roots/globs to scan."),
152
+ no_open: bool = typer.Option(False, "--no-open", help="Do not auto-open the browser."),
153
+ shell: Optional[str] = typer.Option(None, "--shell", help="Executor shell: powershell|pwsh|zsh|bash."),
154
+ drift_interval: str = typer.Option("30m", "--drift-interval", help="Background drift cadence (0 = off)."),
155
+ demo: bool = typer.Option(False, "--demo", help="Force the bundled demo dataset."),
156
+ check: bool = typer.Option(False, "--check", help="Run interactive cloud-SDK setup before serving."),
157
+ skip_checks: bool = typer.Option(False, "--skip-checks", help="Skip the cloud-SDK status check."),
158
+ ):
159
+ """Local mode: scan PATH, serve the UI + executor on http://host:port."""
160
+ import uvicorn
161
+
162
+ from .server.app import Config, create_app
163
+
164
+ roots = [p for p in ([path] if path else []) + list(scan or []) if p]
165
+ if not roots:
166
+ roots = [Path.cwd()]
167
+ cfg = Config(scan_roots=[Path(r) for r in roots], shell=shell, demo=demo,
168
+ drift_interval_min=_parse_minutes(drift_interval))
169
+
170
+ app_obj = create_app(cfg)
171
+ state = app_obj.state.terraui
172
+ needed = [] if state.demo else _clouds_from_stacks(state.stacks)
173
+
174
+ if check and needed:
175
+ _interactive_setup(needed)
176
+ elif not skip_checks and needed:
177
+ _quick_check(needed)
178
+
179
+ url = f"http://{host}:{port}"
180
+ mode = "demo dataset" if state.demo else f"{len(state.stacks)} unit(s) discovered"
181
+ typer.echo(f"TerraUi {__version__} · {mode} · {url}")
182
+ if not no_open:
183
+ threading.Thread(target=lambda: (time.sleep(1.0), webbrowser.open(url)), daemon=True).start()
184
+ uvicorn.run(app_obj, host=host, port=port, log_level="warning")
185
+
186
+
187
+ @app.command()
188
+ def scan(
189
+ path: Optional[Path] = typer.Argument(None, help="IaC root to scan (default: cwd)."),
190
+ as_json: bool = typer.Option(False, "--json", help="Print discovered units as JSON."),
191
+ ):
192
+ """Discover units and print them (no server) — for CI / debugging."""
193
+ from .discovery import scan_roots
194
+
195
+ root = path or Path.cwd()
196
+ stacks = scan_roots([root])
197
+ if as_json:
198
+ typer.echo(json.dumps([s.model_dump() for s in stacks], indent=2))
199
+ return
200
+ if not stacks:
201
+ typer.echo(f"No Terraform/Terragrunt units found under {root}")
202
+ return
203
+ typer.echo(f"Discovered {len(stacks)} unit(s) under {root}:")
204
+ for s in stacks:
205
+ deps = f" deps: {', '.join(s.deps)}" if s.deps else ""
206
+ typer.echo(f" {s.tool:10} {s.env:8} {s.path} [{','.join(s.clouds) or '?'}]{deps}")
207
+
208
+
209
+ @app.command()
210
+ def server(
211
+ config: Optional[Path] = typer.Option(None, "--config", help="terraui.yaml (team mode)."),
212
+ port: int = typer.Option(8787),
213
+ host: str = typer.Option("0.0.0.0"),
214
+ ):
215
+ """Team mode: shared control plane (SSO/RBAC/Postgres). [scaffold]"""
216
+ typer.echo("Server mode is scaffolded — see BACKEND_SPEC §11. Use `terraui start` for local mode.")
217
+ raise typer.Exit(code=1)
218
+
219
+
220
+ @app.command()
221
+ def agent(
222
+ server_url: str = typer.Option(..., "--server", help="Control-plane URL to register with."),
223
+ label: str = typer.Option("runner", "--label", help="Agent label, e.g. prod-runner."),
224
+ ):
225
+ """Remote executor that registers with a server. [scaffold]"""
226
+ typer.echo(f"Agent mode is scaffolded — would register '{label}' with {server_url}. See BACKEND_SPEC §11.")
227
+ raise typer.Exit(code=1)
228
+
229
+
230
+ def _parse_minutes(value: str) -> int:
231
+ value = (value or "0").strip().lower()
232
+ if value in ("0", "off", ""):
233
+ return 0
234
+ if value.endswith("m"):
235
+ return int(value[:-1] or 0)
236
+ if value.endswith("h"):
237
+ return int(value[:-1] or 0) * 60
238
+ return int(value)
239
+
240
+
241
+ if __name__ == "__main__":
242
+ app()
File without changes
terraui/clouds/auth.py ADDED
@@ -0,0 +1,76 @@
1
+ """Cloud SDK auth detection (BACKEND_SPEC §7).
2
+
3
+ Read-only probes per cloud, cached ~60s. GCP requires BOTH a user login and ADC.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ import time
11
+
12
+ from ..execution.executor import run_capture
13
+ from ..models import CloudAuth
14
+
15
+ _CACHE: dict[str, tuple[float, CloudAuth]] = {}
16
+ _TTL = 60.0
17
+
18
+
19
+ async def _aws() -> CloudAuth:
20
+ code, out = await run_capture(["aws", "sts", "get-caller-identity", "--output", "json"])
21
+ name, cli = "AWS", await _version("aws", "--version")
22
+ if code == 0:
23
+ try:
24
+ data = json.loads(out)
25
+ acct, arn = data.get("Account", ""), data.get("Arn", "")
26
+ return CloudAuth(id="aws", name=name, cli=cli, ok=True, status="authenticated", detail=f"{acct} · {arn}")
27
+ except json.JSONDecodeError:
28
+ pass
29
+ return CloudAuth(id="aws", name=name, cli=cli, ok=False, status="re-auth", detail="not authenticated · aws sso login")
30
+
31
+
32
+ async def _gcp() -> CloudAuth:
33
+ cli = await _version("gcloud", "--version")
34
+ adc_code, _ = await run_capture(["gcloud", "auth", "application-default", "print-access-token"])
35
+ list_code, list_out = await run_capture(["gcloud", "auth", "list", "--format=value(account)"])
36
+ active = [ln for ln in list_out.splitlines() if ln.strip()]
37
+ if adc_code == 0 and list_code == 0 and active:
38
+ return CloudAuth(id="gcp", name="GCP", cli=cli, ok=True, status="authenticated", detail=f"{active[0]} · ADC active")
39
+ if list_code == 0 and active and adc_code != 0:
40
+ return CloudAuth(id="gcp", name="GCP", cli=cli, ok=False, status="re-auth", detail="run gcloud auth application-default login")
41
+ return CloudAuth(id="gcp", name="GCP", cli=cli, ok=False, status="re-auth", detail="run gcloud auth login")
42
+
43
+
44
+ async def _azure() -> CloudAuth:
45
+ cli = await _version("az", "version")
46
+ code, out = await run_capture(["az", "account", "show", "--output", "json"])
47
+ if code == 0:
48
+ try:
49
+ data = json.loads(out)
50
+ return CloudAuth(id="azure", name="Azure", cli=cli, ok=True, status="authenticated", detail=f"{data.get('name','')} · {data.get('id','')}")
51
+ except json.JSONDecodeError:
52
+ pass
53
+ return CloudAuth(id="azure", name="Azure", cli=cli, ok=False, status="re-auth", detail="token expired · run az login")
54
+
55
+
56
+ async def _version(exe: str, *args: str) -> str:
57
+ code, out = await run_capture([exe, *args], timeout=10)
58
+ if code != 0:
59
+ return f"{exe} (not found)"
60
+ m = re.search(r"(\d+\.\d+(?:\.\d+)?)", out)
61
+ return f"{exe} {m.group(1)}" if m else exe
62
+
63
+
64
+ async def probe_all(force: bool = False) -> list[CloudAuth]:
65
+ now = time.time()
66
+ results: list[CloudAuth] = []
67
+ probes = {"aws": _aws, "gcp": _gcp, "azure": _azure}
68
+ for cid, fn in probes.items():
69
+ cached = _CACHE.get(cid)
70
+ if not force and cached and now - cached[0] < _TTL:
71
+ results.append(cached[1])
72
+ continue
73
+ auth = await fn()
74
+ _CACHE[cid] = (now, auth)
75
+ results.append(auth)
76
+ return results
@@ -0,0 +1,192 @@
1
+ """Cloud SDK preflight — detect, (optionally) install, and authenticate the
2
+ provider CLIs TerraUi shells out to (BACKEND_SPEC §7).
3
+
4
+ Read-only by default: detection runs `--version` and the auth probes only. Any
5
+ state-changing step (installing a CLI, running a login) is shown as the exact
6
+ command and gated behind explicit per-provider confirmation. Nothing is stored;
7
+ auth uses each SDK's own credential flow. Providers can be skipped and configured
8
+ later — a GCP-only user need not touch AWS/Azure.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import platform
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ from dataclasses import dataclass, field
19
+ from typing import Callable, Optional
20
+
21
+ IS_WIN = platform.system() == "Windows"
22
+ IS_MAC = platform.system() == "Darwin"
23
+
24
+
25
+ # ---- injectable primitives (monkeypatched in tests; never run installs there) ----
26
+ def _which(exe: str) -> Optional[str]:
27
+ return shutil.which(exe)
28
+
29
+
30
+ def _run(argv: list[str], timeout: float = 15, interactive: bool = False) -> tuple[int, str]:
31
+ """Run a command. interactive=True inherits the terminal (for logins/installs)."""
32
+ try:
33
+ if interactive:
34
+ return subprocess.run(argv).returncode or 0, ""
35
+ p = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
36
+ return p.returncode, (p.stdout or "") + (p.stderr or "")
37
+ except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
38
+ return 127, str(exc)
39
+
40
+
41
+ def package_managers() -> list[str]:
42
+ """Available package managers on this machine, in preference order."""
43
+ candidates = ["winget", "choco", "scoop"] if IS_WIN else (["brew"] if IS_MAC else ["brew", "apt-get", "dnf"])
44
+ return [pm for pm in candidates if _which(pm)]
45
+
46
+
47
+ @dataclass
48
+ class Provider:
49
+ id: str # aws | gcp | azure
50
+ name: str # AWS | GCP | Azure
51
+ exe: str # aws | gcloud | az
52
+ docs_url: str
53
+ install: dict = field(default_factory=dict) # pkg-manager -> argv
54
+ install_url: str = "" # manual installer link
55
+ login: list = field(default_factory=list) # [(description, argv), ...]
56
+
57
+
58
+ PROVIDERS: dict[str, Provider] = {
59
+ "aws": Provider(
60
+ id="aws", name="AWS", exe="aws",
61
+ docs_url="https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html",
62
+ install={
63
+ "winget": ["winget", "install", "-e", "--id", "Amazon.AWSCLI", "--accept-source-agreements", "--accept-package-agreements"],
64
+ "choco": ["choco", "install", "awscli", "-y"],
65
+ "brew": ["brew", "install", "awscli"],
66
+ },
67
+ install_url="https://awscli.amazonaws.com/AWSCLIV2.msi" if IS_WIN else "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html",
68
+ login=[
69
+ ("Sign in with AWS IAM Identity Center (SSO)", ["aws", "configure", "sso"]),
70
+ ("Configure static access keys", ["aws", "configure"]),
71
+ ],
72
+ ),
73
+ "gcp": Provider(
74
+ id="gcp", name="GCP", exe="gcloud",
75
+ docs_url="https://cloud.google.com/sdk/docs/install",
76
+ install={
77
+ "winget": ["winget", "install", "-e", "--id", "Google.CloudSDK", "--accept-source-agreements", "--accept-package-agreements"],
78
+ "choco": ["choco", "install", "gcloudsdk", "-y"],
79
+ "brew": ["brew", "install", "--cask", "google-cloud-sdk"],
80
+ },
81
+ install_url="https://cloud.google.com/sdk/docs/install",
82
+ # both are required: user login for the gcloud CLI, ADC for the Terraform google provider
83
+ login=[
84
+ ("gcloud user login (CLI identity)", ["gcloud", "auth", "login"]),
85
+ ("Application Default Credentials (used by Terraform)", ["gcloud", "auth", "application-default", "login"]),
86
+ ],
87
+ ),
88
+ "azure": Provider(
89
+ id="azure", name="Azure", exe="az",
90
+ docs_url="https://learn.microsoft.com/cli/azure/install-azure-cli",
91
+ install={
92
+ "winget": ["winget", "install", "-e", "--id", "Microsoft.AzureCLI", "--accept-source-agreements", "--accept-package-agreements"],
93
+ "choco": ["choco", "install", "azure-cli", "-y"],
94
+ "brew": ["brew", "install", "azure-cli"],
95
+ },
96
+ install_url="https://aka.ms/installazurecliwindows" if IS_WIN else "https://learn.microsoft.com/cli/azure/install-azure-cli",
97
+ login=[("az login (opens browser)", ["az", "login"])],
98
+ ),
99
+ }
100
+
101
+
102
+ @dataclass
103
+ class Status:
104
+ id: str
105
+ name: str
106
+ installed: bool = False
107
+ version: str = ""
108
+ authed: bool = False
109
+ detail: str = ""
110
+
111
+
112
+ def _version(p: Provider) -> str:
113
+ args = ["version"] if p.id == "azure" else ["--version"]
114
+ code, out = _run([p.exe, *args], timeout=10)
115
+ if code == 127:
116
+ return ""
117
+ m = re.search(r"(\d+\.\d+(?:\.\d+)?)", out)
118
+ return f"{p.exe} {m.group(1)}" if m else p.exe
119
+
120
+
121
+ def _auth(p: Provider) -> tuple[bool, str]:
122
+ if p.id == "aws":
123
+ code, out = _run([p.exe, "sts", "get-caller-identity", "--output", "json"])
124
+ if code == 0:
125
+ try:
126
+ d = json.loads(out)
127
+ return True, f"{d.get('Account','')} · {d.get('Arn','')}"
128
+ except json.JSONDecodeError:
129
+ pass
130
+ return False, "not authenticated · aws sso login"
131
+ if p.id == "gcp":
132
+ adc, _ = _run([p.exe, "auth", "application-default", "print-access-token"])
133
+ lst, out = _run([p.exe, "auth", "list", "--format=value(account)"])
134
+ accts = [x for x in out.splitlines() if x.strip()]
135
+ if adc == 0 and lst == 0 and accts:
136
+ return True, f"{accts[0]} · ADC active"
137
+ if accts:
138
+ return False, "ADC missing · gcloud auth application-default login"
139
+ return False, "not authenticated · gcloud auth login"
140
+ # azure
141
+ code, out = _run([p.exe, "account", "show", "--output", "json"])
142
+ if code == 0:
143
+ try:
144
+ d = json.loads(out)
145
+ return True, f"{d.get('name','')} · {d.get('id','')}"
146
+ except json.JSONDecodeError:
147
+ pass
148
+ return False, "token expired · az login"
149
+
150
+
151
+ def check(provider_id: str) -> Status:
152
+ """Read-only status for one provider."""
153
+ p = PROVIDERS[provider_id]
154
+ st = Status(id=p.id, name=p.name)
155
+ if not _which(p.exe):
156
+ st.detail = "CLI not installed"
157
+ return st
158
+ st.installed = True
159
+ st.version = _version(p)
160
+ st.authed, st.detail = _auth(p)
161
+ return st
162
+
163
+
164
+ def check_all(provider_ids: Optional[list[str]] = None) -> list[Status]:
165
+ return [check(pid) for pid in (provider_ids or list(PROVIDERS))]
166
+
167
+
168
+ def install_command(provider_id: str) -> tuple[Optional[list[str]], str]:
169
+ """Best install argv for this machine + the manual-install URL."""
170
+ p = PROVIDERS[provider_id]
171
+ for pm in package_managers():
172
+ if pm in p.install:
173
+ return p.install[pm], p.install_url
174
+ return None, p.install_url
175
+
176
+
177
+ # ---- interactive driver (used by `terraui setup`) ----
178
+ def run_install(provider_id: str) -> bool:
179
+ argv, url = install_command(provider_id)
180
+ if not argv:
181
+ return False
182
+ code, _ = _run(argv, interactive=True, timeout=900)
183
+ return code == 0
184
+
185
+
186
+ def run_login(provider_id: str, step_index: int) -> bool:
187
+ p = PROVIDERS[provider_id]
188
+ if step_index >= len(p.login):
189
+ return False
190
+ _, argv = p.login[step_index]
191
+ code, _ = _run(argv, interactive=True, timeout=900)
192
+ return code == 0