applied-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.
- applied_cli/__init__.py +2 -0
- applied_cli/auth_store.py +263 -0
- applied_cli/commands/__init__.py +2 -0
- applied_cli/commands/_hints.py +11 -0
- applied_cli/commands/_normalize.py +79 -0
- applied_cli/commands/_parsers.py +58 -0
- applied_cli/commands/_ui.py +33 -0
- applied_cli/commands/agent.py +1231 -0
- applied_cli/commands/auth.py +739 -0
- applied_cli/commands/chat.py +379 -0
- applied_cli/commands/coverage.py +348 -0
- applied_cli/commands/discover.py +1006 -0
- applied_cli/commands/fix.py +1204 -0
- applied_cli/commands/insights.py +614 -0
- applied_cli/commands/intents.py +447 -0
- applied_cli/commands/rate.py +508 -0
- applied_cli/commands/responses.py +604 -0
- applied_cli/commands/shop.py +1757 -0
- applied_cli/commands/simulate.py +330 -0
- applied_cli/commands/spec.py +238 -0
- applied_cli/config.py +50 -0
- applied_cli/error_reporting.py +38 -0
- applied_cli/http.py +1614 -0
- applied_cli/main.py +90 -0
- applied_cli/mcp_server.py +738 -0
- applied_cli/presets/demo.yaml +170 -0
- applied_cli/runtime.py +53 -0
- applied_cli/shop_spec.py +398 -0
- applied_cli/spec_workflow.py +432 -0
- applied_cli-0.1.0.dist-info/METADATA +176 -0
- applied_cli-0.1.0.dist-info/RECORD +34 -0
- applied_cli-0.1.0.dist-info/WHEEL +5 -0
- applied_cli-0.1.0.dist-info/entry_points.txt +3 -0
- applied_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
import webbrowser
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from applied_cli.auth_store import (
|
|
12
|
+
clear_credentials,
|
|
13
|
+
get_active_profile_name,
|
|
14
|
+
list_profiles,
|
|
15
|
+
load_credentials,
|
|
16
|
+
save_credentials,
|
|
17
|
+
)
|
|
18
|
+
from applied_cli.config import (
|
|
19
|
+
DEFAULT_BASE_URL,
|
|
20
|
+
Credentials,
|
|
21
|
+
mask_token,
|
|
22
|
+
normalize_base_url,
|
|
23
|
+
)
|
|
24
|
+
from applied_cli.error_reporting import render_api_error
|
|
25
|
+
from applied_cli.http import (
|
|
26
|
+
APIError,
|
|
27
|
+
list_accessible_shops,
|
|
28
|
+
poll_cli_device_login,
|
|
29
|
+
start_cli_device_login,
|
|
30
|
+
validate_api_token,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(
|
|
34
|
+
help=(
|
|
35
|
+
"Authenticate applied-cli with an API token.\n\n"
|
|
36
|
+
"Login requires a human to approve in the browser:\n"
|
|
37
|
+
" applied-cli auth login\n\n"
|
|
38
|
+
"After login, check status and switch shops:\n"
|
|
39
|
+
" applied-cli auth status # who you're logged in as and which shop is active\n"
|
|
40
|
+
" applied-cli auth shops # list all shops your token can access\n"
|
|
41
|
+
" applied-cli auth use-shop \"<name>\" # switch the active shop"
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _select_shop_interactively(shops: list[dict[str, object]]) -> tuple[str, str]:
|
|
47
|
+
"""Return (shop_id, shop_name) after prompting the user to pick a shop."""
|
|
48
|
+
if not shops:
|
|
49
|
+
raise typer.BadParameter(
|
|
50
|
+
"No accessible shops returned for this token. Pass --shop-id explicitly."
|
|
51
|
+
)
|
|
52
|
+
if len(shops) == 1:
|
|
53
|
+
only = shops[0]
|
|
54
|
+
selected = str(only.get("id") or "")
|
|
55
|
+
name = str(only.get("name") or "(unnamed)")
|
|
56
|
+
if selected:
|
|
57
|
+
typer.echo(f"Using shop: {name} ({selected})")
|
|
58
|
+
return selected, name
|
|
59
|
+
raise typer.BadParameter(
|
|
60
|
+
"Shop response did not include an id. Pass --shop-id explicitly."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
typer.echo("Select a default shop:")
|
|
64
|
+
for idx, shop in enumerate(shops, start=1):
|
|
65
|
+
shop_id = str(shop.get("id") or "")
|
|
66
|
+
name = str(shop.get("name") or "(unnamed)")
|
|
67
|
+
typer.echo(f"{idx}) {name} ({shop_id})")
|
|
68
|
+
choice = typer.prompt("Enter number", default="1").strip()
|
|
69
|
+
try:
|
|
70
|
+
selected_index = int(choice)
|
|
71
|
+
except ValueError as exc:
|
|
72
|
+
raise typer.BadParameter("Selection must be a number.") from exc
|
|
73
|
+
if selected_index < 1 or selected_index > len(shops):
|
|
74
|
+
raise typer.BadParameter("Selection out of range.")
|
|
75
|
+
selected_shop = shops[selected_index - 1]
|
|
76
|
+
selected_shop_id = str(selected_shop.get("id") or "")
|
|
77
|
+
selected_shop_name = str(selected_shop.get("name") or "(unnamed)")
|
|
78
|
+
if not selected_shop_id:
|
|
79
|
+
raise typer.BadParameter(
|
|
80
|
+
"Selected shop does not include an id. Pass --shop-id explicitly."
|
|
81
|
+
)
|
|
82
|
+
return selected_shop_id, selected_shop_name
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _resolve_base_url_for_auth(
|
|
86
|
+
*, endpoint: Optional[str], base_url: Optional[str], existing: Optional[Credentials]
|
|
87
|
+
) -> str:
|
|
88
|
+
raw_base_url = (
|
|
89
|
+
base_url
|
|
90
|
+
or endpoint
|
|
91
|
+
or os.getenv("APPLIED_BASE_URL")
|
|
92
|
+
or os.getenv("APPLIED_ENDPOINT")
|
|
93
|
+
or (existing.base_url if existing else "")
|
|
94
|
+
or DEFAULT_BASE_URL
|
|
95
|
+
)
|
|
96
|
+
return normalize_base_url(raw_base_url)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _looks_like_uuid(value: str) -> bool:
|
|
100
|
+
try:
|
|
101
|
+
uuid.UUID(value)
|
|
102
|
+
return True
|
|
103
|
+
except ValueError:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _resolve_profile_name(profile: Optional[str]) -> str:
|
|
108
|
+
return (profile or os.getenv("APPLIED_PROFILE") or get_active_profile_name() or "default").strip()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _default_token_page_url(base_url: str) -> str:
|
|
112
|
+
parsed = urlsplit(base_url)
|
|
113
|
+
host = parsed.hostname or ""
|
|
114
|
+
if host in {"localhost", "127.0.0.1"}:
|
|
115
|
+
local_host = "localhost" if host == "localhost" else "127.0.0.1"
|
|
116
|
+
return f"http://{local_host}:3000/settings/account/api-tokens"
|
|
117
|
+
return urlunsplit((parsed.scheme, parsed.netloc, "/settings/account/api-tokens", "", ""))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@app.command("login")
|
|
121
|
+
def login(
|
|
122
|
+
endpoint: Optional[str] = typer.Option(
|
|
123
|
+
None,
|
|
124
|
+
"--endpoint",
|
|
125
|
+
help="Endpoint alias or URL: prod, dev, local, or full host.",
|
|
126
|
+
envvar="APPLIED_ENDPOINT",
|
|
127
|
+
),
|
|
128
|
+
shop_id: Optional[str] = typer.Option(
|
|
129
|
+
None, "--shop-id", help="Pre-select a shop UUID instead of prompting.", envvar="APPLIED_SHOP_ID"
|
|
130
|
+
),
|
|
131
|
+
token: Optional[str] = typer.Option(
|
|
132
|
+
None, "--token", help="Skip browser auth and use this API token directly.", envvar="APPLIED_API_TOKEN"
|
|
133
|
+
),
|
|
134
|
+
output_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
135
|
+
# Hidden power-user / compat options
|
|
136
|
+
profile: Optional[str] = typer.Option(None, "--profile", envvar="APPLIED_PROFILE", hidden=True),
|
|
137
|
+
base_url: Optional[str] = typer.Option(None, envvar="APPLIED_BASE_URL", hidden=True),
|
|
138
|
+
token_page_url: Optional[str] = typer.Option(None, envvar="APPLIED_TOKEN_PAGE_URL", hidden=True),
|
|
139
|
+
device: bool = typer.Option(True, "--device/--no-device", hidden=True),
|
|
140
|
+
no_browser: bool = typer.Option(False, "--no-browser", hidden=True),
|
|
141
|
+
device_timeout: float = typer.Option(180.0, hidden=True),
|
|
142
|
+
poll_interval: float = typer.Option(2.0, hidden=True),
|
|
143
|
+
timeout: float = typer.Option(10.0, hidden=True),
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Sign in or switch to a different shop — opens a browser for human approval, then saves credentials."""
|
|
146
|
+
profile_name = _resolve_profile_name(profile)
|
|
147
|
+
existing = load_credentials(profile=profile_name)
|
|
148
|
+
resolved_base_url = _resolve_base_url_for_auth(
|
|
149
|
+
endpoint=endpoint,
|
|
150
|
+
base_url=base_url,
|
|
151
|
+
existing=existing,
|
|
152
|
+
)
|
|
153
|
+
explicit_shop_id = shop_id or os.getenv("APPLIED_SHOP_ID")
|
|
154
|
+
resolved_shop_id = explicit_shop_id or ""
|
|
155
|
+
resolved_shop_name = ""
|
|
156
|
+
|
|
157
|
+
token_page = token_page_url or _default_token_page_url(resolved_base_url)
|
|
158
|
+
browser_opened = False
|
|
159
|
+
resolved_token = token or os.getenv("APPLIED_API_TOKEN")
|
|
160
|
+
if not resolved_token and device:
|
|
161
|
+
try:
|
|
162
|
+
device_start = start_cli_device_login(
|
|
163
|
+
base_url=resolved_base_url,
|
|
164
|
+
timeout_seconds=timeout,
|
|
165
|
+
)
|
|
166
|
+
except APIError as exc:
|
|
167
|
+
if exc.status_code == 404:
|
|
168
|
+
if output_json:
|
|
169
|
+
typer.echo(
|
|
170
|
+
json.dumps(
|
|
171
|
+
{
|
|
172
|
+
"error": "endpoint_not_found",
|
|
173
|
+
"message": f"Device login endpoint not found at {resolved_base_url}.",
|
|
174
|
+
"hint": (
|
|
175
|
+
"Specify an endpoint: "
|
|
176
|
+
"applied-cli auth login --endpoint prod "
|
|
177
|
+
"or applied-cli auth login --endpoint local"
|
|
178
|
+
),
|
|
179
|
+
},
|
|
180
|
+
indent=2,
|
|
181
|
+
),
|
|
182
|
+
err=True,
|
|
183
|
+
)
|
|
184
|
+
else:
|
|
185
|
+
typer.echo(
|
|
186
|
+
f"Device login endpoint not found at {resolved_base_url}.\n"
|
|
187
|
+
"Is the endpoint correct? Try:\n"
|
|
188
|
+
" applied-cli auth login --endpoint prod\n"
|
|
189
|
+
" applied-cli auth login --endpoint local",
|
|
190
|
+
err=True,
|
|
191
|
+
)
|
|
192
|
+
else:
|
|
193
|
+
typer.echo(render_api_error(exc, action="start device login"), err=True)
|
|
194
|
+
raise typer.Exit(code=1) from exc
|
|
195
|
+
token_page = str(
|
|
196
|
+
device_start.get("verification_uri_complete")
|
|
197
|
+
or device_start.get("verification_uri")
|
|
198
|
+
or device_start.get("verification_url")
|
|
199
|
+
or token_page
|
|
200
|
+
).strip()
|
|
201
|
+
device_code = str(device_start.get("device_code") or "").strip()
|
|
202
|
+
user_code = str(device_start.get("user_code") or "").strip()
|
|
203
|
+
expires_in = int(device_start.get("expires_in") or 180)
|
|
204
|
+
poll_seconds = float(device_start.get("interval") or poll_interval or 2.0)
|
|
205
|
+
if output_json:
|
|
206
|
+
# Emit approval info immediately so an agent can relay URL+code to the
|
|
207
|
+
# human before blocking on the poll loop. Final result is a second JSON line.
|
|
208
|
+
typer.echo(
|
|
209
|
+
json.dumps(
|
|
210
|
+
{
|
|
211
|
+
"status": "pending_approval",
|
|
212
|
+
"approval_url": token_page,
|
|
213
|
+
"user_code": user_code or None,
|
|
214
|
+
"expires_in": expires_in,
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
typer.echo(f"Approval URL: {token_page}")
|
|
220
|
+
if user_code:
|
|
221
|
+
typer.echo(f"Verification code: {user_code}")
|
|
222
|
+
typer.echo("Enter this code in the browser when prompted.")
|
|
223
|
+
if no_browser:
|
|
224
|
+
pass # URL already printed above
|
|
225
|
+
else:
|
|
226
|
+
browser_opened = webbrowser.open(token_page)
|
|
227
|
+
if not output_json:
|
|
228
|
+
if browser_opened:
|
|
229
|
+
typer.echo("(Browser opened automatically.)")
|
|
230
|
+
else:
|
|
231
|
+
typer.echo("(Could not open browser — open the URL above manually.)")
|
|
232
|
+
if not device_code:
|
|
233
|
+
err = APIError(
|
|
234
|
+
"Device login start response missing device code.",
|
|
235
|
+
code="DEVICE_LOGIN_INVALID_RESPONSE",
|
|
236
|
+
hint="Retry `applied-cli auth login`.",
|
|
237
|
+
retryable=True,
|
|
238
|
+
)
|
|
239
|
+
typer.echo(render_api_error(err, action="start device login"), err=True)
|
|
240
|
+
raise typer.Exit(code=1)
|
|
241
|
+
if not output_json:
|
|
242
|
+
typer.echo("Waiting for browser approval...")
|
|
243
|
+
deadline = time.monotonic() + min(device_timeout, float(expires_in))
|
|
244
|
+
_auth_start = time.monotonic()
|
|
245
|
+
_last_progress = _auth_start
|
|
246
|
+
while time.monotonic() < deadline:
|
|
247
|
+
try:
|
|
248
|
+
poll = poll_cli_device_login(
|
|
249
|
+
base_url=resolved_base_url,
|
|
250
|
+
device_code=device_code,
|
|
251
|
+
timeout_seconds=timeout,
|
|
252
|
+
)
|
|
253
|
+
except APIError as exc:
|
|
254
|
+
typer.echo(render_api_error(exc, action="poll device login"), err=True)
|
|
255
|
+
raise typer.Exit(code=1) from exc
|
|
256
|
+
status_value = str(poll.get("status") or "pending").strip().lower()
|
|
257
|
+
if status_value == "approved":
|
|
258
|
+
resolved_token = str(poll.get("api_token") or "").strip()
|
|
259
|
+
discovered_shop_id = str(poll.get("shop_id") or "").strip()
|
|
260
|
+
if discovered_shop_id and not resolved_shop_id:
|
|
261
|
+
resolved_shop_id = discovered_shop_id
|
|
262
|
+
break
|
|
263
|
+
if status_value in {"expired", "denied"}:
|
|
264
|
+
err = APIError(
|
|
265
|
+
f"Device login {status_value}.",
|
|
266
|
+
code=f"DEVICE_LOGIN_{status_value.upper()}",
|
|
267
|
+
hint="Run `applied-cli auth login` and approve in browser again.",
|
|
268
|
+
retryable=False,
|
|
269
|
+
)
|
|
270
|
+
typer.echo(render_api_error(err, action="log in"), err=True)
|
|
271
|
+
raise typer.Exit(code=1)
|
|
272
|
+
_now = time.monotonic()
|
|
273
|
+
if not output_json and _now - _last_progress >= 15:
|
|
274
|
+
_elapsed = int(_now - _auth_start)
|
|
275
|
+
typer.echo(
|
|
276
|
+
f"Still waiting for browser approval... ({_elapsed}s elapsed)"
|
|
277
|
+
)
|
|
278
|
+
_last_progress = _now
|
|
279
|
+
time.sleep(max(0.5, poll_seconds))
|
|
280
|
+
if not resolved_token:
|
|
281
|
+
err = APIError(
|
|
282
|
+
"Timed out waiting for browser approval.",
|
|
283
|
+
code="DEVICE_LOGIN_TIMEOUT",
|
|
284
|
+
hint="Approve login in browser sooner or increase --device-timeout.",
|
|
285
|
+
retryable=True,
|
|
286
|
+
)
|
|
287
|
+
typer.echo(render_api_error(err, action="log in"), err=True)
|
|
288
|
+
raise typer.Exit(code=1)
|
|
289
|
+
elif not resolved_token:
|
|
290
|
+
if no_browser:
|
|
291
|
+
if not output_json:
|
|
292
|
+
typer.echo(f"Open this page to create a token:\n{token_page}")
|
|
293
|
+
else:
|
|
294
|
+
browser_opened = webbrowser.open(token_page)
|
|
295
|
+
if not output_json:
|
|
296
|
+
if browser_opened:
|
|
297
|
+
typer.echo(f"Opened token page:\n{token_page}")
|
|
298
|
+
else:
|
|
299
|
+
typer.echo(f"Could not open browser. Create token here:\n{token_page}")
|
|
300
|
+
resolved_token = typer.prompt(
|
|
301
|
+
"Paste API token",
|
|
302
|
+
hide_input=True,
|
|
303
|
+
confirmation_prompt=False,
|
|
304
|
+
).strip()
|
|
305
|
+
|
|
306
|
+
# Best-effort: if we already have a shop_id but no name (e.g. from device auth poll),
|
|
307
|
+
# try to resolve the name from the accessible-shops list now that we have a token.
|
|
308
|
+
if resolved_shop_id and not resolved_shop_name:
|
|
309
|
+
try:
|
|
310
|
+
shops_for_name = list_accessible_shops(
|
|
311
|
+
base_url=resolved_base_url,
|
|
312
|
+
api_token=resolved_token,
|
|
313
|
+
timeout_seconds=timeout,
|
|
314
|
+
)
|
|
315
|
+
name_match = next(
|
|
316
|
+
(r for r in shops_for_name if str(r.get("id")) == resolved_shop_id), None
|
|
317
|
+
)
|
|
318
|
+
if name_match:
|
|
319
|
+
resolved_shop_name = str(name_match.get("name") or "")
|
|
320
|
+
except APIError:
|
|
321
|
+
pass # Name is nice-to-have; continue without it
|
|
322
|
+
|
|
323
|
+
used_existing_shop_fallback = False
|
|
324
|
+
if not resolved_shop_id:
|
|
325
|
+
try:
|
|
326
|
+
shops = list_accessible_shops(
|
|
327
|
+
base_url=resolved_base_url,
|
|
328
|
+
api_token=resolved_token,
|
|
329
|
+
timeout_seconds=timeout,
|
|
330
|
+
)
|
|
331
|
+
except APIError:
|
|
332
|
+
shops = []
|
|
333
|
+
if shops:
|
|
334
|
+
if output_json:
|
|
335
|
+
if len(shops) == 1 and shops[0].get("id"):
|
|
336
|
+
resolved_shop_id = str(shops[0]["id"])
|
|
337
|
+
resolved_shop_name = str(shops[0].get("name") or "")
|
|
338
|
+
else:
|
|
339
|
+
raise APIError(
|
|
340
|
+
"Multiple shops available; choose one with --shop-id.",
|
|
341
|
+
code="MISSING_SHOP_ID",
|
|
342
|
+
hint="Pass --shop-id explicitly or run interactive auth login.",
|
|
343
|
+
retryable=False,
|
|
344
|
+
)
|
|
345
|
+
else:
|
|
346
|
+
resolved_shop_id, resolved_shop_name = _select_shop_interactively(shops)
|
|
347
|
+
if not resolved_shop_id:
|
|
348
|
+
# Last resort fallback: use profile default if available, else prompt.
|
|
349
|
+
if existing and existing.shop_id:
|
|
350
|
+
resolved_shop_id = existing.shop_id
|
|
351
|
+
used_existing_shop_fallback = True
|
|
352
|
+
if not output_json:
|
|
353
|
+
typer.echo(
|
|
354
|
+
f"Using existing profile default shop: {resolved_shop_id}"
|
|
355
|
+
)
|
|
356
|
+
else:
|
|
357
|
+
resolved_shop_id = typer.prompt("Shop ID").strip()
|
|
358
|
+
|
|
359
|
+
if not output_json:
|
|
360
|
+
typer.echo("Validating token against Applied API...")
|
|
361
|
+
try:
|
|
362
|
+
validate_api_token(
|
|
363
|
+
base_url=resolved_base_url,
|
|
364
|
+
shop_id=resolved_shop_id,
|
|
365
|
+
api_token=resolved_token,
|
|
366
|
+
timeout_seconds=timeout,
|
|
367
|
+
)
|
|
368
|
+
except APIError as exc:
|
|
369
|
+
# Common local UX issue: stored default shop does not match pasted token.
|
|
370
|
+
# If we auto-selected the existing shop and auth fails, prompt once for a new shop.
|
|
371
|
+
if (
|
|
372
|
+
not output_json
|
|
373
|
+
and used_existing_shop_fallback
|
|
374
|
+
and exc.status_code in {401, 403}
|
|
375
|
+
and not explicit_shop_id
|
|
376
|
+
):
|
|
377
|
+
typer.echo(
|
|
378
|
+
"Token is not valid for the current default shop. "
|
|
379
|
+
"Please enter a shop ID for this token."
|
|
380
|
+
)
|
|
381
|
+
resolved_shop_id = typer.prompt("Shop ID").strip()
|
|
382
|
+
if not resolved_shop_id:
|
|
383
|
+
typer.echo(render_api_error(exc, action="log in"), err=True)
|
|
384
|
+
raise typer.Exit(code=1) from exc
|
|
385
|
+
if not output_json:
|
|
386
|
+
typer.echo("Re-validating token with provided shop...")
|
|
387
|
+
try:
|
|
388
|
+
validate_api_token(
|
|
389
|
+
base_url=resolved_base_url,
|
|
390
|
+
shop_id=resolved_shop_id,
|
|
391
|
+
api_token=resolved_token,
|
|
392
|
+
timeout_seconds=timeout,
|
|
393
|
+
)
|
|
394
|
+
except APIError as retry_exc:
|
|
395
|
+
typer.echo(render_api_error(retry_exc, action="log in"), err=True)
|
|
396
|
+
raise typer.Exit(code=1) from retry_exc
|
|
397
|
+
else:
|
|
398
|
+
typer.echo(render_api_error(exc, action="log in"), err=True)
|
|
399
|
+
raise typer.Exit(code=1) from exc
|
|
400
|
+
|
|
401
|
+
save_credentials(
|
|
402
|
+
Credentials(
|
|
403
|
+
base_url=resolved_base_url,
|
|
404
|
+
shop_id=resolved_shop_id,
|
|
405
|
+
api_token=resolved_token,
|
|
406
|
+
),
|
|
407
|
+
profile=profile_name,
|
|
408
|
+
set_active=True,
|
|
409
|
+
)
|
|
410
|
+
shop_label = (
|
|
411
|
+
f"{resolved_shop_name} ({resolved_shop_id})" if resolved_shop_name else resolved_shop_id
|
|
412
|
+
)
|
|
413
|
+
if output_json:
|
|
414
|
+
_login_result: dict = {
|
|
415
|
+
"logged_in": True,
|
|
416
|
+
"endpoint": resolved_base_url,
|
|
417
|
+
"shop_id": resolved_shop_id,
|
|
418
|
+
"token_masked": mask_token(resolved_token),
|
|
419
|
+
}
|
|
420
|
+
if resolved_shop_name:
|
|
421
|
+
_login_result["shop_name"] = resolved_shop_name
|
|
422
|
+
typer.echo(json.dumps(_login_result, indent=2))
|
|
423
|
+
return
|
|
424
|
+
typer.echo("Login successful.")
|
|
425
|
+
typer.echo(f"- shop: {shop_label}")
|
|
426
|
+
typer.echo(f"- endpoint: {resolved_base_url}")
|
|
427
|
+
typer.echo(f"- token: {mask_token(resolved_token)}")
|
|
428
|
+
typer.echo("")
|
|
429
|
+
typer.echo("To see all shops: applied-cli auth shops")
|
|
430
|
+
typer.echo("To switch shops: applied-cli auth use-shop \"<shop name or uuid>\"")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@app.command("status")
|
|
434
|
+
def status(
|
|
435
|
+
output_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
436
|
+
# Hidden options
|
|
437
|
+
profile: Optional[str] = typer.Option(None, "--profile", envvar="APPLIED_PROFILE", hidden=True),
|
|
438
|
+
verify: bool = typer.Option(True, "--verify/--no-verify", hidden=True),
|
|
439
|
+
timeout: float = typer.Option(10.0, hidden=True),
|
|
440
|
+
) -> None:
|
|
441
|
+
"""Show current authentication state and active shop."""
|
|
442
|
+
profile_name = _resolve_profile_name(profile)
|
|
443
|
+
creds = load_credentials(profile=profile_name)
|
|
444
|
+
if not creds:
|
|
445
|
+
if output_json:
|
|
446
|
+
typer.echo(
|
|
447
|
+
json.dumps(
|
|
448
|
+
{"logged_in": False, "verified": False, "message": "Not logged in."},
|
|
449
|
+
indent=2,
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
else:
|
|
453
|
+
typer.echo("Not logged in. Run: applied-cli auth login")
|
|
454
|
+
raise typer.Exit(code=1)
|
|
455
|
+
|
|
456
|
+
if not output_json:
|
|
457
|
+
typer.echo("Logged in:")
|
|
458
|
+
typer.echo(f"- endpoint: {creds.base_url}")
|
|
459
|
+
typer.echo(f"- shop_id: {creds.shop_id}")
|
|
460
|
+
typer.echo(f"- token: {mask_token(creds.api_token)}")
|
|
461
|
+
|
|
462
|
+
if not verify:
|
|
463
|
+
if output_json:
|
|
464
|
+
typer.echo(
|
|
465
|
+
json.dumps(
|
|
466
|
+
{
|
|
467
|
+
"logged_in": True,
|
|
468
|
+
"endpoint": creds.base_url,
|
|
469
|
+
"shop_id": creds.shop_id,
|
|
470
|
+
"token_masked": mask_token(creds.api_token),
|
|
471
|
+
"verified": False,
|
|
472
|
+
},
|
|
473
|
+
indent=2,
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
return
|
|
477
|
+
|
|
478
|
+
if not output_json:
|
|
479
|
+
typer.echo("Verifying token...")
|
|
480
|
+
try:
|
|
481
|
+
validate_api_token(
|
|
482
|
+
base_url=creds.base_url,
|
|
483
|
+
shop_id=creds.shop_id,
|
|
484
|
+
api_token=creds.api_token,
|
|
485
|
+
timeout_seconds=timeout,
|
|
486
|
+
)
|
|
487
|
+
except APIError as exc:
|
|
488
|
+
typer.echo(render_api_error(exc, action="check auth status"), err=True)
|
|
489
|
+
raise typer.Exit(code=1) from exc
|
|
490
|
+
|
|
491
|
+
if output_json:
|
|
492
|
+
typer.echo(
|
|
493
|
+
json.dumps(
|
|
494
|
+
{
|
|
495
|
+
"logged_in": True,
|
|
496
|
+
"endpoint": creds.base_url,
|
|
497
|
+
"shop_id": creds.shop_id,
|
|
498
|
+
"token_masked": mask_token(creds.api_token),
|
|
499
|
+
"verified": True,
|
|
500
|
+
},
|
|
501
|
+
indent=2,
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
return
|
|
505
|
+
typer.echo("Token is valid.")
|
|
506
|
+
typer.echo("")
|
|
507
|
+
typer.echo("To see all shops: applied-cli auth shops")
|
|
508
|
+
typer.echo("To switch shops: applied-cli auth use-shop \"<shop name or uuid>\"")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
@app.command("shops")
|
|
512
|
+
def shops(
|
|
513
|
+
output_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
514
|
+
# Hidden options
|
|
515
|
+
profile: Optional[str] = typer.Option(None, "--profile", envvar="APPLIED_PROFILE", hidden=True),
|
|
516
|
+
endpoint: Optional[str] = typer.Option(None, "--endpoint", envvar="APPLIED_ENDPOINT", hidden=True),
|
|
517
|
+
base_url: Optional[str] = typer.Option(None, envvar="APPLIED_BASE_URL", hidden=True),
|
|
518
|
+
token: Optional[str] = typer.Option(None, "--token", envvar="APPLIED_API_TOKEN", hidden=True),
|
|
519
|
+
timeout: float = typer.Option(10.0, hidden=True),
|
|
520
|
+
) -> None:
|
|
521
|
+
"""List all shops your token can access."""
|
|
522
|
+
profile_name = _resolve_profile_name(profile)
|
|
523
|
+
creds = load_credentials(profile=profile_name)
|
|
524
|
+
resolved_base_url = _resolve_base_url_for_auth(
|
|
525
|
+
endpoint=endpoint,
|
|
526
|
+
base_url=base_url,
|
|
527
|
+
existing=creds,
|
|
528
|
+
)
|
|
529
|
+
resolved_token = token or os.getenv("APPLIED_API_TOKEN") or (
|
|
530
|
+
creds.api_token if creds else ""
|
|
531
|
+
)
|
|
532
|
+
if not resolved_token:
|
|
533
|
+
raise typer.BadParameter(
|
|
534
|
+
"Not logged in. Run `applied-cli auth login` first."
|
|
535
|
+
)
|
|
536
|
+
try:
|
|
537
|
+
shops_rows = list_accessible_shops(
|
|
538
|
+
base_url=resolved_base_url,
|
|
539
|
+
api_token=resolved_token,
|
|
540
|
+
timeout_seconds=timeout,
|
|
541
|
+
)
|
|
542
|
+
except APIError as exc:
|
|
543
|
+
if exc.status_code in {401, 403}:
|
|
544
|
+
if output_json:
|
|
545
|
+
typer.echo(
|
|
546
|
+
json.dumps(
|
|
547
|
+
{
|
|
548
|
+
"error": "permission_denied",
|
|
549
|
+
"message": "Your token does not have permission to list shops.",
|
|
550
|
+
"hint": (
|
|
551
|
+
"To switch shops, re-authenticate: applied-cli auth login "
|
|
552
|
+
"or if you know the shop UUID: applied-cli auth use-shop <uuid>"
|
|
553
|
+
),
|
|
554
|
+
},
|
|
555
|
+
indent=2,
|
|
556
|
+
),
|
|
557
|
+
err=True,
|
|
558
|
+
)
|
|
559
|
+
else:
|
|
560
|
+
typer.echo(
|
|
561
|
+
"Your token does not have permission to list shops.\n"
|
|
562
|
+
"To switch shops, re-authenticate and select the shop in the browser:\n"
|
|
563
|
+
" applied-cli auth login\n"
|
|
564
|
+
"Or if you know the shop UUID:\n"
|
|
565
|
+
" applied-cli auth use-shop <uuid>",
|
|
566
|
+
err=True,
|
|
567
|
+
)
|
|
568
|
+
else:
|
|
569
|
+
typer.echo(render_api_error(exc, action="list accessible shops"), err=True)
|
|
570
|
+
raise typer.Exit(code=1) from exc
|
|
571
|
+
|
|
572
|
+
if output_json:
|
|
573
|
+
typer.echo(
|
|
574
|
+
json.dumps(
|
|
575
|
+
{
|
|
576
|
+
"endpoint": resolved_base_url,
|
|
577
|
+
"count": len(shops_rows),
|
|
578
|
+
"shops": shops_rows,
|
|
579
|
+
},
|
|
580
|
+
indent=2,
|
|
581
|
+
)
|
|
582
|
+
)
|
|
583
|
+
return
|
|
584
|
+
if not shops_rows:
|
|
585
|
+
typer.echo("No accessible shops returned for this token.")
|
|
586
|
+
return
|
|
587
|
+
current_shop_id = creds.shop_id if creds else ""
|
|
588
|
+
for row in shops_rows:
|
|
589
|
+
active_marker = " (active)" if str(row.get("id")) == current_shop_id else ""
|
|
590
|
+
typer.echo(f"id={row.get('id')} | name={row.get('name') or '(unnamed)'}{active_marker}")
|
|
591
|
+
typer.echo("")
|
|
592
|
+
typer.echo("To switch: applied-cli auth use-shop \"<shop name or uuid>\"")
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
@app.command("use-shop")
|
|
596
|
+
def use_shop(
|
|
597
|
+
shop: str = typer.Argument(..., help="Shop name or UUID."),
|
|
598
|
+
output_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
599
|
+
# Hidden options
|
|
600
|
+
profile: Optional[str] = typer.Option(None, "--profile", envvar="APPLIED_PROFILE", hidden=True),
|
|
601
|
+
endpoint: Optional[str] = typer.Option(None, "--endpoint", envvar="APPLIED_ENDPOINT", hidden=True),
|
|
602
|
+
base_url: Optional[str] = typer.Option(None, envvar="APPLIED_BASE_URL", hidden=True),
|
|
603
|
+
token: Optional[str] = typer.Option(None, "--token", envvar="APPLIED_API_TOKEN", hidden=True),
|
|
604
|
+
timeout: float = typer.Option(10.0, hidden=True),
|
|
605
|
+
) -> None:
|
|
606
|
+
"""Switch the active shop by name or UUID."""
|
|
607
|
+
profile_name = _resolve_profile_name(profile)
|
|
608
|
+
existing = load_credentials(profile=profile_name)
|
|
609
|
+
if not existing and not token:
|
|
610
|
+
raise typer.BadParameter(
|
|
611
|
+
"Not logged in. Run `applied-cli auth login` first."
|
|
612
|
+
)
|
|
613
|
+
resolved_base_url = _resolve_base_url_for_auth(
|
|
614
|
+
endpoint=endpoint,
|
|
615
|
+
base_url=base_url,
|
|
616
|
+
existing=existing,
|
|
617
|
+
)
|
|
618
|
+
resolved_token = token or os.getenv("APPLIED_API_TOKEN") or (
|
|
619
|
+
existing.api_token if existing else ""
|
|
620
|
+
)
|
|
621
|
+
if not resolved_token:
|
|
622
|
+
raise typer.BadParameter("Not logged in. Run `applied-cli auth login` first.")
|
|
623
|
+
|
|
624
|
+
selected_shop_id = ""
|
|
625
|
+
selected_shop_name = ""
|
|
626
|
+
try:
|
|
627
|
+
shops_rows = list_accessible_shops(
|
|
628
|
+
base_url=resolved_base_url,
|
|
629
|
+
api_token=resolved_token,
|
|
630
|
+
timeout_seconds=timeout,
|
|
631
|
+
)
|
|
632
|
+
except APIError:
|
|
633
|
+
shops_rows = []
|
|
634
|
+
|
|
635
|
+
if shops_rows:
|
|
636
|
+
if _looks_like_uuid(shop):
|
|
637
|
+
matched = next((row for row in shops_rows if str(row.get("id")) == shop), None)
|
|
638
|
+
else:
|
|
639
|
+
matched = next(
|
|
640
|
+
(
|
|
641
|
+
row
|
|
642
|
+
for row in shops_rows
|
|
643
|
+
if str(row.get("name") or "").strip().lower() == shop.strip().lower()
|
|
644
|
+
),
|
|
645
|
+
None,
|
|
646
|
+
)
|
|
647
|
+
if matched:
|
|
648
|
+
selected_shop_id = str(matched.get("id") or "")
|
|
649
|
+
selected_shop_name = str(matched.get("name") or "")
|
|
650
|
+
else:
|
|
651
|
+
raise typer.BadParameter(
|
|
652
|
+
"Shop not found in accessible shops for this token. "
|
|
653
|
+
"Run `applied-cli auth shops` to see valid choices."
|
|
654
|
+
)
|
|
655
|
+
else:
|
|
656
|
+
if not _looks_like_uuid(shop):
|
|
657
|
+
raise typer.BadParameter(
|
|
658
|
+
"Could not fetch shops from API. Pass a shop UUID instead of name."
|
|
659
|
+
)
|
|
660
|
+
selected_shop_id = shop
|
|
661
|
+
|
|
662
|
+
# Validate the token works for the selected shop before committing the change.
|
|
663
|
+
try:
|
|
664
|
+
validate_api_token(
|
|
665
|
+
base_url=resolved_base_url,
|
|
666
|
+
shop_id=selected_shop_id,
|
|
667
|
+
api_token=resolved_token,
|
|
668
|
+
timeout_seconds=timeout,
|
|
669
|
+
)
|
|
670
|
+
except APIError as exc:
|
|
671
|
+
if exc.status_code in {401, 403}:
|
|
672
|
+
if output_json:
|
|
673
|
+
typer.echo(
|
|
674
|
+
json.dumps(
|
|
675
|
+
{
|
|
676
|
+
"error": "token_invalid_for_shop",
|
|
677
|
+
"message": f"Your current token is not valid for shop {selected_shop_id}.",
|
|
678
|
+
"hint": "Run `applied-cli auth login` to re-authenticate for a different shop.",
|
|
679
|
+
},
|
|
680
|
+
indent=2,
|
|
681
|
+
),
|
|
682
|
+
err=True,
|
|
683
|
+
)
|
|
684
|
+
else:
|
|
685
|
+
typer.echo(
|
|
686
|
+
f"Your current token is not valid for shop {selected_shop_id}.\n"
|
|
687
|
+
"To switch shops, re-authenticate and select the shop in the browser:\n"
|
|
688
|
+
" applied-cli auth login",
|
|
689
|
+
err=True,
|
|
690
|
+
)
|
|
691
|
+
else:
|
|
692
|
+
typer.echo(render_api_error(exc, action="validate shop access"), err=True)
|
|
693
|
+
raise typer.Exit(code=1) from exc
|
|
694
|
+
|
|
695
|
+
save_credentials(
|
|
696
|
+
Credentials(
|
|
697
|
+
base_url=resolved_base_url,
|
|
698
|
+
shop_id=selected_shop_id,
|
|
699
|
+
api_token=resolved_token,
|
|
700
|
+
),
|
|
701
|
+
profile=profile_name,
|
|
702
|
+
set_active=True,
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
shop_label = (
|
|
706
|
+
f"{selected_shop_name} ({selected_shop_id})" if selected_shop_name else selected_shop_id
|
|
707
|
+
)
|
|
708
|
+
if output_json:
|
|
709
|
+
_use_shop_result: dict = {
|
|
710
|
+
"updated": True,
|
|
711
|
+
"shop_id": selected_shop_id,
|
|
712
|
+
}
|
|
713
|
+
if selected_shop_name:
|
|
714
|
+
_use_shop_result["shop_name"] = selected_shop_name
|
|
715
|
+
typer.echo(json.dumps(_use_shop_result, indent=2))
|
|
716
|
+
return
|
|
717
|
+
typer.echo(f"Active shop: {shop_label}")
|
|
718
|
+
typer.echo("")
|
|
719
|
+
typer.echo("To see all shops: applied-cli auth shops")
|
|
720
|
+
typer.echo("To switch again: applied-cli auth use-shop \"<shop name or uuid>\"")
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
@app.command("logout")
|
|
724
|
+
def logout(
|
|
725
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
|
|
726
|
+
output_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
727
|
+
) -> None:
|
|
728
|
+
"""Remove all stored credentials and log out."""
|
|
729
|
+
had_credentials = bool(list_profiles())
|
|
730
|
+
if had_credentials and not yes and not output_json:
|
|
731
|
+
if not typer.confirm("Remove stored credentials and log out?"):
|
|
732
|
+
raise typer.Exit(code=1)
|
|
733
|
+
clear_credentials()
|
|
734
|
+
if output_json:
|
|
735
|
+
typer.echo(
|
|
736
|
+
json.dumps({"logged_out": True}, indent=2)
|
|
737
|
+
)
|
|
738
|
+
return
|
|
739
|
+
typer.echo("Logged out.")
|