orbiads-cli 1.0.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.
- orbiads_cli/__init__.py +3 -0
- orbiads_cli/client.py +279 -0
- orbiads_cli/commands/__init__.py +0 -0
- orbiads_cli/commands/advertisers.py +47 -0
- orbiads_cli/commands/auth.py +173 -0
- orbiads_cli/commands/billing.py +54 -0
- orbiads_cli/commands/campaigns.py +111 -0
- orbiads_cli/commands/config_cmd.py +81 -0
- orbiads_cli/commands/creatives.py +105 -0
- orbiads_cli/commands/inventory.py +111 -0
- orbiads_cli/commands/network.py +53 -0
- orbiads_cli/commands/orders.py +89 -0
- orbiads_cli/commands/reporting.py +92 -0
- orbiads_cli/config.py +69 -0
- orbiads_cli/errors.py +84 -0
- orbiads_cli/main.py +68 -0
- orbiads_cli/output.py +115 -0
- orbiads_cli-1.0.0.dist-info/METADATA +51 -0
- orbiads_cli-1.0.0.dist-info/RECORD +22 -0
- orbiads_cli-1.0.0.dist-info/WHEEL +4 -0
- orbiads_cli-1.0.0.dist-info/entry_points.txt +2 -0
- orbiads_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
orbiads_cli/__init__.py
ADDED
orbiads_cli/client.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""HTTP client with auto-refresh, retry, and JSend envelope parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from orbiads_cli import config
|
|
13
|
+
|
|
14
|
+
# Public Firebase client identifier (same as frontend).
|
|
15
|
+
# This is NOT a secret — it only identifies the Firebase project.
|
|
16
|
+
FIREBASE_API_KEY = "AIzaSyAr2J5-a6GjIStBSOoIKB48DzSr0K-wHiQ"
|
|
17
|
+
FIREBASE_REFRESH_URL = (
|
|
18
|
+
f"https://securetoken.googleapis.com/v1/token?key={FIREBASE_API_KEY}"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Exponential backoff delays for 429 retries (seconds).
|
|
22
|
+
_BACKOFF_DELAYS = [1, 2, 4]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Exit-code mapping
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
_STATUS_TO_EXIT: dict[int, int] = {
|
|
30
|
+
404: 3,
|
|
31
|
+
401: 4,
|
|
32
|
+
403: 4,
|
|
33
|
+
409: 5,
|
|
34
|
+
412: 6,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _exit_code(status: int) -> int:
|
|
39
|
+
"""Map HTTP status to CLI exit code."""
|
|
40
|
+
return _STATUS_TO_EXIT.get(status, 1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Exceptions
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CliApiError(Exception):
|
|
49
|
+
"""API error with semantic exit code for CLI commands."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
exit_code: int,
|
|
54
|
+
message: str,
|
|
55
|
+
error_code: str = "",
|
|
56
|
+
details: dict[str, Any] | None = None,
|
|
57
|
+
):
|
|
58
|
+
super().__init__(message)
|
|
59
|
+
self.exit_code = exit_code
|
|
60
|
+
self.message = message
|
|
61
|
+
self.error_code = error_code
|
|
62
|
+
self.details: dict[str, Any] = details or {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Helpers
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
_CREDITS_RE = re.compile(
|
|
70
|
+
r"[Bb]alance[:\s]+(\d+).*[Rr]equired[:\s]+(\d+)"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _parse_credits_message(message: str, details: dict[str, Any]) -> None:
|
|
75
|
+
"""Best-effort extraction of balance/required from an error message."""
|
|
76
|
+
m = _CREDITS_RE.search(message)
|
|
77
|
+
if m:
|
|
78
|
+
details["balance"] = int(m.group(1))
|
|
79
|
+
details["required"] = int(m.group(2))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Client
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class OrbiAdsClient:
|
|
88
|
+
"""Thin wrapper around ``httpx.Client`` with auth and retry logic."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, cfg: dict) -> None:
|
|
91
|
+
self._cfg = cfg
|
|
92
|
+
self._http = httpx.Client(
|
|
93
|
+
base_url=cfg["apiUrl"],
|
|
94
|
+
headers={"Authorization": f"Bearer {cfg['token']}"},
|
|
95
|
+
timeout=30.0,
|
|
96
|
+
)
|
|
97
|
+
self._refreshed = False # guard: at most one refresh per request
|
|
98
|
+
|
|
99
|
+
# -- public convenience methods ------------------------------------------
|
|
100
|
+
|
|
101
|
+
def get(self, path: str, **kwargs: Any) -> Any:
|
|
102
|
+
return self._request("GET", path, **kwargs)
|
|
103
|
+
|
|
104
|
+
def post(self, path: str, **kwargs: Any) -> Any:
|
|
105
|
+
return self._request("POST", path, **kwargs)
|
|
106
|
+
|
|
107
|
+
def patch(self, path: str, **kwargs: Any) -> Any:
|
|
108
|
+
return self._request("PATCH", path, **kwargs)
|
|
109
|
+
|
|
110
|
+
def delete(self, path: str, **kwargs: Any) -> Any:
|
|
111
|
+
return self._request("DELETE", path, **kwargs)
|
|
112
|
+
|
|
113
|
+
# -- core request loop ---------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
116
|
+
self._refreshed = False
|
|
117
|
+
resp = self._http.request(method, path, **kwargs)
|
|
118
|
+
|
|
119
|
+
# 401 → attempt one token refresh then retry
|
|
120
|
+
if resp.status_code == 401 and not self._refreshed:
|
|
121
|
+
if self._refresh_token():
|
|
122
|
+
self._refreshed = True
|
|
123
|
+
resp = self._http.request(method, path, **kwargs)
|
|
124
|
+
else:
|
|
125
|
+
raise CliApiError(
|
|
126
|
+
4, "Session expired. Run `orbiads auth login`."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# 429 → exponential backoff (max 3 retries)
|
|
130
|
+
if resp.status_code == 429:
|
|
131
|
+
resp = self._retry_with_backoff(method, path, **kwargs)
|
|
132
|
+
|
|
133
|
+
# Parse JSend envelope
|
|
134
|
+
return self._parse_response(resp)
|
|
135
|
+
|
|
136
|
+
# -- token refresh -------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def _refresh_token(self) -> bool:
|
|
139
|
+
"""Refresh Firebase ID token using the stored refresh token.
|
|
140
|
+
|
|
141
|
+
Returns ``True`` on success, ``False`` otherwise.
|
|
142
|
+
"""
|
|
143
|
+
refresh_token = self._cfg.get("refreshToken")
|
|
144
|
+
if not refresh_token:
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
resp = httpx.post(
|
|
149
|
+
FIREBASE_REFRESH_URL,
|
|
150
|
+
data={
|
|
151
|
+
"grant_type": "refresh_token",
|
|
152
|
+
"refresh_token": refresh_token,
|
|
153
|
+
},
|
|
154
|
+
timeout=15.0,
|
|
155
|
+
)
|
|
156
|
+
except httpx.HTTPError:
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
if resp.status_code != 200:
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
data = resp.json()
|
|
163
|
+
new_token = data.get("id_token", "")
|
|
164
|
+
new_refresh = data.get("refresh_token", "")
|
|
165
|
+
if not new_token:
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
# Persist new tokens
|
|
169
|
+
config.set_token(new_token, new_refresh)
|
|
170
|
+
self._cfg["token"] = new_token
|
|
171
|
+
self._cfg["refreshToken"] = new_refresh
|
|
172
|
+
self._http.headers["Authorization"] = f"Bearer {new_token}"
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
# -- 429 backoff ---------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def _retry_with_backoff(
|
|
178
|
+
self, method: str, path: str, **kwargs: Any
|
|
179
|
+
) -> httpx.Response:
|
|
180
|
+
resp: httpx.Response | None = None
|
|
181
|
+
for delay in _BACKOFF_DELAYS:
|
|
182
|
+
typer.echo(
|
|
183
|
+
f"Rate limited, retrying in {delay}s...", err=True
|
|
184
|
+
)
|
|
185
|
+
time.sleep(delay)
|
|
186
|
+
resp = self._http.request(method, path, **kwargs)
|
|
187
|
+
if resp.status_code != 429:
|
|
188
|
+
return resp
|
|
189
|
+
# Return the last 429 so the caller can raise the appropriate error.
|
|
190
|
+
assert resp is not None
|
|
191
|
+
return resp
|
|
192
|
+
|
|
193
|
+
# -- response parsing ----------------------------------------------------
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _parse_response(resp: httpx.Response) -> Any:
|
|
197
|
+
"""Parse a JSend-style envelope.
|
|
198
|
+
|
|
199
|
+
On success returns the ``data`` field.
|
|
200
|
+
On error raises :class:`CliApiError` with the correct exit code.
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
body = resp.json()
|
|
204
|
+
except Exception:
|
|
205
|
+
if resp.status_code >= 400:
|
|
206
|
+
raise CliApiError(
|
|
207
|
+
_exit_code(resp.status_code),
|
|
208
|
+
f"HTTP {resp.status_code} (non-JSON response)",
|
|
209
|
+
)
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
if resp.status_code >= 400:
|
|
213
|
+
err = body.get("error") or {}
|
|
214
|
+
code = err.get("code", "")
|
|
215
|
+
message = err.get("message", f"HTTP {resp.status_code}")
|
|
216
|
+
details: dict[str, Any] = {}
|
|
217
|
+
|
|
218
|
+
# Extract structured details from the error payload.
|
|
219
|
+
if isinstance(err.get("details"), dict):
|
|
220
|
+
details = err["details"]
|
|
221
|
+
|
|
222
|
+
# Map INSUFFICIENT_CREDITS (HTTP 412) to exit code 6 with
|
|
223
|
+
# balance/required details parsed from the message or payload.
|
|
224
|
+
exit = _exit_code(resp.status_code)
|
|
225
|
+
if code == "INSUFFICIENT_CREDITS":
|
|
226
|
+
exit = 6
|
|
227
|
+
# Backend may include balance/required in details or message.
|
|
228
|
+
if "balance" not in details:
|
|
229
|
+
_parse_credits_message(message, details)
|
|
230
|
+
|
|
231
|
+
# For 404, include the request path as the resource hint.
|
|
232
|
+
if resp.status_code == 404 and "resource" not in details:
|
|
233
|
+
try:
|
|
234
|
+
details["resource"] = resp.request.url.path
|
|
235
|
+
except RuntimeError:
|
|
236
|
+
pass # request not set (e.g. in tests)
|
|
237
|
+
|
|
238
|
+
raise CliApiError(exit, message, code, details)
|
|
239
|
+
|
|
240
|
+
return body.get("data")
|
|
241
|
+
|
|
242
|
+
# -- cleanup -------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
def close(self) -> None:
|
|
245
|
+
self._http.close()
|
|
246
|
+
|
|
247
|
+
def __enter__(self) -> "OrbiAdsClient":
|
|
248
|
+
return self
|
|
249
|
+
|
|
250
|
+
def __exit__(self, *exc: Any) -> None:
|
|
251
|
+
self.close()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
# Factory
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
_singleton: OrbiAdsClient | None = None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def get_client() -> OrbiAdsClient:
|
|
262
|
+
"""Return a configured :class:`OrbiAdsClient`.
|
|
263
|
+
|
|
264
|
+
Reuses a single instance within the process (singleton).
|
|
265
|
+
Exits with code 4 if the user is not authenticated.
|
|
266
|
+
"""
|
|
267
|
+
global _singleton
|
|
268
|
+
if _singleton is not None:
|
|
269
|
+
return _singleton
|
|
270
|
+
|
|
271
|
+
cfg = config.load()
|
|
272
|
+
if not cfg or not cfg.get("token"):
|
|
273
|
+
typer.echo(
|
|
274
|
+
"Not authenticated. Run `orbiads auth login` first.", err=True
|
|
275
|
+
)
|
|
276
|
+
raise typer.Exit(code=4)
|
|
277
|
+
|
|
278
|
+
_singleton = OrbiAdsClient(cfg)
|
|
279
|
+
return _singleton
|
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Manage GAM advertisers."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from orbiads_cli.client import CliApiError, get_client
|
|
6
|
+
from orbiads_cli.errors import handle_error
|
|
7
|
+
from orbiads_cli.output import OutputContext, confirm, render, render_detail, success
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Manage GAM advertisers", no_args_is_help=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command("list")
|
|
13
|
+
def list_advertisers(ctx: typer.Context):
|
|
14
|
+
"""List advertisers."""
|
|
15
|
+
out: OutputContext = ctx.obj
|
|
16
|
+
try:
|
|
17
|
+
client = get_client()
|
|
18
|
+
data = client.get("/api/gam/advertisers")
|
|
19
|
+
# Response may be a model with "advertisers" key or a list
|
|
20
|
+
if isinstance(data, dict):
|
|
21
|
+
items = data.get("advertisers", data.get("companies", []))
|
|
22
|
+
else:
|
|
23
|
+
items = data if isinstance(data, list) else []
|
|
24
|
+
render(items, ["id", "name", "type"], out)
|
|
25
|
+
except CliApiError as e:
|
|
26
|
+
handle_error(e)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command()
|
|
30
|
+
def create(
|
|
31
|
+
ctx: typer.Context,
|
|
32
|
+
name: str = typer.Option(..., "--name", help="Advertiser name"),
|
|
33
|
+
):
|
|
34
|
+
"""Create a new advertiser."""
|
|
35
|
+
out: OutputContext = ctx.obj
|
|
36
|
+
if not confirm(f'Create advertiser "{name}"?', out):
|
|
37
|
+
raise typer.Exit(code=0)
|
|
38
|
+
try:
|
|
39
|
+
client = get_client()
|
|
40
|
+
data = client.post("/api/gam/advertisers", json={"name": name})
|
|
41
|
+
if out.format == "json":
|
|
42
|
+
render_detail(data, out)
|
|
43
|
+
else:
|
|
44
|
+
adv_id = data.get("id", "?") if isinstance(data, dict) else "?"
|
|
45
|
+
success(f"Advertiser created: {adv_id}")
|
|
46
|
+
except CliApiError as e:
|
|
47
|
+
handle_error(e)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Authentication commands: login, logout, status."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import webbrowser
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from orbiads_cli import config
|
|
11
|
+
from orbiads_cli.config import DEFAULT_API_URL
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="Authentication (login, logout, status)", no_args_is_help=True)
|
|
14
|
+
|
|
15
|
+
# stderr console for all auth output (stdout reserved for JSON data)
|
|
16
|
+
err_console = Console(stderr=True)
|
|
17
|
+
|
|
18
|
+
# Maximum polling duration in seconds (match server device code TTL)
|
|
19
|
+
_MAX_POLL_DURATION = 900
|
|
20
|
+
|
|
21
|
+
# HTTP timeout for individual requests
|
|
22
|
+
_HTTP_TIMEOUT = 30.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_api_url() -> str:
|
|
26
|
+
"""Return the configured API URL or the default."""
|
|
27
|
+
cfg = config.load()
|
|
28
|
+
return cfg.get("apiUrl", DEFAULT_API_URL) if cfg else DEFAULT_API_URL
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command()
|
|
32
|
+
def login() -> None:
|
|
33
|
+
"""Authenticate with OrbiAds via browser device flow."""
|
|
34
|
+
api_url = _get_api_url()
|
|
35
|
+
|
|
36
|
+
# Step 1: Request a device code
|
|
37
|
+
try:
|
|
38
|
+
resp = httpx.post(
|
|
39
|
+
f"{api_url}/api/auth/gam/device-code",
|
|
40
|
+
timeout=_HTTP_TIMEOUT,
|
|
41
|
+
)
|
|
42
|
+
resp.raise_for_status()
|
|
43
|
+
except httpx.HTTPError as exc:
|
|
44
|
+
err_console.print(f"[red]Failed to initiate login: {exc}[/red]")
|
|
45
|
+
raise typer.Exit(code=1) from None
|
|
46
|
+
|
|
47
|
+
body = resp.json()
|
|
48
|
+
if body.get("error"):
|
|
49
|
+
err_console.print(f"[red]Server error: {body['error'].get('message', 'Unknown error')}[/red]")
|
|
50
|
+
raise typer.Exit(code=1)
|
|
51
|
+
|
|
52
|
+
data = body["data"]
|
|
53
|
+
device_code = data["deviceCode"]
|
|
54
|
+
user_code = data["userCode"]
|
|
55
|
+
verification_url = data["verificationUrl"]
|
|
56
|
+
poll_interval = data.get("pollInterval", 5)
|
|
57
|
+
|
|
58
|
+
# Step 2: Display instructions and open browser
|
|
59
|
+
err_console.print()
|
|
60
|
+
err_console.print(f" To authorize, visit: [bold cyan]{verification_url}[/bold cyan]")
|
|
61
|
+
err_console.print(f" Your code: [bold yellow]{user_code}[/bold yellow]")
|
|
62
|
+
err_console.print()
|
|
63
|
+
err_console.print(" Waiting for authorization...", style="dim")
|
|
64
|
+
|
|
65
|
+
webbrowser.open(verification_url)
|
|
66
|
+
|
|
67
|
+
# Step 3: Poll for authorization
|
|
68
|
+
start_time = time.monotonic()
|
|
69
|
+
try:
|
|
70
|
+
with err_console.status("[bold green]Waiting for browser authorization...") as spinner:
|
|
71
|
+
while True:
|
|
72
|
+
elapsed = time.monotonic() - start_time
|
|
73
|
+
if elapsed >= _MAX_POLL_DURATION:
|
|
74
|
+
err_console.print(
|
|
75
|
+
"[red]Authorization timed out (max 15 min). Please try again.[/red]"
|
|
76
|
+
)
|
|
77
|
+
raise typer.Exit(code=4)
|
|
78
|
+
|
|
79
|
+
time.sleep(poll_interval)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
poll_resp = httpx.get(
|
|
83
|
+
f"{api_url}/api/auth/gam/device-token-status",
|
|
84
|
+
params={"deviceCode": device_code},
|
|
85
|
+
timeout=_HTTP_TIMEOUT,
|
|
86
|
+
)
|
|
87
|
+
poll_resp.raise_for_status()
|
|
88
|
+
except httpx.HTTPError as exc:
|
|
89
|
+
err_console.print(f"[red]Polling error: {exc}[/red]")
|
|
90
|
+
raise typer.Exit(code=1) from None
|
|
91
|
+
|
|
92
|
+
poll_body = poll_resp.json()
|
|
93
|
+
if poll_body.get("error"):
|
|
94
|
+
err_console.print(
|
|
95
|
+
f"[red]Server error: {poll_body['error'].get('message', 'Unknown error')}[/red]"
|
|
96
|
+
)
|
|
97
|
+
raise typer.Exit(code=1)
|
|
98
|
+
|
|
99
|
+
poll_data = poll_body["data"]
|
|
100
|
+
status_value = poll_data["status"]
|
|
101
|
+
|
|
102
|
+
if status_value == "authorized":
|
|
103
|
+
# Save tokens
|
|
104
|
+
config.set_token(
|
|
105
|
+
poll_data["accessToken"],
|
|
106
|
+
poll_data["refreshToken"],
|
|
107
|
+
)
|
|
108
|
+
spinner.stop()
|
|
109
|
+
err_console.print("[bold green]Authenticated successfully![/bold green]")
|
|
110
|
+
raise typer.Exit(code=0)
|
|
111
|
+
|
|
112
|
+
elif status_value == "expired":
|
|
113
|
+
spinner.stop()
|
|
114
|
+
err_console.print(
|
|
115
|
+
"[red]Authorization expired. Please try again.[/red]"
|
|
116
|
+
)
|
|
117
|
+
raise typer.Exit(code=4)
|
|
118
|
+
|
|
119
|
+
# status == "pending" — continue polling
|
|
120
|
+
|
|
121
|
+
except KeyboardInterrupt:
|
|
122
|
+
err_console.print("\n[yellow]Authorization cancelled.[/yellow]")
|
|
123
|
+
raise typer.Exit(code=1) from None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.command()
|
|
127
|
+
def logout() -> None:
|
|
128
|
+
"""Clear local credentials."""
|
|
129
|
+
config.clear()
|
|
130
|
+
err_console.print("Logged out.")
|
|
131
|
+
raise typer.Exit(code=0)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.command()
|
|
135
|
+
def status() -> None:
|
|
136
|
+
"""Show current authentication status."""
|
|
137
|
+
if not config.has_token():
|
|
138
|
+
err_console.print("Not authenticated.")
|
|
139
|
+
raise typer.Exit(code=4)
|
|
140
|
+
|
|
141
|
+
api_url = _get_api_url()
|
|
142
|
+
token = config.get_token()
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
resp = httpx.get(
|
|
146
|
+
f"{api_url}/api/me",
|
|
147
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
148
|
+
timeout=_HTTP_TIMEOUT,
|
|
149
|
+
)
|
|
150
|
+
except httpx.HTTPError as exc:
|
|
151
|
+
err_console.print(f"[red]Request failed: {exc}[/red]")
|
|
152
|
+
raise typer.Exit(code=1) from None
|
|
153
|
+
|
|
154
|
+
if resp.status_code == 401:
|
|
155
|
+
err_console.print("[red]Token invalid. Run `orbiads auth login` to re-authenticate.[/red]")
|
|
156
|
+
raise typer.Exit(code=4)
|
|
157
|
+
|
|
158
|
+
if resp.status_code != 200:
|
|
159
|
+
err_console.print(f"[red]Unexpected response (HTTP {resp.status_code}).[/red]")
|
|
160
|
+
raise typer.Exit(code=1)
|
|
161
|
+
|
|
162
|
+
body = resp.json()
|
|
163
|
+
if body.get("error"):
|
|
164
|
+
err_console.print(f"[red]Server error: {body['error'].get('message', 'Unknown')}[/red]")
|
|
165
|
+
raise typer.Exit(code=1)
|
|
166
|
+
|
|
167
|
+
data = body.get("data", {})
|
|
168
|
+
email = data.get("email", "unknown")
|
|
169
|
+
network = data.get("networkCode", "not set")
|
|
170
|
+
|
|
171
|
+
err_console.print(f" Authenticated as: [bold]{email}[/bold]")
|
|
172
|
+
err_console.print(f" GAM Network: [bold]{network}[/bold]")
|
|
173
|
+
raise typer.Exit(code=0)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Check credits and billing."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from orbiads_cli.client import CliApiError, get_client
|
|
6
|
+
from orbiads_cli.errors import handle_error
|
|
7
|
+
from orbiads_cli.output import render, render_detail
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Check credits and billing", no_args_is_help=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command()
|
|
13
|
+
def balance(ctx: typer.Context):
|
|
14
|
+
"""Show current credit balance and plan info."""
|
|
15
|
+
try:
|
|
16
|
+
client = get_client()
|
|
17
|
+
data = client.get("/api/billing")
|
|
18
|
+
|
|
19
|
+
# Display key billing fields
|
|
20
|
+
detail = {
|
|
21
|
+
"credits": data.get("balance", 0),
|
|
22
|
+
"plan": data.get("plan", "unknown"),
|
|
23
|
+
"nextRenewal": data.get("cycleStart", "N/A"),
|
|
24
|
+
"overdue": data.get("overdue", False),
|
|
25
|
+
}
|
|
26
|
+
render_detail(detail, ctx.obj)
|
|
27
|
+
except CliApiError as e:
|
|
28
|
+
handle_error(e)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command()
|
|
32
|
+
def transactions(
|
|
33
|
+
ctx: typer.Context,
|
|
34
|
+
limit: int = typer.Option(20, "--limit", help="Max results"),
|
|
35
|
+
):
|
|
36
|
+
"""List recent credit transactions."""
|
|
37
|
+
try:
|
|
38
|
+
client = get_client()
|
|
39
|
+
data = client.get("/api/billing/transactions", params={"limit": limit})
|
|
40
|
+
|
|
41
|
+
rows = data if isinstance(data, list) else []
|
|
42
|
+
columns = ["timestamp", "type", "amount", "reason"]
|
|
43
|
+
# Normalize column names: backend may use camelCase
|
|
44
|
+
normalized = []
|
|
45
|
+
for row in rows:
|
|
46
|
+
normalized.append({
|
|
47
|
+
"timestamp": row.get("timestamp", row.get("date", "")),
|
|
48
|
+
"type": row.get("type", ""),
|
|
49
|
+
"amount": row.get("amount", ""),
|
|
50
|
+
"reason": row.get("reason", ""),
|
|
51
|
+
})
|
|
52
|
+
render(normalized, columns, ctx.obj)
|
|
53
|
+
except CliApiError as e:
|
|
54
|
+
handle_error(e)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Manage GAM campaigns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from orbiads_cli.client import CliApiError, get_client
|
|
8
|
+
from orbiads_cli.errors import handle_error
|
|
9
|
+
from orbiads_cli.output import OutputContext, confirm, info, render, render_detail, success
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Manage GAM campaigns", no_args_is_help=True)
|
|
12
|
+
|
|
13
|
+
_LIST_COLUMNS = ["id", "name", "status", "createdAt"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command("list")
|
|
17
|
+
def list_campaigns(
|
|
18
|
+
ctx: typer.Context,
|
|
19
|
+
status: str = typer.Option(None, "--status", help="Filter by status (comma-separated, e.g. draft,deployed)"),
|
|
20
|
+
limit: int = typer.Option(None, "--limit", help="Max number of campaigns to return"),
|
|
21
|
+
):
|
|
22
|
+
"""List campaigns."""
|
|
23
|
+
try:
|
|
24
|
+
client = get_client()
|
|
25
|
+
params: dict[str, str | int] = {}
|
|
26
|
+
if status is not None:
|
|
27
|
+
params["status"] = status
|
|
28
|
+
if limit is not None:
|
|
29
|
+
params["limit"] = limit
|
|
30
|
+
data = client.get("/api/campaigns", params=params)
|
|
31
|
+
out: OutputContext = ctx.obj
|
|
32
|
+
render(data, _LIST_COLUMNS, out)
|
|
33
|
+
except CliApiError as e:
|
|
34
|
+
handle_error(e)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command()
|
|
38
|
+
def get(
|
|
39
|
+
ctx: typer.Context,
|
|
40
|
+
campaign_id: str = typer.Argument(..., help="Campaign ID"),
|
|
41
|
+
):
|
|
42
|
+
"""Get campaign details."""
|
|
43
|
+
try:
|
|
44
|
+
client = get_client()
|
|
45
|
+
data = client.get(f"/api/campaigns/{campaign_id}")
|
|
46
|
+
out: OutputContext = ctx.obj
|
|
47
|
+
render_detail(data, out)
|
|
48
|
+
except CliApiError as e:
|
|
49
|
+
handle_error(e)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command()
|
|
53
|
+
def deploy(
|
|
54
|
+
ctx: typer.Context,
|
|
55
|
+
campaign_id: str = typer.Argument(..., help="Campaign ID"),
|
|
56
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
57
|
+
):
|
|
58
|
+
"""Deploy a draft campaign to GAM."""
|
|
59
|
+
try:
|
|
60
|
+
out: OutputContext = ctx.obj
|
|
61
|
+
# Merge local --yes with global --yes
|
|
62
|
+
effective_ctx = OutputContext(format=out.format, yes=out.yes or yes)
|
|
63
|
+
if not confirm(f"Deploy campaign {campaign_id}?", effective_ctx):
|
|
64
|
+
raise typer.Exit(code=0)
|
|
65
|
+
|
|
66
|
+
info(f"Deploying campaign {campaign_id}...")
|
|
67
|
+
client = get_client()
|
|
68
|
+
client.post(f"/api/campaigns/{campaign_id}/deploy")
|
|
69
|
+
success(f"Campaign {campaign_id} deployed successfully.")
|
|
70
|
+
except CliApiError as e:
|
|
71
|
+
handle_error(e)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command()
|
|
75
|
+
def pause(
|
|
76
|
+
ctx: typer.Context,
|
|
77
|
+
campaign_id: str = typer.Argument(..., help="Campaign ID"),
|
|
78
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
79
|
+
):
|
|
80
|
+
"""Pause a deployed campaign."""
|
|
81
|
+
try:
|
|
82
|
+
out: OutputContext = ctx.obj
|
|
83
|
+
effective_ctx = OutputContext(format=out.format, yes=out.yes or yes)
|
|
84
|
+
if not confirm(f"Pause campaign {campaign_id}?", effective_ctx):
|
|
85
|
+
raise typer.Exit(code=0)
|
|
86
|
+
|
|
87
|
+
client = get_client()
|
|
88
|
+
client.post(f"/api/campaigns/{campaign_id}/pause")
|
|
89
|
+
success(f"Campaign {campaign_id} paused successfully.")
|
|
90
|
+
except CliApiError as e:
|
|
91
|
+
handle_error(e)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.command()
|
|
95
|
+
def archive(
|
|
96
|
+
ctx: typer.Context,
|
|
97
|
+
campaign_id: str = typer.Argument(..., help="Campaign ID"),
|
|
98
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
99
|
+
):
|
|
100
|
+
"""Archive a campaign."""
|
|
101
|
+
try:
|
|
102
|
+
out: OutputContext = ctx.obj
|
|
103
|
+
effective_ctx = OutputContext(format=out.format, yes=out.yes or yes)
|
|
104
|
+
if not confirm(f"Archive campaign {campaign_id}?", effective_ctx):
|
|
105
|
+
raise typer.Exit(code=0)
|
|
106
|
+
|
|
107
|
+
client = get_client()
|
|
108
|
+
client.post(f"/api/campaigns/{campaign_id}/archive")
|
|
109
|
+
success(f"Campaign {campaign_id} archived successfully.")
|
|
110
|
+
except CliApiError as e:
|
|
111
|
+
handle_error(e)
|