mozbridge-cli 0.2.1__tar.gz → 0.3.0__tar.gz

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 (51) hide show
  1. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/PKG-INFO +1 -1
  2. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/pyproject.toml +1 -1
  3. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/scripts/release.sh +17 -0
  4. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/api.py +116 -0
  5. mozbridge_cli-0.3.0/src/mozbridge_cli/cmd_auth.py +152 -0
  6. mozbridge_cli-0.3.0/src/mozbridge_cli/cmd_deploy.py +307 -0
  7. mozbridge_cli-0.3.0/src/mozbridge_cli/cmd_env.py +115 -0
  8. mozbridge_cli-0.3.0/src/mozbridge_cli/cmd_projects.py +92 -0
  9. mozbridge_cli-0.3.0/src/mozbridge_cli/cmd_publish.py +498 -0
  10. mozbridge_cli-0.3.0/src/mozbridge_cli/cmd_registry.py +97 -0
  11. mozbridge_cli-0.3.0/src/mozbridge_cli/cmd_secrets.py +165 -0
  12. mozbridge_cli-0.3.0/src/mozbridge_cli/cmd_shared.py +31 -0
  13. mozbridge_cli-0.3.0/src/mozbridge_cli/main.py +49 -0
  14. mozbridge_cli-0.3.0/tests/test_registry.py +196 -0
  15. mozbridge_cli-0.3.0/tests/test_secrets.py +294 -0
  16. mozbridge_cli-0.2.1/src/mozbridge_cli/main.py +0 -1142
  17. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/.gitignore +0 -0
  18. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/README.md +0 -0
  19. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/docs/commands.md +0 -0
  20. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/__init__.py +0 -0
  21. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/__main__.py +0 -0
  22. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/auth.py +0 -0
  23. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/build.py +0 -0
  24. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/compose.py +0 -0
  25. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/config.py +0 -0
  26. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/link.py +0 -0
  27. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/local_build.py +0 -0
  28. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/runtime_secrets.py +0 -0
  29. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/session.py +0 -0
  30. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/conftest.py +0 -0
  31. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_build.py +0 -0
  32. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_ci_token_auth.py +0 -0
  33. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_compose.py +0 -0
  34. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_deploy.py +0 -0
  35. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_diff.py +0 -0
  36. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_env.py +0 -0
  37. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_link.py +0 -0
  38. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_local_build.py +0 -0
  39. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_login.py +0 -0
  40. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_login_cli.py +0 -0
  41. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_logout.py +0 -0
  42. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_logs.py +0 -0
  43. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_publish.py +0 -0
  44. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_publish_local.py +0 -0
  45. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_publish_multi_component.py +0 -0
  46. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_refresh.py +0 -0
  47. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_rollback.py +0 -0
  48. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_runtime_secrets.py +0 -0
  49. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_session_permissions.py +0 -0
  50. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_status.py +0 -0
  51. {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_whoami.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mozbridge-cli
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Command-line client for the Mozbridge deployment platform: SSO login, build, publish, status, and rollback for any project hosted on Mozbridge.
5
5
  Project-URL: Homepage, https://mozbridge.com
6
6
  Project-URL: Documentation, https://github.com/jessin01/mozbridge/blob/main/cli/docs/commands.md
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mozbridge-cli"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "Command-line client for the Mozbridge deployment platform: SSO login, build, publish, status, and rollback for any project hosted on Mozbridge."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -10,8 +10,25 @@
10
10
  set -euo pipefail
11
11
 
12
12
  ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
13
+ REPO_ROOT="$(cd "$ROOT/.." && pwd)"
13
14
  PYPROJECT="$ROOT/pyproject.toml"
14
15
 
16
+ # Hard first check: `git fetch` updates the remote-tracking ref, not the
17
+ # working tree — only merge/rebase/pull do that. mozbridge-cli 0.2.0 was
18
+ # published missing 3 commands because this build ran between a `git
19
+ # fetch` and the merge that would have actually updated the checkout.
20
+ # Refuse to build from a checkout that's behind origin/main.
21
+ if command -v git >/dev/null 2>&1 && git -C "$REPO_ROOT" rev-parse --git-dir >/dev/null 2>&1; then
22
+ git -C "$REPO_ROOT" fetch origin main --quiet 2>/dev/null || true
23
+ BEHIND="$(git -C "$REPO_ROOT" rev-list --count HEAD..origin/main 2>/dev/null || echo 0)"
24
+ if [ "${BEHIND:-0}" != "0" ]; then
25
+ echo "✗ Local main is ${BEHIND} commit(s) behind origin/main." >&2
26
+ echo " Run: git -C \"$REPO_ROOT\" merge --ff-only origin/main" >&2
27
+ echo " then re-run this script. Refusing to build a stale release." >&2
28
+ exit 1
29
+ fi
30
+ fi
31
+
15
32
  usage() {
16
33
  echo "Usage: $0 [--patch|--minor|--major] [--dry-run]" >&2
17
34
  echo " Default bump: patch" >&2
@@ -308,6 +308,122 @@ def delete_project_env_var(
308
308
  return resp.json()
309
309
 
310
310
 
311
+ def get_project_registry(client: httpx.Client, access_token: str, org_id: int, project_id: int) -> dict:
312
+ """GET /api/v1/projects/{project_id}/registry -> schemas.RegistryCredentialStatus.
313
+
314
+ {configured, registry_url, username, updated_at} — the token/secret value
315
+ is never returned by this route (router.py:2343-2352), by design, so
316
+ there is nothing here for a caller to accidentally print.
317
+ """
318
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/registry"
319
+ try:
320
+ resp = client.get(url, headers=_headers(access_token, org_id))
321
+ except httpx.HTTPError as exc:
322
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
323
+ _raise_for_status(resp, "fetch the registry credential status")
324
+ return resp.json()
325
+
326
+
327
+ def set_project_registry(
328
+ client: httpx.Client,
329
+ access_token: str,
330
+ org_id: int,
331
+ project_id: int,
332
+ registry_url: str,
333
+ username: str,
334
+ token: str,
335
+ ) -> dict:
336
+ """PUT /api/v1/projects/{project_id}/registry, body schemas.RegistryCredentialSet.
337
+
338
+ Admin-gated (ensure_permify_can_administer) and validated against the
339
+ real registry BEFORE storing (router.py:2355-2384) — a bad credential
340
+ comes back as a 400 with a real message ("Could not validate registry
341
+ credential: ..."), which `_raise_for_status` already surfaces via
342
+ ApiError, same as every other function in this module.
343
+ """
344
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/registry"
345
+ payload = {"registry_url": registry_url, "username": username, "token": token}
346
+ try:
347
+ resp = client.put(url, json=payload, headers=_headers(access_token, org_id))
348
+ except httpx.HTTPError as exc:
349
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
350
+ _raise_for_status(resp, "set the registry credential")
351
+ return resp.json()
352
+
353
+
354
+ def _secrets_url(project_id: int, env_slug: str, component: str) -> str:
355
+ """Shared path builder for the four secrets endpoints below — keeps the
356
+ env_slug/component path segments byte-for-byte identical across list
357
+ keys / get values / patch one key, rather than four separately-typed
358
+ f-strings that could drift apart.
359
+ """
360
+ base = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}"
361
+ return f"{base}/environments/{env_slug}/secrets/{component}"
362
+
363
+
364
+ def list_env_secret_keys(
365
+ client: httpx.Client, access_token: str, org_id: int, project_id: int, env_slug: str, component: str
366
+ ) -> dict:
367
+ """GET .../environments/{env_slug}/secrets/{component}/keys -> {keys, count}.
368
+
369
+ Key NAMES only — no values leave the server (router.py:3422-3434). This
370
+ is the default listing behavior for `mozbridge secrets list`.
371
+ """
372
+ url = f"{_secrets_url(project_id, env_slug, component)}/keys"
373
+ try:
374
+ resp = client.get(url, headers=_headers(access_token, org_id))
375
+ except httpx.HTTPError as exc:
376
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
377
+ _raise_for_status(resp, "list secret keys")
378
+ return resp.json()
379
+
380
+
381
+ def get_env_secrets(
382
+ client: httpx.Client, access_token: str, org_id: int, project_id: int, env_slug: str, component: str
383
+ ) -> dict:
384
+ """GET .../environments/{env_slug}/secrets/{component} -> dict[key, value].
385
+
386
+ Returns actual secret VALUES (router.py:3412-3419). Only called from
387
+ `mozbridge secrets list --show-values`, gated behind an interactive
388
+ confirmation (or --yes) before this is ever invoked.
389
+ """
390
+ url = _secrets_url(project_id, env_slug, component)
391
+ try:
392
+ resp = client.get(url, headers=_headers(access_token, org_id))
393
+ except httpx.HTTPError as exc:
394
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
395
+ _raise_for_status(resp, "fetch secret values")
396
+ return resp.json()
397
+
398
+
399
+ def patch_env_secret(
400
+ client: httpx.Client,
401
+ access_token: str,
402
+ org_id: int,
403
+ project_id: int,
404
+ env_slug: str,
405
+ component: str,
406
+ key: str,
407
+ value: str,
408
+ ) -> dict:
409
+ """PATCH .../environments/{env_slug}/secrets/{component}, body schemas.SecretPatch {key, value}.
410
+
411
+ Merges ONE key server-side over a strict read (router.py:3437-3453) —
412
+ the safe way to set one secret without touching the rest. `mozbridge
413
+ secrets set` MUST use this, never the POST replace-all endpoint below.
414
+
415
+ Returns {"status": "success", "key", "created", "total_keys"} — never
416
+ the merged secret values.
417
+ """
418
+ url = _secrets_url(project_id, env_slug, component)
419
+ try:
420
+ resp = client.patch(url, json={"key": key, "value": value}, headers=_headers(access_token, org_id))
421
+ except httpx.HTTPError as exc:
422
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
423
+ _raise_for_status(resp, "set the secret")
424
+ return resp.json()
425
+
426
+
311
427
  def trigger_prebuilt_build(
312
428
  client: httpx.Client,
313
429
  access_token: str,
@@ -0,0 +1,152 @@
1
+ """`mozbridge login` / `whoami` / `logout` command definitions.
2
+
3
+ The underlying device-flow logic lives in auth.py (unchanged, imported
4
+ here, not duplicated). This module owns only the Typer command layer that
5
+ used to live directly in main.py.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import httpx
11
+ import typer
12
+
13
+ from . import auth, config, session
14
+
15
+ app = typer.Typer(add_completion=False)
16
+
17
+
18
+ @app.command("login")
19
+ def login_cmd() -> None:
20
+ """Sign in via Logto's device authorization flow (opens a URL for you to approve)."""
21
+ if session.token_from_env() is not None:
22
+ typer.echo(
23
+ f"{session.TOKEN_ENV_VAR} is set — this CLI is already authenticating as that "
24
+ "token for every command, so an interactive `mozbridge login` doesn't apply here. "
25
+ f"These are mutually exclusive auth modes; unset {session.TOKEN_ENV_VAR} first if "
26
+ "you want to log in interactively instead.",
27
+ err=True,
28
+ )
29
+ raise typer.Exit(code=1)
30
+
31
+ with httpx.Client(timeout=30.0) as client:
32
+ try:
33
+ authorization = auth.start_device_authorization(client)
34
+ except httpx.HTTPError as exc:
35
+ typer.echo(f"Could not start login: {exc}", err=True)
36
+ raise typer.Exit(code=1)
37
+
38
+ if authorization.verification_uri_complete:
39
+ typer.echo(f"Open this URL to sign in:\n\n {authorization.verification_uri_complete}\n")
40
+ typer.echo(f"Or go to {authorization.verification_uri} and enter code: {authorization.user_code}")
41
+ typer.echo("Waiting for approval...")
42
+
43
+ try:
44
+ token_response = auth.poll_for_token(client, authorization)
45
+ except auth.AccessDenied as exc:
46
+ typer.echo(str(exc), err=True)
47
+ raise typer.Exit(code=1)
48
+ except auth.ExpiredToken as exc:
49
+ typer.echo(str(exc), err=True)
50
+ raise typer.Exit(code=1)
51
+ except auth.DeviceLoginError as exc:
52
+ typer.echo(str(exc), err=True)
53
+ raise typer.Exit(code=1)
54
+
55
+ new_session = session.session_from_token_response(token_response)
56
+ path = session.save_session(new_session)
57
+ typer.echo(f"Logged in. Session saved to {path}")
58
+
59
+
60
+ def load_and_refresh_session(client: httpx.Client) -> session.Session:
61
+ """Return the bearer token to use for this command.
62
+
63
+ Single choke point for auth used by every command below (whoami, link,
64
+ status, rollback, diff, publish's remote AND --local paths). Checked
65
+ BEFORE anything session-file-related, mirroring gh/flyctl/Vercel's own
66
+ env-var precedence: if MOZBRIDGE_TOKEN is set, it is used directly as
67
+ the bearer token and load_session()/save_session()/refresh-token logic
68
+ are never invoked — there's no on-disk session file involved in this
69
+ path. Otherwise, falls back to loading the cached session, refreshing
70
+ the access token first if expired.
71
+
72
+ Exits the process with a clear message if there is no session, or if
73
+ refresh fails.
74
+ """
75
+ env_token = session.token_from_env()
76
+ if env_token is not None:
77
+ return session.Session(
78
+ access_token=env_token,
79
+ refresh_token=None,
80
+ id_token=None,
81
+ expires_at=float("inf"),
82
+ )
83
+
84
+ current = session.load_session()
85
+ if current is None:
86
+ typer.echo("Not logged in. Run `mozbridge login` first.", err=True)
87
+ raise typer.Exit(code=1)
88
+
89
+ if not current.is_expired():
90
+ return current
91
+
92
+ if not current.refresh_token:
93
+ typer.echo("Session expired and cannot be refreshed. Run `mozbridge login` again.", err=True)
94
+ raise typer.Exit(code=1)
95
+
96
+ try:
97
+ token_response = auth.refresh_access_token(client, current.refresh_token)
98
+ except auth.RefreshError as exc:
99
+ typer.echo(f"{exc} — run `mozbridge login` again.", err=True)
100
+ raise typer.Exit(code=1)
101
+
102
+ refreshed = session.session_from_token_response(token_response)
103
+ # Logto may omit refresh_token on rotation-less refresh — keep the old one.
104
+ if not refreshed.refresh_token:
105
+ refreshed.refresh_token = current.refresh_token
106
+ session.save_session(refreshed)
107
+ return refreshed
108
+
109
+
110
+ @app.command("whoami")
111
+ def whoami_cmd() -> None:
112
+ """Show the signed-in Mozbridge identity."""
113
+ with httpx.Client(timeout=30.0) as client:
114
+ active_session = load_and_refresh_session(client)
115
+
116
+ url = f"{config.API_BASE_URL}{config.WHOAMI_PATH}"
117
+ try:
118
+ resp = client.get(url, headers={"Authorization": f"Bearer {active_session.access_token}"})
119
+ except httpx.HTTPError as exc:
120
+ typer.echo(f"Could not reach Mozbridge API: {exc}", err=True)
121
+ raise typer.Exit(code=1)
122
+
123
+ if resp.status_code == 401:
124
+ typer.echo("Session is no longer valid. Run `mozbridge login` again.", err=True)
125
+ raise typer.Exit(code=1)
126
+ if resp.status_code != 200:
127
+ typer.echo(f"Mozbridge API returned {resp.status_code}: {resp.text}", err=True)
128
+ raise typer.Exit(code=1)
129
+
130
+ data = resp.json()
131
+
132
+ email = data.get("email") or "(no email on record)"
133
+ username = data.get("username")
134
+ full_name = data.get("full_name")
135
+ display = full_name or username or email
136
+
137
+ typer.echo(f"Logged in as {display}")
138
+ typer.echo(f" email: {email}")
139
+ if username:
140
+ typer.echo(f" username: {username}")
141
+ typer.echo(f" user id: {data.get('id')}")
142
+ typer.echo(f" platform admin: {bool(data.get('is_platform_admin'))}")
143
+
144
+
145
+ @app.command("logout")
146
+ def logout_cmd() -> None:
147
+ """Delete the cached local session. Safe to run even if already logged out."""
148
+ removed = session.delete_session()
149
+ if removed:
150
+ typer.echo("Logged out.")
151
+ else:
152
+ typer.echo("Already logged out.")
@@ -0,0 +1,307 @@
1
+ """`mozbridge status` / `rollback` / `deploy` / `logs` — deploy lifecycle commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+ import typer
10
+
11
+ from . import api
12
+ from .cmd_shared import require_link_and_session
13
+
14
+ app = typer.Typer(add_completion=False)
15
+
16
+
17
+ @app.command("status")
18
+ def status_cmd() -> None:
19
+ """Show the linked project's status and its 5 most recent deployments."""
20
+ cwd = Path.cwd()
21
+
22
+ with httpx.Client(timeout=30.0) as client:
23
+ current_link, active_session = require_link_and_session(client, cwd)
24
+
25
+ try:
26
+ project = api.get_project(
27
+ client, active_session.access_token, current_link.org_id, current_link.project_id
28
+ )
29
+ except api.ApiError as exc:
30
+ typer.echo(str(exc), err=True)
31
+ raise typer.Exit(code=1)
32
+
33
+ try:
34
+ deployments = api.list_project_deployments(
35
+ client, active_session.access_token, current_link.org_id, current_link.project_id
36
+ )
37
+ except api.ApiError as exc:
38
+ typer.echo(str(exc), err=True)
39
+ raise typer.Exit(code=1)
40
+
41
+ typer.echo(f"{project.get('name')} ({project.get('slug')})")
42
+ typer.echo(f" status: {project.get('status')}")
43
+
44
+ if not deployments:
45
+ typer.echo("No deployments yet.")
46
+ return
47
+
48
+ typer.echo("Recent deployments:")
49
+ for deployment in deployments[:5]:
50
+ stable_marker = " [stable]" if deployment.get("is_stable") else ""
51
+ typer.echo(
52
+ f" #{deployment.get('id')} {deployment.get('deployed_at') or '(no timestamp)'} "
53
+ f"{deployment.get('component')}={deployment.get('version')} "
54
+ f"{deployment.get('status')}{stable_marker}"
55
+ )
56
+
57
+
58
+ @app.command("rollback")
59
+ def rollback_cmd(
60
+ to: int | None = typer.Option(
61
+ None, "--to", help="Deployment id to roll back to. Omit to roll back to the previous deployment."
62
+ ),
63
+ yes: bool = typer.Option(False, "--yes", help="Skip the interactive confirmation prompt."),
64
+ ) -> None:
65
+ """Roll back the linked project's deployment. A real, consequential production action."""
66
+ cwd = Path.cwd()
67
+
68
+ with httpx.Client(timeout=30.0) as client:
69
+ current_link, active_session = require_link_and_session(client, cwd)
70
+
71
+ target_desc = f"deployment #{to}" if to is not None else "the previous deployment"
72
+ project_desc = f"{current_link.org_slug}/{current_link.project_slug}"
73
+ typer.echo(f"This will roll back {project_desc} to {target_desc}.")
74
+ typer.echo("Target environment: prod.")
75
+ if not yes:
76
+ typer.confirm("Continue?", abort=True)
77
+
78
+ try:
79
+ if to is not None:
80
+ result = api.rollback_project_to(
81
+ client,
82
+ active_session.access_token,
83
+ current_link.org_id,
84
+ current_link.project_id,
85
+ to,
86
+ )
87
+ else:
88
+ result = api.rollback_project(
89
+ client, active_session.access_token, current_link.org_id, current_link.project_id
90
+ )
91
+ except api.ApiError as exc:
92
+ typer.echo(str(exc), err=True)
93
+ raise typer.Exit(code=1)
94
+
95
+ task_id = result.get("task_id") or result.get("target_task")
96
+ typer.echo(f"Rollback triggered (task_id={task_id}).")
97
+
98
+
99
+ @app.command("deploy")
100
+ def deploy_cmd(
101
+ frontend: str | None = typer.Option(
102
+ None, "--frontend", help="Frontend image tag to deploy (e.g. v1.9.319)."
103
+ ),
104
+ backend: str | None = typer.Option(
105
+ None, "--backend", help="Backend image tag to deploy (e.g. v1.9.319)."
106
+ ),
107
+ environment: str = typer.Option("prod", "--env", help="Environment to deploy to."),
108
+ force: bool = typer.Option(
109
+ False,
110
+ "--force",
111
+ help=(
112
+ "Override a blocking preflight check (the platform's own "
113
+ "known-to-fail guard). A separate, explicit decision each time — "
114
+ "never set automatically by this CLI."
115
+ ),
116
+ ),
117
+ yes: bool = typer.Option(False, "--yes", help="Skip the interactive confirmation prompt."),
118
+ ) -> None:
119
+ """Deploy an already-built image tag to the linked Mozbridge project.
120
+
121
+ A real, consequential production action — same category as `mozbridge
122
+ rollback`: it mutates production the moment it's called, not a dry-run.
123
+ Requires `mozbridge link` and an active login session. At least one of
124
+ --frontend/--backend must be given (deploying neither makes no sense).
125
+
126
+ Only the services you actually gave a tag for are deployed — `services`
127
+ is built from --frontend/--backend, not left to the API's own default of
128
+ both, unless you passed both.
129
+ """
130
+ if frontend is None and backend is None:
131
+ typer.echo(
132
+ "Provide at least one of --frontend/--backend — deploying neither makes no sense.",
133
+ err=True,
134
+ )
135
+ raise typer.Exit(code=1)
136
+
137
+ services: list[str] = []
138
+ if frontend is not None:
139
+ services.append("frontend")
140
+ if backend is not None:
141
+ services.append("backend")
142
+
143
+ cwd = Path.cwd()
144
+
145
+ with httpx.Client(timeout=30.0) as client:
146
+ current_link, active_session = require_link_and_session(client, cwd)
147
+
148
+ project_desc = f"{current_link.org_slug}/{current_link.project_slug}"
149
+ typer.echo(f"This will deploy {project_desc} ({environment}):")
150
+ if frontend is not None:
151
+ typer.echo(f" frontend -> {frontend}")
152
+ if backend is not None:
153
+ typer.echo(f" backend -> {backend}")
154
+ if not yes:
155
+ typer.confirm("Continue?", abort=True)
156
+
157
+ payload = {
158
+ "frontend_version": frontend,
159
+ "backend_version": backend,
160
+ "services": services,
161
+ "environment": environment,
162
+ "force": force,
163
+ }
164
+
165
+ try:
166
+ result = api.deploy_project(
167
+ client, active_session.access_token, current_link.org_id, current_link.project_id, payload
168
+ )
169
+ except api.PreflightBlockedError as exc:
170
+ typer.echo(str(exc), err=True)
171
+ for check in exc.blocking:
172
+ title = check.get("title") or check.get("key") or "(unnamed check)"
173
+ detail = check.get("detail") or ""
174
+ typer.echo(f" - {title}: {detail}", err=True)
175
+ if check.get("action"):
176
+ typer.echo(f" action: {check['action']}", err=True)
177
+ typer.echo(exc.override or "Pass force=true to deploy anyway.", err=True)
178
+ typer.echo("Re-run with --force to override — this is not done automatically.", err=True)
179
+ raise typer.Exit(code=1)
180
+ except api.ApiError as exc:
181
+ typer.echo(str(exc), err=True)
182
+ raise typer.Exit(code=1)
183
+
184
+ task_id = result.get("task_id")
185
+ typer.echo(f"Deploy triggered (task_id={task_id}).")
186
+ typer.echo("Run `mozbridge logs` to watch it.")
187
+
188
+
189
+ def _iter_sse_events(lines) -> Iterator[tuple[str, str]]:
190
+ """Parse a text/event-stream line iterator into (event, data) pairs.
191
+
192
+ Standard SSE framing (see router.py's `stream_operation_logs`, which is
193
+ the only producer this ever reads): lines starting with "data:" accumulate
194
+ until a blank line, which dispatches one event; an "event:" line names
195
+ its type (defaults to "message" if absent, though this server always
196
+ sends one). A leading single space after the colon is stripped per the
197
+ SSE spec; any other line (comments, unknown fields) is ignored.
198
+ """
199
+ event_type = "message"
200
+ data_lines: list[str] = []
201
+ for raw_line in lines:
202
+ line = raw_line.rstrip("\r")
203
+ if line == "":
204
+ if data_lines:
205
+ yield event_type, "\n".join(data_lines)
206
+ event_type = "message"
207
+ data_lines = []
208
+ continue
209
+ if line.startswith("event:"):
210
+ event_type = line[len("event:") :].strip()
211
+ elif line.startswith("data:"):
212
+ value = line[len("data:") :]
213
+ if value.startswith(" "):
214
+ value = value[1:]
215
+ data_lines.append(value)
216
+ if data_lines:
217
+ yield event_type, "\n".join(data_lines)
218
+
219
+
220
+ @app.command("logs")
221
+ def logs_cmd(
222
+ task_id: str | None = typer.Argument(
223
+ None, help="Task id of a build/deploy operation. Omit to use the most recent one for this project."
224
+ ),
225
+ follow: bool = typer.Option(
226
+ False, "--follow", "-f", help="Stream new log output as it arrives; exits when the run finishes."
227
+ ),
228
+ ) -> None:
229
+ """Show BUILD/DEPLOY OPERATION logs for the linked project.
230
+
231
+ This is the log output of a Mozbridge build or deploy task — what
232
+ `publish`, `deploy`, and `rollback` trigger — NOT your running
233
+ application's runtime/request logs. This platform does not expose
234
+ application runtime logs via any API today; this command is not
235
+ `docker logs` for your live app.
236
+
237
+ Requires `mozbridge link` and an active login session. Without --follow,
238
+ fetches and prints whatever log content exists right now, once. With
239
+ --follow, opens the platform's SSE log stream and prints new content as
240
+ it arrives, exiting cleanly when the server closes the stream (it does
241
+ so automatically once the operation reaches a terminal state).
242
+ """
243
+ cwd = Path.cwd()
244
+
245
+ with httpx.Client(timeout=30.0) as client:
246
+ current_link, active_session = require_link_and_session(client, cwd)
247
+
248
+ resolved_task_id = task_id
249
+ if resolved_task_id is None:
250
+ try:
251
+ operations = api.list_project_operations(
252
+ client,
253
+ active_session.access_token,
254
+ current_link.org_id,
255
+ current_link.project_id,
256
+ limit=1,
257
+ )
258
+ except api.ApiError as exc:
259
+ typer.echo(str(exc), err=True)
260
+ raise typer.Exit(code=1)
261
+ if not operations or not operations[0].get("task_id"):
262
+ typer.echo("No operations found for this project yet.", err=True)
263
+ raise typer.Exit(code=1)
264
+ resolved_task_id = operations[0]["task_id"]
265
+
266
+ if not follow:
267
+ try:
268
+ op = api.get_operation(
269
+ client, active_session.access_token, current_link.org_id, resolved_task_id
270
+ )
271
+ except api.ApiError as exc:
272
+ typer.echo(str(exc), err=True)
273
+ raise typer.Exit(code=1)
274
+ logs = (op.get("result") or {}).get("logs")
275
+ if logs:
276
+ typer.echo(logs)
277
+ else:
278
+ typer.echo(f"No logs yet for task {resolved_task_id} (status: {op.get('status')}).")
279
+ return
280
+
281
+ org_id = current_link.org_id
282
+ access_token = active_session.access_token
283
+
284
+ # --follow: a separate client with no read timeout — the server holds
285
+ # this connection open for as long as the operation runs, closing it
286
+ # itself once the operation reaches a terminal state (see
287
+ # stream_operation_logs's docstring); we only need to survive that,
288
+ # not impose our own timeout on top of it.
289
+ stream_url = api.operation_stream_url(resolved_task_id)
290
+ headers = {"Authorization": f"Bearer {access_token}", "X-Organization-Id": str(org_id)}
291
+ try:
292
+ with httpx.Client(timeout=httpx.Timeout(30.0, read=None)) as stream_client:
293
+ with stream_client.stream("GET", stream_url, headers=headers) as resp:
294
+ if resp.status_code != 200:
295
+ resp.read()
296
+ typer.echo(f"Could not stream logs: HTTP {resp.status_code}: {resp.text}", err=True)
297
+ raise typer.Exit(code=1)
298
+ for event_type, data in _iter_sse_events(resp.iter_lines()):
299
+ if event_type == "log":
300
+ typer.echo(data)
301
+ elif event_type == "error":
302
+ typer.echo(f"Stream error: {data}", err=True)
303
+ elif event_type == "done":
304
+ break
305
+ except httpx.HTTPError as exc:
306
+ typer.echo(f"Log stream connection dropped: {exc}", err=True)
307
+ raise typer.Exit(code=1)