mozbridge-cli 0.3.0__tar.gz → 0.4.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 (56) hide show
  1. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/PKG-INFO +8 -6
  2. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/README.md +7 -5
  3. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/docs/commands.md +214 -6
  4. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/pyproject.toml +1 -1
  5. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/src/mozbridge_cli/api.py +116 -0
  6. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/src/mozbridge_cli/cmd_deploy.py +56 -0
  7. mozbridge_cli-0.4.0/src/mozbridge_cli/cmd_doctor.py +229 -0
  8. mozbridge_cli-0.4.0/src/mozbridge_cli/cmd_registry.py +134 -0
  9. mozbridge_cli-0.4.0/src/mozbridge_cli/cmd_token.py +180 -0
  10. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/src/mozbridge_cli/config.py +7 -0
  11. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/src/mozbridge_cli/main.py +23 -8
  12. mozbridge_cli-0.4.0/tests/test_deploy_preflight.py +259 -0
  13. mozbridge_cli-0.4.0/tests/test_doctor.py +407 -0
  14. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_registry.py +98 -1
  15. mozbridge_cli-0.4.0/tests/test_token.py +275 -0
  16. mozbridge_cli-0.3.0/src/mozbridge_cli/cmd_registry.py +0 -97
  17. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/.gitignore +0 -0
  18. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/scripts/release.sh +0 -0
  19. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/src/mozbridge_cli/__init__.py +0 -0
  20. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/src/mozbridge_cli/__main__.py +0 -0
  21. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/src/mozbridge_cli/auth.py +0 -0
  22. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/src/mozbridge_cli/build.py +0 -0
  23. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/src/mozbridge_cli/cmd_auth.py +0 -0
  24. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/src/mozbridge_cli/cmd_env.py +0 -0
  25. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/src/mozbridge_cli/cmd_projects.py +0 -0
  26. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/src/mozbridge_cli/cmd_publish.py +0 -0
  27. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/src/mozbridge_cli/cmd_secrets.py +0 -0
  28. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/src/mozbridge_cli/cmd_shared.py +0 -0
  29. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/src/mozbridge_cli/compose.py +0 -0
  30. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/src/mozbridge_cli/link.py +0 -0
  31. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/src/mozbridge_cli/local_build.py +0 -0
  32. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/src/mozbridge_cli/runtime_secrets.py +0 -0
  33. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/src/mozbridge_cli/session.py +0 -0
  34. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/conftest.py +0 -0
  35. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_build.py +0 -0
  36. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_ci_token_auth.py +0 -0
  37. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_compose.py +0 -0
  38. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_deploy.py +0 -0
  39. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_diff.py +0 -0
  40. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_env.py +0 -0
  41. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_link.py +0 -0
  42. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_local_build.py +0 -0
  43. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_login.py +0 -0
  44. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_login_cli.py +0 -0
  45. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_logout.py +0 -0
  46. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_logs.py +0 -0
  47. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_publish.py +0 -0
  48. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_publish_local.py +0 -0
  49. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_publish_multi_component.py +0 -0
  50. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_refresh.py +0 -0
  51. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_rollback.py +0 -0
  52. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_runtime_secrets.py +0 -0
  53. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_secrets.py +0 -0
  54. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_session_permissions.py +0 -0
  55. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.0}/tests/test_status.py +0 -0
  56. {mozbridge_cli-0.3.0 → mozbridge_cli-0.4.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.3.0
3
+ Version: 0.4.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
@@ -250,11 +250,13 @@ written, or permission-checked in this path at all. Because of that,
250
250
  means "don't attempt an interactive login") — unset it first if you actually
251
251
  want to run the device flow.
252
252
 
253
- **Where the token comes from**: a project-scoped Mozbridge `ServiceToken`,
254
- created out of band via the Mozbridge dashboard/API
255
- (`create_service_token`) — the CLI never creates or mints one itself. Set it
256
- as your CI platform's secret store, never as a `--token` flag (it would leak
257
- into shell history and process listings) and never committed to the repo.
253
+ **Where the token comes from**: a project- or org-scoped Mozbridge
254
+ `ServiceToken`. Mint one directly from this CLI — `mozbridge token create
255
+ --scope deploy` (requires an active `mozbridge login` session; see
256
+ `mozbridge token create --help`) — or out of band via the dashboard/API
257
+ (`create_service_token`). Set it as your CI platform's secret store, never
258
+ as a `--token` flag (it would leak into shell history and process
259
+ listings) and never committed to the repo.
258
260
 
