deepcell-cli 0.6.1__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.
- deepcell_cli/__init__.py +12 -0
- deepcell_cli/__main__.py +5 -0
- deepcell_cli/_findings.py +84 -0
- deepcell_cli/capabilities.py +560 -0
- deepcell_cli/capability-contract.json +15622 -0
- deepcell_cli/client.py +503 -0
- deepcell_cli/commands/__init__.py +1 -0
- deepcell_cli/commands/_batch_input.py +29 -0
- deepcell_cli/commands/_datatypes.py +56 -0
- deepcell_cli/commands/_negative_args.py +133 -0
- deepcell_cli/commands/_swapped_args.py +153 -0
- deepcell_cli/commands/_version_display.py +40 -0
- deepcell_cli/commands/_write_opts.py +139 -0
- deepcell_cli/commands/account.py +123 -0
- deepcell_cli/commands/auth.py +610 -0
- deepcell_cli/commands/changes.py +307 -0
- deepcell_cli/commands/deck.py +594 -0
- deepcell_cli/commands/defs.py +3890 -0
- deepcell_cli/commands/describe.py +902 -0
- deepcell_cli/commands/doc.py +529 -0
- deepcell_cli/commands/doctor.py +257 -0
- deepcell_cli/commands/download.py +36 -0
- deepcell_cli/commands/edit.py +384 -0
- deepcell_cli/commands/example.py +161 -0
- deepcell_cli/commands/export.py +81 -0
- deepcell_cli/commands/export_docx.py +57 -0
- deepcell_cli/commands/export_pdf.py +66 -0
- deepcell_cli/commands/export_pptx.py +45 -0
- deepcell_cli/commands/files.py +386 -0
- deepcell_cli/commands/grep.py +90 -0
- deepcell_cli/commands/guide.py +431 -0
- deepcell_cli/commands/help_cmd.py +348 -0
- deepcell_cli/commands/impact.py +382 -0
- deepcell_cli/commands/import_cmd.py +208 -0
- deepcell_cli/commands/ingest.py +110 -0
- deepcell_cli/commands/merge.py +399 -0
- deepcell_cli/commands/query.py +718 -0
- deepcell_cli/commands/reasoning.py +2981 -0
- deepcell_cli/commands/ref.py +279 -0
- deepcell_cli/commands/replace.py +326 -0
- deepcell_cli/commands/rules.py +206 -0
- deepcell_cli/commands/share.py +186 -0
- deepcell_cli/commands/sync.py +804 -0
- deepcell_cli/commands/upgrade.py +185 -0
- deepcell_cli/commands/variant.py +353 -0
- deepcell_cli/commands/version.py +445 -0
- deepcell_cli/commands/viewer.py +54 -0
- deepcell_cli/commands/workspace.py +101 -0
- deepcell_cli/config.py +352 -0
- deepcell_cli/context.py +187 -0
- deepcell_cli/errors.py +141 -0
- deepcell_cli/logging_setup.py +161 -0
- deepcell_cli/main.py +518 -0
- deepcell_cli/mcp_server.py +906 -0
- deepcell_cli/oauth_provider.py +580 -0
- deepcell_cli/output.py +503 -0
- deepcell_cli/revision.py +164 -0
- deepcell_cli/stages.py +223 -0
- deepcell_cli/surface.py +628 -0
- deepcell_cli/sync_state.py +120 -0
- deepcell_cli/upgrade_check.py +399 -0
- deepcell_cli/xml_replace.py +89 -0
- deepcell_cli-0.6.1.dist-info/METADATA +264 -0
- deepcell_cli-0.6.1.dist-info/RECORD +67 -0
- deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
- deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
- deepcell_cli-0.6.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
"""Authentication commands: login, register, logout, whoami."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from deepcell_cli.config import (
|
|
12
|
+
clear_credentials,
|
|
13
|
+
client_headers,
|
|
14
|
+
get_api_url,
|
|
15
|
+
get_refresh_token,
|
|
16
|
+
load_config,
|
|
17
|
+
load_credentials,
|
|
18
|
+
save_config,
|
|
19
|
+
save_credentials,
|
|
20
|
+
warn_if_insecure,
|
|
21
|
+
)
|
|
22
|
+
from deepcell_cli.errors import APIError
|
|
23
|
+
from deepcell_cli.errors import ConnectionError as CLIConnectionError
|
|
24
|
+
from deepcell_cli.context import Ctx, pass_ctx
|
|
25
|
+
from deepcell_cli.output import echo_error, echo_info, echo_success, echo_warning, output
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _api_post(url: str, **kwargs: object) -> "httpx.Response":
|
|
29
|
+
"""POST with user-friendly connection error handling.
|
|
30
|
+
|
|
31
|
+
Deliberately not routed through ``DeepCellClient``: these are the
|
|
32
|
+
pre-credential calls (register, login, the device flow), and that client
|
|
33
|
+
would try to mint an anonymous session to satisfy them. It must still
|
|
34
|
+
announce the calling surface, though — ``user_registered``,
|
|
35
|
+
``demo_signup_converted`` and ``login_success`` are funnel milestones, and
|
|
36
|
+
they were the whole login stage reading NULL because this path built its
|
|
37
|
+
own request.
|
|
38
|
+
"""
|
|
39
|
+
import httpx
|
|
40
|
+
|
|
41
|
+
headers = {**client_headers(), **(kwargs.pop("headers", None) or {})} # type: ignore[dict-item]
|
|
42
|
+
try:
|
|
43
|
+
return httpx.post(url, timeout=30.0, headers=headers, **kwargs)
|
|
44
|
+
except (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError) as exc:
|
|
45
|
+
base = url.rsplit("/auth/", 1)[0] if "/auth/" in url else url
|
|
46
|
+
raise CLIConnectionError(base, exc) from exc
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── anonymous-work claim ────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Key under which a failed claim's device_id survives in the (account)
|
|
53
|
+
# credentials file, so the next `deepcell login` can retry the claim instead
|
|
54
|
+
# of orphaning the anonymous workspace.
|
|
55
|
+
_PENDING_CLAIM_KEY = "pending_anon_claim"
|
|
56
|
+
|
|
57
|
+
_CLAIM_RETRY_NOTE = (
|
|
58
|
+
"Note: your earlier work could not be moved automatically. "
|
|
59
|
+
"It will be retried on your next `deepcell login`."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Refresh tokens whose server-side revocation failed after local logout. They
|
|
63
|
+
# live in the private (0600) config rather than credentials.json: keeping them
|
|
64
|
+
# in credentials would let DeepCellClient refresh the supposedly logged-out
|
|
65
|
+
# session on the next command. A later logout retries this list through the
|
|
66
|
+
# unauthenticated RFC-7009-style endpoint.
|
|
67
|
+
_PENDING_REVOCATIONS_KEY = "pending_refresh_revocations"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _snapshot_anon_credentials() -> dict | None:
|
|
71
|
+
"""Capture the anonymous session (if any) before login overwrites it.
|
|
72
|
+
|
|
73
|
+
Also resurrects a claim that failed on a previous login: the stashed
|
|
74
|
+
``pending_anon_claim`` device_id becomes a token-less snapshot, which
|
|
75
|
+
forces :func:`_claim_anonymous_work` down its re-mint path.
|
|
76
|
+
"""
|
|
77
|
+
creds = load_credentials()
|
|
78
|
+
if creds.get("anonymous") and creds.get("device_id"):
|
|
79
|
+
return creds
|
|
80
|
+
pending = creds.get(_PENDING_CLAIM_KEY)
|
|
81
|
+
if pending:
|
|
82
|
+
return {"device_id": pending, "expires_at": 0}
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _stash_pending_claim(device_id: str | None) -> None:
|
|
87
|
+
"""Persist the anon device_id onto the just-saved account credentials.
|
|
88
|
+
|
|
89
|
+
The in-memory snapshot is the only copy once login has overwritten the
|
|
90
|
+
credentials file — without this, a transiently failed claim would orphan
|
|
91
|
+
the anonymous workspace permanently.
|
|
92
|
+
"""
|
|
93
|
+
if not device_id:
|
|
94
|
+
return
|
|
95
|
+
creds = load_credentials()
|
|
96
|
+
creds[_PENDING_CLAIM_KEY] = device_id
|
|
97
|
+
save_credentials(creds)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _register_claim_proof(prior: dict | None) -> tuple[str, str] | None:
|
|
101
|
+
"""``(device_id, live anon access token)`` for an in-place register claim.
|
|
102
|
+
|
|
103
|
+
Same two factors ``POST /auth/claim-demo`` requires in its body variant,
|
|
104
|
+
gathered before login overwrites the credentials file. Returns None when
|
|
105
|
+
there is nothing to claim or no live token can be obtained — register then
|
|
106
|
+
proceeds as an ordinary signup and the post-hoc claim path still runs, so a
|
|
107
|
+
failure here costs the promotion, never the work.
|
|
108
|
+
"""
|
|
109
|
+
if not prior:
|
|
110
|
+
return None
|
|
111
|
+
device_id = prior.get("device_id")
|
|
112
|
+
if not device_id:
|
|
113
|
+
return None
|
|
114
|
+
anon_token = prior.get("access_token")
|
|
115
|
+
if prior.get("expires_at", 0) < time.time() + 30:
|
|
116
|
+
anon_token = None
|
|
117
|
+
try:
|
|
118
|
+
resp = _api_post(
|
|
119
|
+
f"{get_api_url()}/demo/session",
|
|
120
|
+
json={"client": "cli", "device_id": device_id},
|
|
121
|
+
)
|
|
122
|
+
if resp.status_code == 200:
|
|
123
|
+
anon_token = resp.json()["access_token"]
|
|
124
|
+
except Exception:
|
|
125
|
+
return None
|
|
126
|
+
if not anon_token:
|
|
127
|
+
return None
|
|
128
|
+
return str(device_id), str(anon_token)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _claim_anonymous_work(prior: dict | None) -> None:
|
|
132
|
+
"""Move a prior anonymous session's work into the just-authenticated account.
|
|
133
|
+
|
|
134
|
+
Proof mirrors POST /auth/claim-demo's body variant: the stored
|
|
135
|
+
``device_id`` plus a live anon access token — re-minted first when the
|
|
136
|
+
stored one is stale. Every failure is non-fatal (login already
|
|
137
|
+
succeeded), but never silent: without a live token the claim is skipped
|
|
138
|
+
entirely (a stale token would 404 indistinguishably from "nothing to
|
|
139
|
+
claim"), and any failure stashes the device_id back into the credentials
|
|
140
|
+
file so the next login retries the claim.
|
|
141
|
+
"""
|
|
142
|
+
if not prior:
|
|
143
|
+
return
|
|
144
|
+
device_id = prior.get("device_id")
|
|
145
|
+
anon_token = prior.get("access_token")
|
|
146
|
+
|
|
147
|
+
if prior.get("expires_at", 0) < time.time() + 30:
|
|
148
|
+
anon_token = None
|
|
149
|
+
try:
|
|
150
|
+
resp = _api_post(
|
|
151
|
+
f"{get_api_url()}/demo/session",
|
|
152
|
+
json={"client": "cli", "device_id": device_id},
|
|
153
|
+
)
|
|
154
|
+
if resp.status_code == 200:
|
|
155
|
+
anon_token = resp.json()["access_token"]
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
if not anon_token:
|
|
160
|
+
_stash_pending_claim(device_id)
|
|
161
|
+
echo_info(_CLAIM_RETRY_NOTE)
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
from deepcell_cli.client import DeepCellClient
|
|
165
|
+
|
|
166
|
+
client = DeepCellClient()
|
|
167
|
+
try:
|
|
168
|
+
data = client.post(
|
|
169
|
+
"/auth/claim-demo",
|
|
170
|
+
json={"device_id": device_id, "anon_access_token": anon_token},
|
|
171
|
+
)
|
|
172
|
+
except APIError as exc:
|
|
173
|
+
if exc.status_code == 404:
|
|
174
|
+
# Presented a live token, so 404 genuinely means nothing to
|
|
175
|
+
# claim (row already merged or expired server-side) — stay quiet.
|
|
176
|
+
return
|
|
177
|
+
_stash_pending_claim(device_id)
|
|
178
|
+
echo_info(_CLAIM_RETRY_NOTE)
|
|
179
|
+
return
|
|
180
|
+
except Exception:
|
|
181
|
+
_stash_pending_claim(device_id)
|
|
182
|
+
echo_info(_CLAIM_RETRY_NOTE)
|
|
183
|
+
return
|
|
184
|
+
finally:
|
|
185
|
+
client.close()
|
|
186
|
+
|
|
187
|
+
moved = int(data.get("workspaces_moved", 0) or 0)
|
|
188
|
+
echo_success(
|
|
189
|
+
f"Moved {moved} workspace{'s' if moved != 1 else ''} into your account."
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ── login ───────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@click.command()
|
|
197
|
+
@click.option("--email", help="Email for direct login (headless/CI).")
|
|
198
|
+
@click.option("--password", help="Password for direct login (headless/CI).")
|
|
199
|
+
@pass_ctx
|
|
200
|
+
def login(ctx: Ctx, email: str | None, password: str | None) -> None:
|
|
201
|
+
"""Authenticate with the DeepCell API.
|
|
202
|
+
|
|
203
|
+
Without flags, opens a browser-based authorization flow (like `gh auth login`).
|
|
204
|
+
No account yet? Sign up right on that page (email or Google) — no separate
|
|
205
|
+
`register` step needed.
|
|
206
|
+
|
|
207
|
+
Use `--email` (password will be prompted securely) for headless environments.
|
|
208
|
+
"""
|
|
209
|
+
if password and not email:
|
|
210
|
+
raise click.UsageError("--password requires --email.")
|
|
211
|
+
prior_anon = _snapshot_anon_credentials()
|
|
212
|
+
if email:
|
|
213
|
+
if not password:
|
|
214
|
+
password = click.prompt("Password", hide_input=True)
|
|
215
|
+
_login_password(ctx, email, password)
|
|
216
|
+
else:
|
|
217
|
+
_login_device_flow(ctx)
|
|
218
|
+
_claim_anonymous_work(prior_anon)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _login_password(ctx: Ctx, email: str, password: str) -> None:
|
|
222
|
+
"""Direct email/password login."""
|
|
223
|
+
api_url = get_api_url()
|
|
224
|
+
warn_if_insecure(api_url)
|
|
225
|
+
|
|
226
|
+
resp = _api_post(
|
|
227
|
+
f"{api_url}/auth/login",
|
|
228
|
+
json={"email": email, "password": password},
|
|
229
|
+
)
|
|
230
|
+
if resp.status_code != 200:
|
|
231
|
+
try:
|
|
232
|
+
detail = resp.json().get("detail", resp.text)
|
|
233
|
+
except Exception:
|
|
234
|
+
detail = resp.text
|
|
235
|
+
raise APIError(resp.status_code, str(detail))
|
|
236
|
+
|
|
237
|
+
data = resp.json()
|
|
238
|
+
creds: dict[str, object] = {
|
|
239
|
+
"access_token": data["access_token"],
|
|
240
|
+
"expires_at": time.time() + data["expires_in"],
|
|
241
|
+
"user_id": data["user"]["id"],
|
|
242
|
+
"email": data["user"]["email"],
|
|
243
|
+
}
|
|
244
|
+
if data.get("refresh_token"):
|
|
245
|
+
creds["refresh_token"] = data["refresh_token"]
|
|
246
|
+
save_credentials(creds)
|
|
247
|
+
echo_success(f"Authenticated as {data['user']['email']}")
|
|
248
|
+
echo_info(" Token saved to ~/.deepcell/credentials.json")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _is_safe_verification_url(url: str, api_url: str) -> bool:
|
|
252
|
+
"""Return True if *url* is safe to open in a browser.
|
|
253
|
+
|
|
254
|
+
Rules:
|
|
255
|
+
- Scheme must be ``https`` (or ``http`` only for localhost).
|
|
256
|
+
- Host must match the API host.
|
|
257
|
+
"""
|
|
258
|
+
try:
|
|
259
|
+
parsed = urlparse(url)
|
|
260
|
+
api_parsed = urlparse(api_url)
|
|
261
|
+
except Exception:
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
if parsed.scheme not in ("http", "https"):
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
url_host = parsed.hostname or ""
|
|
268
|
+
api_host = api_parsed.hostname or ""
|
|
269
|
+
|
|
270
|
+
if url_host != api_host:
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
# Allow http only for localhost
|
|
274
|
+
if parsed.scheme == "http" and url_host not in ("localhost", "127.0.0.1", "::1"):
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
return True
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _login_device_flow(ctx: Ctx) -> None:
|
|
281
|
+
"""Browser-based device code login flow."""
|
|
282
|
+
api_url = get_api_url()
|
|
283
|
+
warn_if_insecure(api_url)
|
|
284
|
+
|
|
285
|
+
# Step 1: Request device code, carrying this session's anonymous identity
|
|
286
|
+
# when it has one. If the user signs UP in the browser during this flow,
|
|
287
|
+
# that lets their new account adopt the row whose workspace already holds
|
|
288
|
+
# their work — otherwise they land on an empty personal workspace with the
|
|
289
|
+
# work beside it. Nothing here is required: an unproven or absent identity
|
|
290
|
+
# simply yields an ordinary device flow.
|
|
291
|
+
proof = _register_claim_proof(_snapshot_anon_credentials())
|
|
292
|
+
device_payload: dict[str, object] = {}
|
|
293
|
+
device_headers: dict[str, str] = {}
|
|
294
|
+
if proof:
|
|
295
|
+
device_payload["device_id"] = proof[0]
|
|
296
|
+
device_headers["Authorization"] = f"Bearer {proof[1]}"
|
|
297
|
+
|
|
298
|
+
resp = _api_post(
|
|
299
|
+
f"{api_url}/auth/device/code",
|
|
300
|
+
json=device_payload,
|
|
301
|
+
headers=device_headers or None,
|
|
302
|
+
)
|
|
303
|
+
if resp.status_code != 200:
|
|
304
|
+
try:
|
|
305
|
+
detail = resp.json().get("detail", resp.text)
|
|
306
|
+
except Exception:
|
|
307
|
+
detail = resp.text
|
|
308
|
+
raise APIError(resp.status_code, str(detail))
|
|
309
|
+
|
|
310
|
+
data = resp.json()
|
|
311
|
+
device_code = data["device_code"]
|
|
312
|
+
verification_url = data["verification_url"]
|
|
313
|
+
expires_in = data.get("expires_in", 900)
|
|
314
|
+
interval = data.get("interval", 5)
|
|
315
|
+
|
|
316
|
+
# Step 2: Show URL and try to open browser
|
|
317
|
+
echo_info("To authenticate, visit this URL in your browser:\n")
|
|
318
|
+
echo_info(f" {verification_url}\n")
|
|
319
|
+
|
|
320
|
+
if _is_safe_verification_url(verification_url, api_url):
|
|
321
|
+
try:
|
|
322
|
+
import webbrowser
|
|
323
|
+
|
|
324
|
+
webbrowser.open(verification_url)
|
|
325
|
+
echo_info("(Browser opened automatically)")
|
|
326
|
+
except Exception:
|
|
327
|
+
pass
|
|
328
|
+
else:
|
|
329
|
+
echo_info("(URL not opened automatically — copy and paste it into your browser)")
|
|
330
|
+
|
|
331
|
+
# Step 3: Poll for authorization
|
|
332
|
+
deadline = time.time() + expires_in
|
|
333
|
+
spinner_chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
334
|
+
spin_idx = 0
|
|
335
|
+
|
|
336
|
+
while time.time() < deadline:
|
|
337
|
+
click.echo(
|
|
338
|
+
f"\rWaiting for authorization... {spinner_chars[spin_idx % len(spinner_chars)]}",
|
|
339
|
+
nl=False,
|
|
340
|
+
err=True,
|
|
341
|
+
)
|
|
342
|
+
spin_idx += 1
|
|
343
|
+
time.sleep(interval)
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
poll_resp = _api_post(
|
|
347
|
+
f"{api_url}/auth/device/token",
|
|
348
|
+
json={"device_code": device_code},
|
|
349
|
+
)
|
|
350
|
+
except CLIConnectionError:
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
if poll_resp.status_code == 200:
|
|
354
|
+
poll_data = poll_resp.json()
|
|
355
|
+
if poll_data.get("status") == "pending":
|
|
356
|
+
continue
|
|
357
|
+
|
|
358
|
+
# Got tokens
|
|
359
|
+
click.echo("\r" + " " * 50 + "\r", nl=False, err=True)
|
|
360
|
+
creds: dict[str, object] = {
|
|
361
|
+
"access_token": poll_data["access_token"],
|
|
362
|
+
"expires_at": time.time() + poll_data["expires_in"],
|
|
363
|
+
"user_id": poll_data["user"]["id"],
|
|
364
|
+
"email": poll_data["user"]["email"],
|
|
365
|
+
}
|
|
366
|
+
if poll_data.get("refresh_token"):
|
|
367
|
+
creds["refresh_token"] = poll_data["refresh_token"]
|
|
368
|
+
save_credentials(creds)
|
|
369
|
+
echo_success(f"Authenticated as {poll_data['user']['email']}")
|
|
370
|
+
echo_info(" Token saved to ~/.deepcell/credentials.json")
|
|
371
|
+
return
|
|
372
|
+
elif poll_resp.status_code == 404:
|
|
373
|
+
click.echo("\r" + " " * 50 + "\r", nl=False, err=True)
|
|
374
|
+
echo_error("Device code expired or not found.")
|
|
375
|
+
raise SystemExit(1)
|
|
376
|
+
|
|
377
|
+
click.echo("\r" + " " * 50 + "\r", nl=False, err=True)
|
|
378
|
+
echo_error("Authorization timed out. Please try again.")
|
|
379
|
+
raise SystemExit(1)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ── verify-email ──────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _run_verification_flow(ctx: Ctx) -> bool:
|
|
386
|
+
"""Run the email verification flow interactively.
|
|
387
|
+
|
|
388
|
+
Returns True on success, False on failure.
|
|
389
|
+
"""
|
|
390
|
+
echo_info("Sending verification email…")
|
|
391
|
+
try:
|
|
392
|
+
data = ctx.client.post("/auth/send-verification")
|
|
393
|
+
except APIError as exc:
|
|
394
|
+
echo_error(f"Failed to send verification email: {exc.detail}")
|
|
395
|
+
return False
|
|
396
|
+
|
|
397
|
+
if data.get("message") == "Email is already verified":
|
|
398
|
+
echo_success("Email is already verified!")
|
|
399
|
+
return True
|
|
400
|
+
|
|
401
|
+
# In dev mode the server returns the token directly
|
|
402
|
+
token_from_server = data.get("token")
|
|
403
|
+
if token_from_server:
|
|
404
|
+
echo_info(f" [dev mode] Verification token: {token_from_server}")
|
|
405
|
+
|
|
406
|
+
token = click.prompt("Enter the verification token from your email")
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
ctx.client.post("/auth/verify-email", json={"token": token}, auth=False)
|
|
410
|
+
except APIError as exc:
|
|
411
|
+
echo_error(f"Verification failed: {exc.detail}")
|
|
412
|
+
return False
|
|
413
|
+
|
|
414
|
+
echo_success("Email verified successfully!")
|
|
415
|
+
return True
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@click.command("verify-email")
|
|
419
|
+
@pass_ctx
|
|
420
|
+
def verify_email(ctx: Ctx) -> None:
|
|
421
|
+
"""Verify your email address.
|
|
422
|
+
|
|
423
|
+
Sends a verification token to your registered email. In development mode
|
|
424
|
+
the token is displayed directly. Enter the token when prompted.
|
|
425
|
+
"""
|
|
426
|
+
if not _run_verification_flow(ctx):
|
|
427
|
+
raise SystemExit(1)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ── register ─────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@click.command()
|
|
434
|
+
@click.option("--email", prompt=True, help="Account email.")
|
|
435
|
+
@click.option(
|
|
436
|
+
"--password",
|
|
437
|
+
prompt=True,
|
|
438
|
+
hide_input=True,
|
|
439
|
+
confirmation_prompt=True,
|
|
440
|
+
help="Account password (min 8 chars).",
|
|
441
|
+
)
|
|
442
|
+
@click.option("--name", "display_name", prompt="Display name", help="Your display name.")
|
|
443
|
+
@click.option("--locale", default="zh-CN", help="Locale (default: zh-CN).")
|
|
444
|
+
def register(email: str, password: str, display_name: str, locale: str) -> None:
|
|
445
|
+
"""Create a new DeepCell account (headless/CI).
|
|
446
|
+
|
|
447
|
+
For interactive use, prefer `deepcell login` — it opens the browser,
|
|
448
|
+
where you can sign up (email or Google) and authorize the CLI in one flow.
|
|
449
|
+
"""
|
|
450
|
+
api_url = get_api_url()
|
|
451
|
+
warn_if_insecure(api_url)
|
|
452
|
+
prior_anon = _snapshot_anon_credentials()
|
|
453
|
+
|
|
454
|
+
# Hand the anonymous identity to /auth/register so it upgrades that row IN
|
|
455
|
+
# PLACE, which is what makes the scratch workspace become the new account's
|
|
456
|
+
# personal workspace. Without it register creates an empty personal
|
|
457
|
+
# workspace first and the scratch one arrives afterwards as an ordinary
|
|
458
|
+
# second workspace — the work survives, beside a home that is not it.
|
|
459
|
+
#
|
|
460
|
+
# Both halves are required server-side, so send both or neither: the
|
|
461
|
+
# device_id proves which row, the live anon token proves we held its
|
|
462
|
+
# session. A stale token is re-minted first, exactly as the claim path does.
|
|
463
|
+
claim_proof = _register_claim_proof(prior_anon)
|
|
464
|
+
payload: dict[str, object] = {
|
|
465
|
+
"email": email,
|
|
466
|
+
"password": password,
|
|
467
|
+
"display_name": display_name,
|
|
468
|
+
"locale": locale,
|
|
469
|
+
}
|
|
470
|
+
headers: dict[str, str] = {}
|
|
471
|
+
if claim_proof:
|
|
472
|
+
payload["device_id"] = claim_proof[0]
|
|
473
|
+
headers["Authorization"] = f"Bearer {claim_proof[1]}"
|
|
474
|
+
|
|
475
|
+
resp = _api_post(
|
|
476
|
+
f"{api_url}/auth/register",
|
|
477
|
+
json=payload,
|
|
478
|
+
headers=headers or None,
|
|
479
|
+
)
|
|
480
|
+
if resp.status_code == 409:
|
|
481
|
+
echo_error("An account with that email already exists.")
|
|
482
|
+
raise SystemExit(1)
|
|
483
|
+
if resp.status_code != 201:
|
|
484
|
+
try:
|
|
485
|
+
detail = resp.json().get("detail", resp.text)
|
|
486
|
+
except Exception:
|
|
487
|
+
detail = resp.text
|
|
488
|
+
raise APIError(resp.status_code, str(detail))
|
|
489
|
+
|
|
490
|
+
data = resp.json()
|
|
491
|
+
creds: dict[str, object] = {
|
|
492
|
+
"access_token": data["access_token"],
|
|
493
|
+
"expires_at": time.time() + data["expires_in"],
|
|
494
|
+
"user_id": data["user"]["id"],
|
|
495
|
+
"email": data["user"]["email"],
|
|
496
|
+
}
|
|
497
|
+
if data.get("refresh_token"):
|
|
498
|
+
creds["refresh_token"] = data["refresh_token"]
|
|
499
|
+
save_credentials(creds)
|
|
500
|
+
echo_success(f"Account created! Authenticated as {data['user']['email']}")
|
|
501
|
+
echo_info(" Token saved to ~/.deepcell/credentials.json")
|
|
502
|
+
if claim_proof:
|
|
503
|
+
# Register already merged it, in place. Calling claim-demo now would
|
|
504
|
+
# find no anonymous row for this device_id and — because the stored
|
|
505
|
+
# token is stale by then — re-mint a BRAND NEW anonymous user via
|
|
506
|
+
# /demo/session just to claim its empty self. Harmless but it litters,
|
|
507
|
+
# so skip it when we know the work already moved.
|
|
508
|
+
echo_success("Moved your workspace and its files into your account.")
|
|
509
|
+
else:
|
|
510
|
+
_claim_anonymous_work(prior_anon)
|
|
511
|
+
|
|
512
|
+
if not data["user"].get("email_verified", False):
|
|
513
|
+
# Don't block onboarding on verification — features that need a
|
|
514
|
+
# verified email will say so; `deepcell verify-email` handles it then.
|
|
515
|
+
echo_info(
|
|
516
|
+
"\nSome features require a verified email. "
|
|
517
|
+
"Run `deepcell verify-email` when you need them."
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# ── logout ──────────────────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@click.command()
|
|
525
|
+
@pass_ctx
|
|
526
|
+
def logout(ctx: Ctx) -> None:
|
|
527
|
+
"""Log out and remove stored credentials."""
|
|
528
|
+
from deepcell_cli.config import is_anonymous_session
|
|
529
|
+
|
|
530
|
+
if is_anonymous_session():
|
|
531
|
+
# The device_id is the only key to the anonymous demo workspace —
|
|
532
|
+
# discarding it orphans that work permanently.
|
|
533
|
+
click.confirm(
|
|
534
|
+
"This discards your local session and its workspace. Continue?",
|
|
535
|
+
abort=True,
|
|
536
|
+
)
|
|
537
|
+
# No server-side revocation: there is no anon-audience logout route,
|
|
538
|
+
# and the anon access token is short-lived (~15 min) — discarding the
|
|
539
|
+
# device_id locally is what actually severs access to the workspace.
|
|
540
|
+
clear_credentials()
|
|
541
|
+
echo_success("Session discarded.")
|
|
542
|
+
return
|
|
543
|
+
# /auth/logout reads the refresh token from a COOKIE, and the CLI holds no
|
|
544
|
+
# cookie jar (login posts with a bare httpx call), so it revoked nothing
|
|
545
|
+
# and returned 204 — `logout` reported success while a copied
|
|
546
|
+
# credentials.json stayed valid. /auth/revoke-token takes the token in the
|
|
547
|
+
# body precisely for non-browser callers.
|
|
548
|
+
config = load_config()
|
|
549
|
+
pending = config.get(_PENDING_REVOCATIONS_KEY) or []
|
|
550
|
+
if not isinstance(pending, list):
|
|
551
|
+
pending = []
|
|
552
|
+
refresh_token = get_refresh_token()
|
|
553
|
+
tokens = list(dict.fromkeys(
|
|
554
|
+
[str(token) for token in pending if token]
|
|
555
|
+
+ ([refresh_token] if refresh_token else [])
|
|
556
|
+
))
|
|
557
|
+
|
|
558
|
+
failed_tokens: list[str] = []
|
|
559
|
+
failure_messages: list[str] = []
|
|
560
|
+
for token in tokens:
|
|
561
|
+
try:
|
|
562
|
+
# The endpoint treats possession of the refresh token as
|
|
563
|
+
# authorization and intentionally needs no Bearer token. Using
|
|
564
|
+
# ctx.client here made a retry impossible after credentials were
|
|
565
|
+
# cleared because the client tried to authenticate first.
|
|
566
|
+
response = _api_post(
|
|
567
|
+
f"{get_api_url()}/auth/revoke-token",
|
|
568
|
+
json={"refresh_token": token},
|
|
569
|
+
)
|
|
570
|
+
if response.status_code != 200:
|
|
571
|
+
raise APIError(response.status_code, response.text)
|
|
572
|
+
except Exception as exc: # noqa: BLE001 — retained and reported
|
|
573
|
+
failed_tokens.append(token)
|
|
574
|
+
failure_messages.append(str(exc))
|
|
575
|
+
clear_credentials()
|
|
576
|
+
|
|
577
|
+
if failed_tokens:
|
|
578
|
+
config[_PENDING_REVOCATIONS_KEY] = failed_tokens
|
|
579
|
+
save_config(config)
|
|
580
|
+
echo_warning(
|
|
581
|
+
"Local credentials removed, but the server did not confirm "
|
|
582
|
+
f"revocation ({'; '.join(failure_messages)}) — {len(failed_tokens)} "
|
|
583
|
+
"refresh token(s) may still be valid. Retry `deepcell logout` once "
|
|
584
|
+
"you are back online; the pending token(s) were retained privately "
|
|
585
|
+
"for that retry."
|
|
586
|
+
)
|
|
587
|
+
raise click.exceptions.Exit(1)
|
|
588
|
+
else:
|
|
589
|
+
if _PENDING_REVOCATIONS_KEY in config:
|
|
590
|
+
config.pop(_PENDING_REVOCATIONS_KEY, None)
|
|
591
|
+
save_config(config)
|
|
592
|
+
echo_success("Logged out. Credentials removed.")
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
# ── whoami ──────────────────────────────────────────────────
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
@click.command()
|
|
599
|
+
@pass_ctx
|
|
600
|
+
def whoami(ctx: Ctx) -> None:
|
|
601
|
+
"""Show the currently authenticated user."""
|
|
602
|
+
from deepcell_cli.config import is_anonymous_session
|
|
603
|
+
|
|
604
|
+
if is_anonymous_session():
|
|
605
|
+
# /auth/me is full-user only; report the anonymous state locally.
|
|
606
|
+
click.echo("Not signed in.")
|
|
607
|
+
click.echo("Run `deepcell login` to sign in.")
|
|
608
|
+
return
|
|
609
|
+
data = ctx.client.get("/auth/me")
|
|
610
|
+
output(data, ctx.fmt)
|