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/config.py ADDED
@@ -0,0 +1,94 @@
1
+ """Profiles and settings.
2
+
3
+ ~/.config/devlift/cli.json:
4
+
5
+ {
6
+ "default_profile": "default",
7
+ "profiles": {
8
+ "default": {"base_url": "http://localhost:8000", "output": "table"},
9
+ "stage": {"base_url": "https://devlift.example.com"}
10
+ }
11
+ }
12
+
13
+ Environment overrides (highest precedence): DEVLIFT_PROFILE, DEVLIFT_BASE_URL,
14
+ DEVLIFT_TOKEN (skips the stored credentials entirely), DEVLIFT_OUTPUT.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ DEFAULT_BASE_URL = "http://localhost:8000"
25
+ API_PREFIX = "/api/v1"
26
+ OAUTH_METADATA_PATH = "/.well-known/oauth-authorization-server/devlift-mcp"
27
+
28
+ CONFIG_DIR = Path(os.environ.get("DEVLIFT_CONFIG_DIR", Path.home() / ".config" / "devlift"))
29
+ CONFIG_FILE = CONFIG_DIR / "cli.json"
30
+ PROJECT_FILE_NAME = "project.json"
31
+
32
+
33
+ @dataclass
34
+ class Profile:
35
+ name: str
36
+ base_url: str = DEFAULT_BASE_URL
37
+ output: str = "table"
38
+
39
+ @property
40
+ def api_url(self) -> str:
41
+ return self.base_url.rstrip("/") + API_PREFIX
42
+
43
+
44
+ def _read_file() -> dict:
45
+ try:
46
+ return json.loads(CONFIG_FILE.read_text())
47
+ except (FileNotFoundError, ValueError):
48
+ return {}
49
+
50
+
51
+ def _write_file(data: dict) -> None:
52
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
53
+ tmp = CONFIG_FILE.with_suffix(".tmp")
54
+ tmp.write_text(json.dumps(data, indent=2) + "\n")
55
+ tmp.chmod(0o600)
56
+ tmp.replace(CONFIG_FILE)
57
+
58
+
59
+ def list_profiles() -> dict[str, dict]:
60
+ return dict(_read_file().get("profiles") or {})
61
+
62
+
63
+ def default_profile_name() -> str:
64
+ return os.environ.get("DEVLIFT_PROFILE") or _read_file().get("default_profile") or "default"
65
+
66
+
67
+ def load_profile(name: str | None = None, base_url_override: str | None = None) -> Profile:
68
+ name = name or default_profile_name()
69
+ raw = list_profiles().get(name) or {}
70
+ base_url = base_url_override or os.environ.get("DEVLIFT_BASE_URL") or raw.get("base_url") or DEFAULT_BASE_URL
71
+ output = os.environ.get("DEVLIFT_OUTPUT") or raw.get("output") or "table"
72
+ return Profile(name=name, base_url=base_url.rstrip("/"), output=output)
73
+
74
+
75
+ def save_profile(profile: Profile, make_default: bool = False) -> None:
76
+ data = _read_file()
77
+ profiles = data.setdefault("profiles", {})
78
+ profiles[profile.name] = {"base_url": profile.base_url, "output": profile.output}
79
+ if make_default or "default_profile" not in data:
80
+ data["default_profile"] = profile.name
81
+ _write_file(data)
82
+
83
+
84
+ def find_project_id(start: Path | None = None) -> str | None:
85
+ """`.devlift/project.json` in the cwd or any parent, like `.git`."""
86
+ here = (start or Path.cwd()).resolve()
87
+ for directory in (here, *here.parents):
88
+ candidate = directory / ".devlift" / PROJECT_FILE_NAME
89
+ if candidate.is_file():
90
+ try:
91
+ return json.loads(candidate.read_text()).get("project_id")
92
+ except ValueError:
93
+ return None
94
+ return None
devlift_cli/context.py ADDED
@@ -0,0 +1,97 @@
1
+ """Per-invocation state shared by every command: profile, output format,
2
+ confirmation policy, and a lazily built API client."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+
8
+ from devlift_cli.api.client import ApiClient
9
+ from devlift_cli.auth.session import TokenSource
10
+ from devlift_cli.config import Profile
11
+ from devlift_cli.errors import ConfirmationRequired, InputError
12
+ from devlift_cli.render import output
13
+
14
+
15
+ @dataclass
16
+ class Invocation:
17
+ profile: Profile
18
+ output: str
19
+ yes: bool = False
20
+ no_input: bool = False
21
+ debug: bool = False
22
+ _api: ApiClient | None = field(default=None, repr=False)
23
+
24
+ @property
25
+ def api(self) -> ApiClient:
26
+ if self._api is None:
27
+ self._api = ApiClient(self.profile, TokenSource(self.profile), debug=self.debug)
28
+ return self._api
29
+
30
+ @property
31
+ def interactive(self) -> bool:
32
+ import sys
33
+
34
+ return not self.no_input and sys.stdin.isatty()
35
+
36
+ @property
37
+ def show_summary(self) -> bool:
38
+ """Print the pre-confirmation summary (tables on stderr)?
39
+
40
+ On a terminal or in table mode, always. In json mode it is also
41
+ printed whenever the run is a DRY RUN (no --yes): a script or an agent
42
+ that runs a write without -y is asking "what would this do?", and the
43
+ answer has to be visible, not only the exit-5 line. Only a scripted
44
+ `-y -o json` run stays quiet."""
45
+ return self.output == "table" or self.interactive or not self.yes
46
+
47
+ def confirm(self, question: str) -> None:
48
+ """Proceed, or raise. --yes answers; --no-input without --yes refuses."""
49
+ if self.yes:
50
+ return
51
+ if not self.interactive:
52
+ raise ConfirmationRequired(f"{question} (needs confirmation)")
53
+ from rich.prompt import Confirm
54
+
55
+ if not Confirm.ask(question, default=False, console=output.err_console):
56
+ raise ConfirmationRequired("Cancelled.")
57
+
58
+ def ask(self, label: str, *, choices: list[str] | None = None, default: str | None = None, flag: str | None = None) -> str:
59
+ """Prompt for a missing value, or refuse under --no-input naming the flag."""
60
+ if not self.interactive:
61
+ raise InputError(f"{label} is required.", hint=f"Pass {flag}." if flag else None)
62
+ from rich.prompt import Prompt
63
+
64
+ if choices and len(choices) <= 12:
65
+ output.err_console.print(f"[bold]{label}[/bold]")
66
+ for i, choice in enumerate(choices, 1):
67
+ output.err_console.print(f" {i}. {choice}")
68
+ while True:
69
+ raw = Prompt.ask("Choose (number or name)", default=default, console=output.err_console)
70
+ if raw is None:
71
+ continue
72
+ raw = raw.strip()
73
+ if raw.isdigit() and 1 <= int(raw) <= len(choices):
74
+ return choices[int(raw) - 1]
75
+ match = [c for c in choices if c.lower() == raw.lower()]
76
+ if match:
77
+ return match[0]
78
+ output.warn(f"Pick one of 1-{len(choices)}.")
79
+ while True:
80
+ raw = Prompt.ask(label, default=default, console=output.err_console)
81
+ if raw and raw.strip():
82
+ return raw.strip()
83
+ output.warn(f"{label} is required.")
84
+
85
+ def ask_bool(self, question: str, *, default: bool = False, flag: str | None = None) -> bool:
86
+ if not self.interactive:
87
+ raise InputError(f"{question} needs an answer.", hint=f"Pass {flag}." if flag else None)
88
+ from rich.prompt import Confirm
89
+
90
+ return Confirm.ask(question, default=default, console=output.err_console)
91
+
92
+ def emit(self, data, table=None) -> None:
93
+ output.emit(data, self.output, table)
94
+
95
+ def close(self) -> None:
96
+ if self._api is not None:
97
+ self._api.close()
@@ -0,0 +1,16 @@
1
+ {
2
+ "_tenants": ["vance", "aspora"],
3
+
4
+ "Default_workspace": {
5
+ "core": {
6
+ "stage": {
7
+ "ap-south-1": ["database", "dynamo", "gateway", "s3", "sqs", "eks"]
8
+ },
9
+
10
+ "prod": {
11
+ "ap-south-1": ["database", "gateway", "s3", "sqs"],
12
+ "eu-west-2": ["database", "dynamo", "gateway", "s3", "sqs", "eks"]
13
+ }
14
+ }
15
+ }
16
+ }
devlift_cli/errors.py ADDED
@@ -0,0 +1,52 @@
1
+ """One error type, one exit-code table.
2
+
3
+ Every command failure is raised as a CliError and printed once, in main().
4
+ The codes are part of the CLI's contract for scripts:
5
+
6
+ 0 ok
7
+ 1 error (server 5xx, unexpected)
8
+ 2 not authenticated — run `devlift login`
9
+ 3 missing / invalid input, ambiguous name
10
+ 4 permission refused (403)
11
+ 5 confirmation required (no --yes under --no-input)
12
+ 6 not found (404)
13
+ 7 conflict / lane busy / wrong state (409)
14
+ """
15
+
16
+ EXIT_OK = 0
17
+ EXIT_ERROR = 1
18
+ EXIT_AUTH = 2
19
+ EXIT_INPUT = 3
20
+ EXIT_PERMISSION = 4
21
+ EXIT_CONFIRM = 5
22
+ EXIT_NOT_FOUND = 6
23
+ EXIT_CONFLICT = 7
24
+
25
+
26
+ class CliError(Exception):
27
+ def __init__(self, message: str, code: int = EXIT_ERROR, hint: str | None = None, http_status: int | None = None):
28
+ super().__init__(message)
29
+ self.message = message
30
+ self.code = code
31
+ self.hint = hint
32
+ self.http_status = http_status
33
+
34
+ @property
35
+ def transient(self) -> bool:
36
+ """A server-side hiccup worth retrying (bad gateway, unavailable, timeout)."""
37
+ return self.http_status in (502, 503, 504)
38
+
39
+
40
+ class AuthError(CliError):
41
+ def __init__(self, message: str = "Not signed in.", hint: str | None = "Run `devlift login`."):
42
+ super().__init__(message, EXIT_AUTH, hint)
43
+
44
+
45
+ class InputError(CliError):
46
+ def __init__(self, message: str, hint: str | None = None):
47
+ super().__init__(message, EXIT_INPUT, hint)
48
+
49
+
50
+ class ConfirmationRequired(CliError):
51
+ def __init__(self, message: str = "Confirmation required."):
52
+ super().__init__(message, EXIT_CONFIRM, "Pass --yes to confirm non-interactively.")
File without changes
@@ -0,0 +1,343 @@
1
+ """`devlift request …`, `devlift approval …`, `devlift eks deploy`: the review lane.
2
+
3
+ A service's change is a CHANGE SET: its settings row, its gateway-routes row
4
+ and its variables row in the transaction queue, all for one service
5
+ configuration and one author. The backend moves the whole set on every verb
6
+ (submit, approve, ...), so here a request is named either by its queue code
7
+ or by the service, and the output lists every row that moved.
8
+
9
+ draft ──submit──▶ submit ──approve──▶ approved ──deploy──▶ in flight
10
+ ▲ │ ▲ │
11
+ └───withdraw──────┘ └─────revoke────────┘
12
+ └──request-changes──┘ reject ──▶ rejected (terminal)
13
+ discard: a draft is thrown away
14
+
15
+ Deploy is `POST /deployments/multiple-deploy {service_config_code}`: obs_tool
16
+ resolves the approved rows itself, runs the gate (status, can_deploy, seal)
17
+ and starts one Temporal batch — the same single call the dashboard makes.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+
24
+ from devlift_cli.api import approvals as approvals_api
25
+ from devlift_cli.api import kong as kong_api
26
+ from devlift_cli.context import Invocation
27
+ from devlift_cli.errors import EXIT_CONFLICT, EXIT_INPUT, EXIT_NOT_FOUND, CliError, InputError
28
+ from devlift_cli.ops import wait
29
+ from devlift_cli.render import output
30
+ from devlift_cli.render.output import kv_table, rows_table
31
+ from devlift_cli.resolve.names import Resolver
32
+
33
+ KIND = {"update_service": "Settings", "add_route": "Gateway routes", "update_variables": "Variables", "delete_service": "Delete service"}
34
+
35
+ # Which statuses each verb starts from, and whether only the author may call it.
36
+ VERB_FROM = {
37
+ "submit": ("draft",), "discard": ("draft",), "withdraw": ("submit",),
38
+ "approve": ("submit",), "request-changes": ("submit",), "reject": ("submit",), "revoke": ("approved",),
39
+ }
40
+ AUTHOR_VERBS = ("submit", "discard", "withdraw")
41
+ VERB_DONE = {
42
+ "submit": "submitted for review", "discard": "discarded", "withdraw": "withdrawn, back with you as a draft",
43
+ "approve": "approved — it can be deployed", "request-changes": "sent back to the author as a draft",
44
+ "reject": "rejected", "revoke": "approval taken back — waiting for a decision again",
45
+ }
46
+
47
+
48
+ def kind_of(row: dict) -> str:
49
+ return KIND.get(row.get("case_ref_code") or "", row.get("case_ref_code") or row.get("resource_type") or "?")
50
+
51
+
52
+ # ── finding the request ──────────────────────────────────────────────────────
53
+
54
+ @dataclass
55
+ class Target:
56
+ config_code: str
57
+ service_name: str
58
+ environment: str | None
59
+ region_name: str | None
60
+
61
+
62
+ def _target_from_row(inv: Invocation, row: dict) -> Target:
63
+ """A queue row names its configuration; the environment is read from the
64
+ service's configurations, since the row itself does not carry it."""
65
+ code = row.get("resource_code") or ""
66
+ name = (row.get("config_snapshot") or {}).get("service_name") or row.get("display_name") or code
67
+ env = region = None
68
+ services_mst_code = (row.get("config_snapshot") or {}).get("services_mst_code")
69
+ if services_mst_code:
70
+ for o in kong_api.env_geo_options(inv.api, services_mst_code):
71
+ if o.get("config_code") == code:
72
+ env, region = o.get("environment"), o.get("geo_loc_name") or o.get("geo_loc_code")
73
+ name = name if name != code else o.get("service_name") or name
74
+ return Target(code, name, env, region)
75
+
76
+
77
+ def resolve_target(inv: Invocation, res: Resolver, service: str, env: str | None, region: str | None) -> Target:
78
+ from devlift_cli.ops.kong import resolve_target as _rt # same lookup the kong commands use
79
+ t = _rt(inv, res, service, env, region)
80
+ return Target(t.config_code, t.service_name, t.environment, t.region_name)
81
+
82
+
83
+ def find_requests(inv: Invocation, res: Resolver, ref: str, *, env: str | None, region: str | None,
84
+ statuses: tuple[str, ...] | None, mine_only: bool) -> tuple[Target, list[dict]]:
85
+ """The change set the user means: by queue code (that row's set) or by
86
+ service (every row on its configuration in the wanted statuses)."""
87
+ if ref.startswith("queue-"):
88
+ row = approvals_api.get_request(inv.api, ref)
89
+ target = _target_from_row(inv, row)
90
+ rows = approvals_api.list_requests(inv.api, resource_code=target.config_code)
91
+ same = [r for r in rows if r.get("status") == row.get("status") and r.get("requested_by") == row.get("requested_by")]
92
+ if all(r["code"] != row["code"] for r in same):
93
+ same.append(row)
94
+ return target, sorted(same, key=lambda r: r["code"] != row["code"])
95
+ target = resolve_target(inv, res, ref, env, region)
96
+ rows = approvals_api.list_requests(inv.api, resource_code=target.config_code)
97
+ if statuses:
98
+ rows = [r for r in rows if r.get("status") in statuses]
99
+ if mine_only:
100
+ rows = [r for r in rows if (r.get("you") or {}).get("mine")]
101
+ authors = {r.get("requested_by") for r in rows}
102
+ if len(authors) > 1:
103
+ raise CliError(
104
+ f"{target.service_name} has requests from several people: " + ", ".join(
105
+ f"{r['code']} ({r.get('requested_by_name') or r.get('requested_by')}, {r['status']})" for r in rows),
106
+ EXIT_INPUT, hint="Name the queue code instead of the service.",
107
+ )
108
+ return target, rows
109
+
110
+
111
+ # ── showing it ───────────────────────────────────────────────────────────────
112
+
113
+ def diff_rows(row: dict) -> list[tuple[str, str, str]]:
114
+ """The frozen diff as (field, from, to). Settings rows carry a field map;
115
+ a gateway row carries the groups with their path actions."""
116
+ changes = row.get("changes") or {}
117
+ out: list[tuple[str, str, str]] = []
118
+ if "groups" in changes:
119
+ for g in changes.get("groups") or []:
120
+ label = f"{g.get('route_group_key')} · {g.get('http_method')}"
121
+ for p in g.get("paths") or []:
122
+ act = (p.get("action") or "").lower()
123
+ if act == "add":
124
+ out.append((label, "–", p.get("route_path") or ""))
125
+ elif act == "delete":
126
+ out.append((label, p.get("route_path") or "", "– (removed)"))
127
+ elif act == "edit":
128
+ out.append((label, p.get("old_path") or "", p.get("route_path") or ""))
129
+ pb, pa = g.get("plugins_before") or [], g.get("plugins_after") or []
130
+ if sorted(pb) != sorted(pa):
131
+ out.append((f"{label} plugins", ", ".join(pb) or "–", ", ".join(pa) or "–"))
132
+ rb, ra = g.get("regex_priority_before") or 0, g.get("regex_priority_after") or 0
133
+ if rb != ra:
134
+ out.append((f"{label} priority", str(rb), str(ra)))
135
+ return out
136
+ for field, move in changes.items():
137
+ if isinstance(move, dict) and ("from" in move or "to" in move):
138
+ out.append((field, _v(move.get("from")), _v(move.get("to"))))
139
+ else:
140
+ out.append((field, "", _v(move)))
141
+ return out
142
+
143
+
144
+ def _v(value) -> str:
145
+ if value is None or value == "":
146
+ return "–"
147
+ if isinstance(value, list):
148
+ return ", ".join(str(x) for x in value)
149
+ return str(value)
150
+
151
+
152
+ def print_request(row: dict, *, with_diff: bool = True) -> None:
153
+ you = row.get("you") or {}
154
+ rows = [
155
+ ("Request", f"{row.get('code')} ({kind_of(row)})"),
156
+ ("Service", row.get("display_name")),
157
+ ("Status", row.get("status")),
158
+ ("Requested by", f"{row.get('requested_by_name') or row.get('requested_by')}" + (" (you)" if you.get("mine") else "")),
159
+ ("Requested at", (row.get("requested_at") or "")[:19].replace("T", " ")),
160
+ ]
161
+ if row.get("decided_by"):
162
+ rows.append(("Decided by", f"{row.get('decided_by_name') or row.get('decided_by')} at {(row.get('decided_at') or '')[:19].replace('T', ' ')}"))
163
+ if row.get("decision_comment"):
164
+ rows.append(("Comment", row["decision_comment"]))
165
+ rows.append(("You may", ", ".join(k[4:].replace("_", " ") for k in ("can_approve", "can_deploy") if you.get(k)) or "–"))
166
+ output.err_console.print(kv_table(rows))
167
+ if with_diff:
168
+ diff = diff_rows(row)
169
+ if diff:
170
+ output.err_console.print(rows_table(["Field", "From", "To"], diff, title="Changes"))
171
+ elif row.get("status") == "draft":
172
+ output.info("The diff is frozen at submit; a draft shows it in the dashboard's Preview tab.")
173
+
174
+
175
+ # ── acting ───────────────────────────────────────────────────────────────────
176
+
177
+ @dataclass
178
+ class VerbResult:
179
+ verb: str
180
+ service_name: str
181
+ config_code: str
182
+ rows: list[dict]
183
+
184
+ def to_dict(self) -> dict:
185
+ return {
186
+ "verb": self.verb, "service": self.service_name, "service_config_code": self.config_code,
187
+ "requests": [{"code": r.get("code"), "kind": kind_of(r), "status": r.get("status")} for r in self.rows],
188
+ }
189
+
190
+
191
+ def run_verb(inv: Invocation, res: Resolver, verb: str, ref: str, *, env: str | None, region: str | None, comment: str) -> VerbResult:
192
+ from_statuses = VERB_FROM[verb]
193
+ target, rows = find_requests(inv, res, ref, env=env, region=region, statuses=from_statuses, mine_only=verb in AUTHOR_VERBS)
194
+ if not rows:
195
+ raise CliError(
196
+ f"No {' or '.join(from_statuses)} request of yours on {target.service_name}" if verb in AUTHOR_VERBS
197
+ else f"No {' or '.join(from_statuses)} request on {target.service_name}",
198
+ EXIT_NOT_FOUND, hint=f"`devlift request list --service {target.service_name}` shows what exists.",
199
+ )
200
+ wrong = [r for r in rows if r.get("status") not in from_statuses]
201
+ if wrong:
202
+ raise CliError(
203
+ f"{wrong[0]['code']} is {wrong[0]['status']} — only a {' or '.join(from_statuses)} request can be {verb}ed.".replace("eed", "ed"),
204
+ EXIT_CONFLICT,
205
+ )
206
+ if verb in ("request-changes", "reject") and not comment.strip():
207
+ raise InputError(f"{verb} needs a comment telling the author why.", hint="Pass --comment.")
208
+
209
+ if inv.show_summary:
210
+ for r in rows:
211
+ full = approvals_api.get_request(inv.api, r["code"])
212
+ print_request(full, with_diff=verb != "discard")
213
+ inv.confirm(f"{verb.capitalize().replace('-', ' ')} this change on {target.service_name} ({len(rows)} request{'s' if len(rows) > 1 else ''})?")
214
+
215
+ approvals_api.act(inv.api, rows[0]["code"], verb, comment)
216
+ after: list[dict] = []
217
+ for r in rows:
218
+ try:
219
+ after.append(approvals_api.get_request(inv.api, r["code"]))
220
+ except CliError:
221
+ after.append({**r, "status": "discarded"}) # gone from every read
222
+ for r in after:
223
+ output.info(f"{r['code']} ({kind_of(r)}): {VERB_DONE[verb]}.")
224
+ return VerbResult(verb, target.service_name, target.config_code, after)
225
+
226
+
227
+ # ── deploy ───────────────────────────────────────────────────────────────────
228
+
229
+ @dataclass
230
+ class DeployResult:
231
+ service_name: str
232
+ config_code: str
233
+ environment: str | None
234
+ rows: list[dict]
235
+ workflow_id: str | None
236
+ status: str | None
237
+ state: dict | None = None
238
+
239
+ def to_dict(self) -> dict:
240
+ return {
241
+ "service": self.service_name, "service_config_code": self.config_code, "environment": self.environment,
242
+ "requests": [{"code": r.get("code"), "kind": kind_of(r)} for r in self.rows],
243
+ "workflow_id": self.workflow_id, "status": self.status, "state": self.state,
244
+ }
245
+
246
+
247
+ def deploy(inv: Invocation, res: Resolver, service: str, *, env: str | None, region: str | None,
248
+ confirm_name: str | None, do_wait: bool) -> DeployResult:
249
+ target = resolve_target(inv, res, service, env, region)
250
+ rows = [r for r in approvals_api.list_requests(inv.api, resource_code=target.config_code) if r.get("status") == "approved"]
251
+ if not rows:
252
+ raise CliError(f"Nothing approved to deploy on {target.service_name} ({target.environment}).", EXIT_NOT_FOUND,
253
+ hint=f"`devlift request list --service {target.service_name}` shows where it stands.")
254
+ if any((r.get("you") or {}).get("deploy_in_flight") for r in rows):
255
+ raise CliError(f"A deployment of {target.service_name} is already in progress.", EXIT_CONFLICT)
256
+ if not all((r.get("you") or {}).get("can_deploy", True) for r in rows):
257
+ raise CliError(f"You are not allowed to deploy {target.service_name} in {target.environment}.", 4)
258
+
259
+ if inv.show_summary:
260
+ output.err_console.print(kv_table([
261
+ ("Service", f"{target.service_name} ({target.environment} / {target.region_name})"),
262
+ ("Configuration", target.config_code),
263
+ ] + [(f"Approved {kind_of(r).lower()}", f"{r['code']} by {r.get('requested_by_name') or r.get('requested_by')}") for r in rows], title="Deploy"))
264
+ # What is actually about to ship, not just which rows. This is the last
265
+ # point where the values can still be looked at, and the approved
266
+ # snapshot is exactly what the pipeline renders — so show the same
267
+ # Deployed → Requested table the reviewer decided on.
268
+ for row in rows:
269
+ try:
270
+ full = approvals_api.get_request(inv.api, row["code"])
271
+ except CliError:
272
+ continue
273
+ output.err_console.print(rows_table(
274
+ ["Field", "Deployed", "Requested"],
275
+ diff_rows(full) or [("–", "–", "no field-level changes recorded")],
276
+ title=f"{kind_of(full)} · {full.get('code')}",
277
+ ))
278
+ if (target.environment or "").lower() == "prod":
279
+ # Production: the service name has to be typed, as the dashboard and the
280
+ # assistant require. --yes alone is not enough here.
281
+ typed = confirm_name
282
+ if typed is None:
283
+ if not inv.interactive:
284
+ raise CliError("Deploying to production needs the service name typed back.", 5,
285
+ hint=f"Pass --confirm-name {target.service_name}.")
286
+ typed = inv.ask(f"Type the service name to deploy to PRODUCTION ({target.service_name})", flag="--confirm-name")
287
+ if typed.strip() != target.service_name:
288
+ raise InputError(f"'{typed}' does not match {target.service_name}. Nothing was deployed.")
289
+ else:
290
+ inv.confirm(f"Deploy {target.service_name} to {target.environment}?")
291
+
292
+ started = approvals_api.multiple_deploy(inv.api, target.config_code) or {}
293
+ workflow_id = started.get("workflow_id")
294
+ output.info(f"Deployment of {target.service_name} to {target.environment} started" + (f" (workflow {workflow_id})." if workflow_id else "."))
295
+ result = DeployResult(target.service_name, target.config_code, target.environment, rows, workflow_id, started.get("status"))
296
+ if do_wait and workflow_id:
297
+ result.state = wait_for_multiple_deploy(inv, workflow_id, label=f"deploying {target.service_name}")
298
+ result.status = str((result.state or {}).get("step") or result.status)
299
+ return result
300
+
301
+
302
+ _ORCH_STEPS = {
303
+ "preparing": "preparing", "acquiring_locks": "acquiring locks", "deploying_variables": "deploying variables",
304
+ "deploying_infra": "deploying the service", "completed": "done", "failed": "failed",
305
+ }
306
+
307
+
308
+ def wait_for_multiple_deploy(inv: Invocation, workflow_id: str, label: str) -> dict:
309
+ """Follow the orchestrator: its own steps, then the infra child workflow's
310
+ granular steps once it exists."""
311
+ last: dict = {}
312
+ hiccups = 0
313
+
314
+ def describe():
315
+ nonlocal last, hiccups
316
+ try:
317
+ last = approvals_api.multiple_deploy_status(inv.api, workflow_id) or {}
318
+ hiccups = 0
319
+ except CliError as exc:
320
+ if not getattr(exc, "transient", False) or hiccups >= 12:
321
+ raise
322
+ hiccups += 1
323
+ return f"waiting for status (status service busy, retry {hiccups})", False, False
324
+ step = str(last.get("step") or "").lower()
325
+ text = _ORCH_STEPS.get(step, step or "starting")
326
+ child = last.get("infra_workflow_id") or last.get("infra_child_workflow_id")
327
+ if child and step == "deploying_infra":
328
+ try:
329
+ from devlift_cli.api import infra
330
+ sub = infra.deploy_status(inv.api, child) or {}
331
+ sub_step = str(sub.get("step") or "").lower()
332
+ text += ": " + wait._STEP_TEXT.get(sub_step, sub_step or "starting")
333
+ if sub.get("pr_number") and sub.get("repo_full_name"):
334
+ text += f" (PR #{sub['pr_number']} on {sub['repo_full_name']})"
335
+ except CliError:
336
+ pass
337
+ failed = step == "failed"
338
+ if failed and last.get("error"):
339
+ text += f": {last['error']}"
340
+ return text, failed or step == "completed", failed
341
+
342
+ wait.poll(describe, label=label)
343
+ return last