259
261
  **`MOZBRIDGE_TOKEN` vs `MOZBRIDGE_BUILD_TOKEN` — do not confuse these**:
260
262
 
@@ -222,11 +222,13 @@ written, or permission-checked in this path at all. Because of that,
222
222
  means "don't attempt an interactive login") — unset it first if you actually
223
223
  want to run the device flow.
224
224
 
225
- **Where the token comes from**: a project-scoped Mozbridge `ServiceToken`,
226
- created out of band via the Mozbridge dashboard/API
227
- (`create_service_token`) — the CLI never creates or mints one itself. Set it
228
- as your CI platform's secret store, never as a `--token` flag (it would leak
229
- into shell history and process listings) and never committed to the repo.
225
+ **Where the token comes from**: a project- or org-scoped Mozbridge
226
+ `ServiceToken`. Mint one directly from this CLI — `mozbridge token create
227
+ --scope deploy` (requires an active `mozbridge login` session; see
228
+ `mozbridge token create --help`) — or out of band via the dashboard/API
229
+ (`create_service_token`). Set it as your CI platform's secret store, never
230
+ as a `--token` flag (it would leak into shell history and process
231
+ listings) and never committed to the repo.
230
232
 
231
233
  **`MOZBRIDGE_TOKEN` vs `MOZBRIDGE_BUILD_TOKEN` — do not confuse these**:
232
234
 
@@ -575,12 +575,25 @@ calls `POST /api/v1/projects/{project_id}/deploy` with
575
575
  gave a tag** — not the API's own default of deploying both — so the CLI only
576
576
  ever asks the platform to deploy what you specified.
577
577
 
578
- The backend runs a preflight check before enqueuing the deploy. A 409
579
- response means it found a real, known-to-fail condition and refused —
580
- each blocking check is printed with its title, detail, and suggested
581
- action, followed by a hint to re-run with `--force` if you want to override
582
- it. **The CLI never retries with `--force` automatically** — that is always
583
- a separate, explicit re-invocation.
578
+ Before the confirmation prompt, `deploy` also proactively calls `GET
579
+ /projects/{id}/preflight` (the platform's own pre-deploy checks) and
580
+ prints anything at `warn`/`fail` level — title, detail, and suggested
581
+ action — so a likely-to-fail condition is visible before you commit, not
582
+ just after a 409. When every check is `pass`, nothing extra is printed —
583
+ the happy path stays exactly as quiet as before this existed. A failed
584
+ lookup here (network error, etc.) is swallowed silently and never blocks
585
+ `deploy` — this is informational only, matching the backend's own
586
+ fail-open design; the real gate is still the backend's own check at
587
+ enqueue time. These checks are platform-level (registry credentials, DNS,
588
+ disk, Vault role, and similar) — they do **not** detect a project's own
589
+ compose/template misconfiguration.
590
+
591
+ The backend also runs its own preflight check before enqueuing the
592
+ deploy. A 409 response means it found a real, known-to-fail condition and
593
+ refused — each blocking check is printed with its title, detail, and
594
+ suggested action, followed by a hint to re-run with `--force` if you want
595
+ to override it. **The CLI never retries with `--force` automatically** —
596
+ that is always a separate, explicit re-invocation.
584
597
 
585
598
  **Example (`--yes`, single service)**:
586
599
  ```
@@ -737,6 +750,201 @@ Removed DATABASE_URL.
737
750
 
738
751
  ---
739
752
 
753
+ ## `mozbridge registry status` / `mozbridge registry set`
754
+
755
+ Manages the linked project's own container-registry credential, used by
756
+ `publish --local` builds and by platform-side deploys when no
757
+ platform-provisioned registry applies. GET never returns a token/secret
758
+ value — `schemas.RegistryCredentialStatus` has no such field — so there is
759
+ no `registry get` that prints one.
760
+
761
+ ### `mozbridge registry status`
762
+
763
+ **What it does**: `GET /api/v1/projects/{project_id}/registry`. If the
764
+ project has its own credential configured, prints it (`registry_url`,
765
+ `username`, `updated_at`) and stops — a project-level credential always
766
+ takes precedence. Only when the project has none does it also call `GET
767
+ /api/v1/organizations/{org_id}/registry` and report the org-level default
768
+ instead, since that's what a build/deploy will actually fall back to.
769
+ This mirrors the real resolution order
770
+ (`app/services/registry_credentials.resolve_registry_credential`: project
771
+ override → org default → none) rather than only ever showing the
772
+ project's own (possibly misleading) empty state.
773
+
774
+ ```
775
+ $ mozbridge registry status
776
+ No project-level registry credential configured.
777
+ Builds/deploys will use the ORG-level default:
778
+ registry_url: ghcr.io
779
+ username: acme-bot
780
+ updated_at: 2026-09-01T12:00:00Z
781
+ ```
782
+
783
+ If neither is configured:
784
+
785
+ ```
786
+ $ mozbridge registry status
787
+ No registry credential configured at the project or org level.
788
+ `--local` builds will fail without one — run `mozbridge registry set`.
789
+ ```
790
+
791
+ ### `mozbridge registry set`
792
+
793
+ **Flags**: `--url` (default `ghcr.io`), `--username` (required), `--token`
794
+ (required — the registry password/token, not a Mozbridge service token).
795
+
796
+ **What it does**: `PUT /api/v1/projects/{project_id}/registry`. Always
797
+ writes the **project-level** credential — there is no `--org` flag on this
798
+ command, so an org-level default is configured from the dashboard
799
+ (**Organization Settings → Container Registry**), not from here. The
800
+ backend validates the credential against the real registry before storing
801
+ it; a bad credential is rejected with the registry's own error, not
802
+ silently saved.
803
+
804
+ ```
805
+ $ mozbridge registry set --url ghcr.io --username acme-bot --token ghp_xxx
806
+ Registry credential set (ghcr.io, username=acme-bot).
807
+ ```
808
+
809
+ **Failure modes**: not linked / not logged in, exit 1. Registry rejects
810
+ the credential (bad token, wrong URL): the raw validation error, exit 1.
811
+
812
+ ---
813
+
814
+ ## `mozbridge secrets list` / `mozbridge secrets set`
815
+
816
+ Manages the linked project's Vault-backed, per-environment/per-component
817
+ secrets (`backend/app/features/projects/router.py`'s
818
+ `/environments/{env}/secrets/{component}` routes) — distinct from the
819
+ plain env-vars `env` wraps. The underlying replace-all POST endpoint
820
+ deletes any key not included in the request; this CLI deliberately never
821
+ calls it — `secrets set` uses PATCH (merge one key) only, and `api.py` has
822
+ no wrapper for the replace-all route at all, so there is nothing here
823
+ that could call it by accident.
824
+
825
+ ### `mozbridge secrets list`
826
+
827
+ **Flags**: `--component` (`backend` default, or `frontend`/`infra`),
828
+ `--env` (`prod` default), `--show-values` (fetch and print actual VALUES
829
+ instead of just key names — prompts for confirmation unless `--yes` is
830
+ also passed).
831
+
832
+ **What it does**: defaults to the keys-only endpoint — no secret value
833
+ ever leaves the server for a plain `mozbridge secrets list`. With
834
+ `--show-values`, confirms (real secret material is about to print to your
835
+ terminal and end up in scrollback/history — same gating as `deploy`) then
836
+ fetches and prints actual values.
837
+
838
+ ```
839
+ $ mozbridge secrets list
840
+ backend/prod: 2 secret(s)
841
+ DATABASE_URL
842
+ STRIPE_SECRET_KEY
843
+ ```
844
+
845
+ ### `mozbridge secrets set KEY VALUE`
846
+
847
+ **Flags**: `--component` (`backend` default), `--env` (`prod` default).
848
+
849
+ **What it does**: `PATCH .../secrets/{component}` — merges this one key
850
+ server-side over a strict read of what's already there; never touches any
851
+ other key. There is no `secrets rm` — the backend has no single-key
852
+ delete endpoint, only PATCH-to-add/update or the wholesale-replace POST
853
+ this CLI avoids on purpose. Removing a key today means the dashboard, or
854
+ accepting the replace-all risk directly.
855
+
856
+ ```
857
+ $ mozbridge secrets set STRIPE_SECRET_KEY sk_live_xxx
858
+ Set STRIPE_SECRET_KEY (backend/prod).
859
+ ```
860
+
861
+ **Failure modes (both commands)**: not linked / not logged in, exit 1.
862
+ Any other API error: the raw `ApiError` message, exit 1.
863
+
864
+ ---
865
+
866
+ ## `mozbridge token create`
867
+
868
+ Mints a new Mozbridge `ServiceToken` without leaving the CLI — wraps `POST
869
+ /api/v1/tokens` (`token_router.create_service_token`), authenticated by
870
+ the CLI's existing human login session. A service token cannot call this
871
+ endpoint to mint further tokens (enforced server-side), which is exactly
872
+ why this command requires `mozbridge login`, not `MOZBRIDGE_TOKEN`.
873
+
874
+ **Flags**:
875
+ | Flag | Meaning |
876
+ |---|---|
877
+ | `--scope` | Repeatable. Required at least once — no silent default. Real scopes: `read`, `write`, `deploy`, `admin`, `build`. |
878
+ | `--name` | Token name. Defaults to a generated `mozbridge-cli-<slug>-<random>` name. |
879
+ | `--org` | Mint an org-scoped token (bound to the linked org, no single project) instead of the default project-scoped one. |
880
+ | `--project` | Mint a project-scoped token — already the default; pass only for explicitness. Mutually exclusive with `--org`. |
881
+ | `--expires` | A single duration: `45m`, `24h`, `30d`, `2w`. Omit for a token that never expires. |
882
+
883
+ **What it does**: calls `create_service_token` with the given scopes,
884
+ bound to the linked project (default) or org (`--org`). The raw token
885
+ value is printed once, in full — the one place in this CLI where printing
886
+ a real secret to the terminal is correct, since there is no `mozbridge
887
+ token show` to retrieve it again.
888
+
889
+ ```
890
+ $ mozbridge token create --scope build --expires 30d
891
+ Token created: mozbridge-cli-acme-web-app-3f9a2c1e (project-scoped, scopes=build)
892
+ expires_at: 2026-10-14T12:00:00Z
893
+
894
+ ======================================================================
895
+ SAVE THIS TOKEN NOW — it will never be shown again:
896
+
897
+ mbt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
898
+
899
+ Store it in a password manager or your CI provider's secret store
900
+ immediately. There is no `mozbridge token show` — this is the only time
901
+ the raw value is ever returned.
902
+ ======================================================================
903
+ ```
904
+
905
+ **Failure modes**: no `--scope` given: clear error, exit 1, no API call.
906
+ Both `--org` and `--project` given: clear error, exit 1. Invalid
907
+ `--expires` format: clear error naming the expected shape, exit 1. Not
908
+ linked / not logged in: identical messages to `publish` above, exit 1.
909
+ Any other API error: the raw `ApiError` message, exit 1.
910
+
911
+ ---
912
+
913
+ ## `mozbridge doctor`
914
+
915
+ A self-diagnostic checklist for this CLI's setup — same motivation as
916
+ sibling package `cruxhive-mcp`'s own `cruxhive-doctor`: enough moving
917
+ parts (cached session, per-repo link file, registry credential, optional
918
+ build token, local docker daemon) that "is my setup actually working"
919
+ deserves one command instead of five.
920
+
921
+ **What it checks, in order**: logged in, linked (verified with a real
922
+ `GET /projects/{id}` call, not just the presence of
923
+ `.mozbridge/link.json`), project-level registry credential configured,
924
+ the platform's own pre-deploy checks (the same `GET .../preflight` call
925
+ `deploy` now runs proactively), whether `MOZBRIDGE_BUILD_TOKEN` is set,
926
+ and whether Docker is available locally. Each check is independent — one
927
+ erroring API call is reported as "could not check (error)" on its own
928
+ line and never prevents the rest from running.
929
+
930
+ ```
931
+ $ mozbridge doctor
932
+ ✓ Logged in: session valid
933
+ ✓ Linked: acme/web-app (.mozbridge/link.json present) — resolved via API
934
+ ○ Registry configured: no project-level registry credential configured (normal, not an error — ...)
935
+ ✓ Pre-deploy checks: all clear (runs the platform's own pre-deploy checks)
936
+ ○ MOZBRIDGE_BUILD_TOKEN set: not set — only required for `mozbridge publish --local`
937
+ ✓ Docker available: docker CLI + daemon reachable
938
+ ```
939
+
940
+ **Exit code**: non-zero only when a MEANINGFUL gate failed — not logged
941
+ in, or not linked. Registry, pre-deploy checks, `MOZBRIDGE_BUILD_TOKEN`,
942
+ and Docker are informational only: none of them are required for every
943
+ workflow this CLI supports, so a "not configured"/"not set"/"not found"
944
+ result never fails the command.
945
+
946
+ ---
947
+
740
948
  ## A note on authorization for `publish` / `rollback`
741
949
 
742
950
  Both commands work today because a plain human Logto session token passes
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mozbridge-cli"
7
- version = "0.3.0"
7
+ version = "0.4.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"
@@ -324,6 +324,33 @@ def get_project_registry(client: httpx.Client, access_token: str, org_id: int, p
324
324
  return resp.json()
325
325
 
326
326
 
327
+ def get_org_registry(client: httpx.Client, access_token: str, org_id: int) -> dict:
328
+ """GET /api/v1/organizations/{org_id}/registry -> schemas.RegistryCredentialStatus.
329
+
330
+ Same response shape as get_project_registry above — {configured,
331
+ registry_url, username, updated_at}, token never included
332
+ (backend/app/features/identity/router.py:521-537's get_org_registry
333
+ mirrors the project-level route's own never-return-the-token design).
334
+ This is the org's default registry credential, used as a fallback for
335
+ any project that has no project-level override — see
336
+ app/services/registry_credentials.resolve_registry_credential, whose
337
+ real resolution order is project override -> org default -> none.
338
+
339
+ No X-Organization-Id header: get_org_registry is gated by
340
+ ensure_permify_org_access(user, org_id, db), which takes org_id
341
+ straight from the path, not from that header — same as
342
+ list_organizations above, the only other /organizations route this
343
+ module calls.
344
+ """
345
+ url = f"{config.API_BASE_URL}{config.ORGANIZATIONS_PATH}/{org_id}/registry"
346
+ try:
347
+ resp = client.get(url, headers=_headers(access_token))
348
+ except httpx.HTTPError as exc:
349
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
350
+ _raise_for_status(resp, "fetch the org registry credential status")
351
+ return resp.json()
352
+
353
+
327
354
  def set_project_registry(
328
355
  client: httpx.Client,
329
356
  access_token: str,
@@ -503,6 +530,47 @@ def deploy_project(
503
530
  return resp.json()
504
531
 
505
532
 
533
+ def get_project_preflight(client: httpx.Client, access_token: str, org_id: int, project_id: int) -> dict:
534
+ """GET /api/v1/projects/{project_id}/preflight -> preflight.Preflight.as_dict().
535
+
536
+ Read-only (backend/app/features/projects/router.py:1534's
537
+ project_preflight docstring: "nothing here provisions, binds or
538
+ writes"). Returns:
539
+
540
+ {project_id, project_slug, host, can_deploy,
541
+ blocking: [key, ...], warnings: [key, ...],
542
+ checks: [{key, level, title, detail, action}, ...]}
543
+
544
+ (see backend/app/services/preflight.py's Preflight.as_dict; `level` is
545
+ one of "pass"/"warn"/"fail", `action` is None when there's nothing to
546
+ do). `can_deploy` is `not blocking` — exactly the 10 checks
547
+ run_preflight assembles (placement, host_ready, architecture, dns,
548
+ registry, disk, vault_role, site_placement, secrets, observability).
549
+ This is the SAME gate `deploy_project` above already hits reactively as
550
+ a 409 (`_assert_preflight_clear` calls this same `run_preflight`); this
551
+ wrapper lets a caller run it proactively, before committing to a
552
+ deploy, not instead of that reactive path.
553
+
554
+ Does not detect a project's compose/template mismatch or any other
555
+ app-specific misconfiguration — none of the 10 checks cover that class
556
+ of problem. Callers must not describe this as catching it.
557
+
558
+ The backend's own gate fails OPEN: if preflight itself cannot be
559
+ computed (vault down, host lookup error, etc.), the real deploy is not
560
+ blocked. This read-only endpoint has no gate to fail open around, but a
561
+ caller of this wrapper should apply the same philosophy on its own
562
+ side: treat an ApiError here as "could not check", not as a reason to
563
+ block anything itself.
564
+ """
565
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/preflight"
566
+ try:
567
+ resp = client.get(url, headers=_headers(access_token, org_id))
568
+ except httpx.HTTPError as exc:
569
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
570
+ _raise_for_status(resp, "run pre-deploy checks")
571
+ return resp.json()
572
+
573
+
506
574
  def list_project_operations(
507
575
  client: httpx.Client, access_token: str, org_id: int, project_id: int, *, limit: int = 1
508
576
  ) -> list[dict]:
@@ -541,6 +609,54 @@ def get_operation(client: httpx.Client, access_token: str, org_id: int, task_id:
541
609
  return resp.json()
542
610
 
543
611
 
612
+ def create_service_token(
613
+ client: httpx.Client,
614
+ access_token: str,
615
+ org_id: int,
616
+ *,
617
+ name: str,
618
+ token_type: str,
619
+ scopes: list[str],
620
+ project_id: int | None = None,
621
+ expires_at: str | None = None,
622
+ ) -> dict:
623
+ """POST /api/v1/tokens, body identity.token_router.TokenCreate.
624
+
625
+ Authenticated by the caller's regular LOGIN session access_token, never
626
+ a service token — create_service_token (token_router.py:146) is gated
627
+ by get_current_user and explicitly rejects a service-token caller (403
628
+ "Service tokens cannot mint new tokens"), a deliberate anti-escalation
629
+ design. `org_id` is sent as the X-Organization-Id header for
630
+ consistency with every other call in this module, though this route
631
+ does not itself depend on get_current_org: `org_id` in the JSON body
632
+ only matters for an org-scoped token (the backend 400s without it), and
633
+ is ignored/overwritten server-side for a project-scoped one
634
+ (project.organization_id wins over anything the client sends).
635
+
636
+ `project_id`/`expires_at` are omitted from the body entirely when None,
637
+ same "let the backend's own default apply" convention as trigger_build
638
+ above — expires_at omitted means the token never expires (schemas
639
+ default), project_id omitted means an org-scoped token.
640
+
641
+ Returns TokenCreated: the full token record plus `token`, the raw value
642
+ — shown exactly once here, never retrievable from the API again.
643
+ """
644
+ payload: dict = {"name": name, "type": token_type, "scopes": scopes}
645
+ if project_id is not None:
646
+ payload["project_id"] = project_id
647
+ if token_type == "org":
648
+ payload["org_id"] = org_id
649
+ if expires_at is not None:
650
+ payload["expires_at"] = expires_at
651
+ url = f"{config.API_BASE_URL}{config.TOKENS_PATH}"
652
+ try:
653
+ resp = client.post(url, json=payload, headers=_headers(access_token, org_id))
654
+ except httpx.HTTPError as exc:
655
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
656
+ _raise_for_status(resp, "create the service token")
657
+ return resp.json()
658
+
659
+
544
660
  def operation_stream_url(task_id: str) -> str:
545
661
  """URL for GET /api/v1/projects/operations/{task_id}/stream (SSE).
546
662
 
@@ -14,6 +14,33 @@ from .cmd_shared import require_link_and_session
14
14
  app = typer.Typer(add_completion=False)
15
15
 
16
16
 
17
+ def _print_preflight_notice(preflight: dict) -> None:
18
+ """Print anything the platform's pre-deploy checks flagged, before the
19
+ confirm prompt. Silent when everything is clean — the happy path stays
20
+ exactly as quiet/fast as it was before this existed, no "10 lines of
21
+ ok" noise.
22
+
23
+ `preflight` is api.get_project_preflight's response
24
+ ({can_deploy, checks: [{key, level, title, detail, action}, ...], ...}
25
+ — see that function's docstring for the full shape). Only "warn"/"fail"
26
+ level checks are ever shown; "pass" checks are never printed.
27
+ """
28
+ checks = preflight.get("checks") or []
29
+ notable = [c for c in checks if c.get("level") in ("fail", "warn")]
30
+ if not notable:
31
+ return
32
+
33
+ typer.echo("The platform's pre-deploy checks found something to flag:")
34
+ for check in notable:
35
+ marker = "FAIL" if check.get("level") == "fail" else "WARN"
36
+ title = check.get("title") or check.get("key") or "(unnamed check)"
37
+ detail = check.get("detail") or ""
38
+ typer.echo(f" [{marker}] {title}: {detail}")
39
+ if check.get("action"):
40
+ typer.echo(f" action: {check['action']}")
41
+ typer.echo()
42
+
43
+
17
44
  @app.command("status")
18
45
  def status_cmd() -> None:
19
46
  """Show the linked project's status and its 5 most recent deployments."""
@@ -126,6 +153,24 @@ def deploy_cmd(
126
153
  Only the services you actually gave a tag for are deployed — `services`
127
154
  is built from --frontend/--backend, not left to the API's own default of
128
155
  both, unless you passed both.
156
+
157
+ Before the confirmation prompt, this also runs the platform's own
158
+ pre-deploy checks (GET .../preflight — the same 10 checks the backend
159
+ already runs reactively on every deploy attempt, covering placement,
160
+ host readiness, architecture, DNS, registry auth, disk, vault role,
161
+ site placement, secrets, and observability) and prints anything that
162
+ isn't clean, so a real, known-to-fail condition is visible before you
163
+ confirm rather than only after a rejected attempt. This is additive:
164
+ the existing reactive handling of a 409 preflight-blocked response
165
+ below is unchanged and still the actual enforcement point — this
166
+ earlier check is informational only and cannot itself refuse the
167
+ deploy. It does not check for a project's compose/template mismatch or
168
+ any other app-specific misconfiguration; that class of problem is
169
+ outside what these 10 checks cover. If the check itself cannot be run
170
+ (network error, backend error), this command notes that plainly and
171
+ still proceeds to the normal confirmation flow — matching the
172
+ backend's own fail-open design for this gate rather than being
173
+ stricter than the platform itself is.
129
174
  """
130
175
  if frontend is None and backend is None:
131
176
  typer.echo(
@@ -145,6 +190,17 @@ def deploy_cmd(
145
190
  with httpx.Client(timeout=30.0) as client:
146
191
  current_link, active_session = require_link_and_session(client, cwd)
147
192
 
193
+ try:
194
+ preflight = api.get_project_preflight(
195
+ client, active_session.access_token, current_link.org_id, current_link.project_id
196
+ )
197
+ except api.ApiError as exc:
198
+ typer.echo(f"Note: {exc} — proceeding without them.")
199
+ except Exception as exc: # noqa: BLE001 - a preflight lookup failure must never block deploy
200
+ typer.echo(f"Note: could not run pre-deploy checks (error: {exc}) — proceeding without them.")
201
+ else:
202
+ _print_preflight_notice(preflight)
203
+
148
204
  project_desc = f"{current_link.org_slug}/{current_link.project_slug}"
149
205
  typer.echo(f"This will deploy {project_desc} ({environment}):")
150
206
  if frontend is not None: