mozbridge-cli 0.3.0__tar.gz → 0.5.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 (58) hide show
  1. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/PKG-INFO +8 -6
  2. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/README.md +7 -5
  3. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/docs/commands.md +300 -6
  4. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/pyproject.toml +1 -1
  5. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/api.py +143 -0
  6. mozbridge_cli-0.5.0/src/mozbridge_cli/cmd_config.py +110 -0
  7. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_deploy.py +56 -0
  8. mozbridge_cli-0.5.0/src/mozbridge_cli/cmd_doctor.py +229 -0
  9. mozbridge_cli-0.5.0/src/mozbridge_cli/cmd_registry.py +134 -0
  10. mozbridge_cli-0.5.0/src/mozbridge_cli/cmd_token.py +180 -0
  11. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/config.py +7 -0
  12. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/main.py +25 -8
  13. mozbridge_cli-0.5.0/tests/test_config.py +244 -0
  14. mozbridge_cli-0.5.0/tests/test_deploy_preflight.py +259 -0
  15. mozbridge_cli-0.5.0/tests/test_doctor.py +407 -0
  16. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_registry.py +98 -1
  17. mozbridge_cli-0.5.0/tests/test_token.py +275 -0
  18. mozbridge_cli-0.3.0/src/mozbridge_cli/cmd_registry.py +0 -97
  19. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/.gitignore +0 -0
  20. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/scripts/release.sh +0 -0
  21. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/__init__.py +0 -0
  22. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/__main__.py +0 -0
  23. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/auth.py +0 -0
  24. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/build.py +0 -0
  25. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_auth.py +0 -0
  26. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_env.py +0 -0
  27. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_projects.py +0 -0
  28. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_publish.py +0 -0
  29. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_secrets.py +0 -0
  30. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_shared.py +0 -0
  31. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/compose.py +0 -0
  32. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/link.py +0 -0
  33. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/local_build.py +0 -0
  34. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/runtime_secrets.py +0 -0
  35. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/session.py +0 -0
  36. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/conftest.py +0 -0
  37. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_build.py +0 -0
  38. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_ci_token_auth.py +0 -0
  39. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_compose.py +0 -0
  40. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_deploy.py +0 -0
  41. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_diff.py +0 -0
  42. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_env.py +0 -0
  43. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_link.py +0 -0
  44. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_local_build.py +0 -0
  45. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_login.py +0 -0
  46. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_login_cli.py +0 -0
  47. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_logout.py +0 -0
  48. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_logs.py +0 -0
  49. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_publish.py +0 -0
  50. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_publish_local.py +0 -0
  51. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_publish_multi_component.py +0 -0
  52. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_refresh.py +0 -0
  53. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_rollback.py +0 -0
  54. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_runtime_secrets.py +0 -0
  55. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_secrets.py +0 -0
  56. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_session_permissions.py +0 -0
  57. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.0}/tests/test_status.py +0 -0
  58. {mozbridge_cli-0.3.0 → mozbridge_cli-0.5.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.5.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,287 @@ 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 config set` / `mozbridge config get`
815
+
816
+ Manages one key on the linked project's `config`
817
+ (`PUT /api/v1/projects/{project_id}`, body `{"config": {KEY: VALUE}}`) —
818
+ the same allowlisted settings the platform template reads
819
+ (`app_compose_file`, `backend_port`, `data_volumes`, and anything else on
820
+ `schemas.PROJECT_CONFIG_ALLOWLIST` in
821
+ `backend/app/features/projects/schemas.py`). This wraps an existing
822
+ endpoint with zero backend changes — the same one `env`/`registry`/
823
+ `secrets` wrap, using the normal logged-in session (`get_valid_project`),
824
+ no special scope.
825
+
826
+ **Requires**: link + login, same as `env`/`registry`.
827
+
828
+ ### `mozbridge config set KEY VALUE`
829
+
830
+ **What it does**: `PUT /api/v1/projects/{project_id}` with body
831
+ `{"config": {KEY: VALUE}}`. **This merges into the existing config, it
832
+ does not replace it** — read directly off
833
+ `backend/app/features/projects/router.py`'s `update_project`:
834
+ `current_config.update(incoming)`, so setting one key never touches any
835
+ other key already set.
836
+
837
+ `KEY` is checked against `PROJECT_CONFIG_ALLOWLIST` **server-side only**
838
+ — there is no client-side copy of that list in this CLI, and none is
839
+ maintained here. A `KEY` not on the allowlist gets the whole request
840
+ rejected with a 400:
841
+
842
+ ```
843
+ $ mozbridge config set totally_made_up_key x
844
+ Could not set the project config: Unknown or privileged config keys rejected: totally_made_up_key
845
+ ```
846
+
847
+ On success, the confirmation is read back off the response body's
848
+ `config` field — not echoed from what was typed — so it reflects what
849
+ the backend actually stored:
850
+
851
+ ```
852
+ $ mozbridge config set app_compose_file compose.custom.yml
853
+ Set app_compose_file = compose.custom.yml
854
+ ```
855
+
856
+ An empty-string `VALUE` is passed through unchanged, same posture as
857
+ `env set` — this command does not second-guess values.
858
+
859
+ ### `mozbridge config get KEY`
860
+
861
+ **What it does**: `GET /api/v1/projects/{project_id}` (the same call
862
+ `status`/`doctor` already make), prints the current value of `KEY` from
863
+ the response's `config` field:
864
+
865
+ ```
866
+ $ mozbridge config get app_compose_file
867
+ app_compose_file = compose.custom.yml
868
+ ```
869
+
870
+ If `KEY` isn't present in the project's config, prints a clear message
871
+ instead of a raw `None`/`null`:
872
+
873
+ ```
874
+ $ mozbridge config get backend_port
875
+ backend_port is not set.
876
+ ```
877
+
878
+ **This command does not validate `KEY` against any allowlist** — it
879
+ prints whatever is actually stored, including a key that isn't (or is no
880
+ longer) on `PROJECT_CONFIG_ALLOWLIST`, e.g. a leftover from before it was
881
+ removed. It mirrors reality; it is not a gatekeeper.
882
+
883
+ **No `config list` / `config unset`**: unlike `env`, which has a real
884
+ `DELETE /env-vars/{key}` route, the backend has no operation to delete a
885
+ single arbitrary config key — only `set` (merge) and reading the whole
886
+ project. Inventing client-side behavior for something the backend can't
887
+ actually do would be misleading, so neither subcommand exists.
888
+
889
+ **Failure modes**:
890
+ - Not linked / not logged in: identical messages to `publish` above,
891
+ exit 1.
892
+ - `set` against a key not on the backend's allowlist: the raw `ApiError`
893
+ message (`Could not set the project config: Unknown or privileged
894
+ config keys rejected: <key>`), exit 1.
895
+ - Any other API error (e.g. a 500 from the database): the raw `ApiError`
896
+ message, exit 1.
897
+
898
+ ---
899
+
900
+ ## `mozbridge secrets list` / `mozbridge secrets set`
901
+
902
+ Manages the linked project's Vault-backed, per-environment/per-component
903
+ secrets (`backend/app/features/projects/router.py`'s
904
+ `/environments/{env}/secrets/{component}` routes) — distinct from the
905
+ plain env-vars `env` wraps. The underlying replace-all POST endpoint
906
+ deletes any key not included in the request; this CLI deliberately never
907
+ calls it — `secrets set` uses PATCH (merge one key) only, and `api.py` has
908
+ no wrapper for the replace-all route at all, so there is nothing here
909
+ that could call it by accident.
910
+
911
+ ### `mozbridge secrets list`
912
+
913
+ **Flags**: `--component` (`backend` default, or `frontend`/`infra`),
914
+ `--env` (`prod` default), `--show-values` (fetch and print actual VALUES
915
+ instead of just key names — prompts for confirmation unless `--yes` is
916
+ also passed).
917
+
918
+ **What it does**: defaults to the keys-only endpoint — no secret value
919
+ ever leaves the server for a plain `mozbridge secrets list`. With
920
+ `--show-values`, confirms (real secret material is about to print to your
921
+ terminal and end up in scrollback/history — same gating as `deploy`) then
922
+ fetches and prints actual values.
923
+
924
+ ```
925
+ $ mozbridge secrets list
926
+ backend/prod: 2 secret(s)
927
+ DATABASE_URL
928
+ STRIPE_SECRET_KEY
929
+ ```
930
+
931
+ ### `mozbridge secrets set KEY VALUE`
932
+
933
+ **Flags**: `--component` (`backend` default), `--env` (`prod` default).
934
+
935
+ **What it does**: `PATCH .../secrets/{component}` — merges this one key
936
+ server-side over a strict read of what's already there; never touches any
937
+ other key. There is no `secrets rm` — the backend has no single-key
938
+ delete endpoint, only PATCH-to-add/update or the wholesale-replace POST
939
+ this CLI avoids on purpose. Removing a key today means the dashboard, or
940
+ accepting the replace-all risk directly.
941
+
942
+ ```
943
+ $ mozbridge secrets set STRIPE_SECRET_KEY sk_live_xxx
944
+ Set STRIPE_SECRET_KEY (backend/prod).
945
+ ```
946
+
947
+ **Failure modes (both commands)**: not linked / not logged in, exit 1.
948
+ Any other API error: the raw `ApiError` message, exit 1.
949
+
950
+ ---
951
+
952
+ ## `mozbridge token create`
953
+
954
+ Mints a new Mozbridge `ServiceToken` without leaving the CLI — wraps `POST
955
+ /api/v1/tokens` (`token_router.create_service_token`), authenticated by
956
+ the CLI's existing human login session. A service token cannot call this
957
+ endpoint to mint further tokens (enforced server-side), which is exactly
958
+ why this command requires `mozbridge login`, not `MOZBRIDGE_TOKEN`.
959
+
960
+ **Flags**:
961
+ | Flag | Meaning |
962
+ |---|---|
963
+ | `--scope` | Repeatable. Required at least once — no silent default. Real scopes: `read`, `write`, `deploy`, `admin`, `build`. |
964
+ | `--name` | Token name. Defaults to a generated `mozbridge-cli-<slug>-<random>` name. |
965
+ | `--org` | Mint an org-scoped token (bound to the linked org, no single project) instead of the default project-scoped one. |
966
+ | `--project` | Mint a project-scoped token — already the default; pass only for explicitness. Mutually exclusive with `--org`. |
967
+ | `--expires` | A single duration: `45m`, `24h`, `30d`, `2w`. Omit for a token that never expires. |
968
+
969
+ **What it does**: calls `create_service_token` with the given scopes,
970
+ bound to the linked project (default) or org (`--org`). The raw token
971
+ value is printed once, in full — the one place in this CLI where printing
972
+ a real secret to the terminal is correct, since there is no `mozbridge
973
+ token show` to retrieve it again.
974
+
975
+ ```
976
+ $ mozbridge token create --scope build --expires 30d
977
+ Token created: mozbridge-cli-acme-web-app-3f9a2c1e (project-scoped, scopes=build)
978
+ expires_at: 2026-10-14T12:00:00Z
979
+
980
+ ======================================================================
981
+ SAVE THIS TOKEN NOW — it will never be shown again:
982
+
983
+ mbt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
984
+
985
+ Store it in a password manager or your CI provider's secret store
986
+ immediately. There is no `mozbridge token show` — this is the only time
987
+ the raw value is ever returned.
988
+ ======================================================================
989
+ ```
990
+
991
+ **Failure modes**: no `--scope` given: clear error, exit 1, no API call.
992
+ Both `--org` and `--project` given: clear error, exit 1. Invalid
993
+ `--expires` format: clear error naming the expected shape, exit 1. Not
994
+ linked / not logged in: identical messages to `publish` above, exit 1.
995
+ Any other API error: the raw `ApiError` message, exit 1.
996
+
997
+ ---
998
+
999
+ ## `mozbridge doctor`
1000
+
1001
+ A self-diagnostic checklist for this CLI's setup — same motivation as
1002
+ sibling package `cruxhive-mcp`'s own `cruxhive-doctor`: enough moving
1003
+ parts (cached session, per-repo link file, registry credential, optional
1004
+ build token, local docker daemon) that "is my setup actually working"
1005
+ deserves one command instead of five.
1006
+
1007
+ **What it checks, in order**: logged in, linked (verified with a real
1008
+ `GET /projects/{id}` call, not just the presence of
1009
+ `.mozbridge/link.json`), project-level registry credential configured,
1010
+ the platform's own pre-deploy checks (the same `GET .../preflight` call
1011
+ `deploy` now runs proactively), whether `MOZBRIDGE_BUILD_TOKEN` is set,
1012
+ and whether Docker is available locally. Each check is independent — one
1013
+ erroring API call is reported as "could not check (error)" on its own
1014
+ line and never prevents the rest from running.
1015
+
1016
+ ```
1017
+ $ mozbridge doctor
1018
+ ✓ Logged in: session valid
1019
+ ✓ Linked: acme/web-app (.mozbridge/link.json present) — resolved via API
1020
+ ○ Registry configured: no project-level registry credential configured (normal, not an error — ...)
1021
+ ✓ Pre-deploy checks: all clear (runs the platform's own pre-deploy checks)
1022
+ ○ MOZBRIDGE_BUILD_TOKEN set: not set — only required for `mozbridge publish --local`
1023
+ ✓ Docker available: docker CLI + daemon reachable
1024
+ ```
1025
+
1026
+ **Exit code**: non-zero only when a MEANINGFUL gate failed — not logged
1027
+ in, or not linked. Registry, pre-deploy checks, `MOZBRIDGE_BUILD_TOKEN`,
1028
+ and Docker are informational only: none of them are required for every
1029
+ workflow this CLI supports, so a "not configured"/"not set"/"not found"
1030
+ result never fails the command.
1031
+
1032
+ ---
1033
+
740
1034
  ## A note on authorization for `publish` / `rollback`
741
1035
 
742
1036
  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.5.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,
@@ -351,6 +378,33 @@ def set_project_registry(
351
378
  return resp.json()
352
379
 
353
380
 
381
+ def set_project_config(
382
+ client: httpx.Client, access_token: str, org_id: int, project_id: int, key: str, value: str
383
+ ) -> dict:
384
+ """PUT /api/v1/projects/{project_id}, body schemas.ProjectUpdate {"config": {key: value}}.
385
+
386
+ router.py's update_project merges `incoming` into the project's existing
387
+ config (`current_config.update(incoming)`) — it does NOT replace the
388
+ whole config, so this never touches any other key. Unknown/privileged
389
+ keys (anything not on schemas.PROJECT_CONFIG_ALLOWLIST) come back as a
390
+ 400 with `"Unknown or privileged config keys rejected: <key>"`, which
391
+ `_raise_for_status` already surfaces verbatim via ApiError, same as
392
+ every other function in this module.
393
+
394
+ Returns the full updated schemas.Project — the caller reads the
395
+ confirmed value back off its `config` field rather than trusting what
396
+ was sent, in case the backend did anything to it.
397
+ """
398
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}"
399
+ payload = {"config": {key: value}}
400
+ try:
401
+ resp = client.put(url, json=payload, headers=_headers(access_token, org_id))
402
+ except httpx.HTTPError as exc:
403
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
404
+ _raise_for_status(resp, "set the project config")
405
+ return resp.json()
406
+
407
+
354
408
  def _secrets_url(project_id: int, env_slug: str, component: str) -> str:
355
409
  """Shared path builder for the four secrets endpoints below — keeps the
356
410
  env_slug/component path segments byte-for-byte identical across list
@@ -503,6 +557,47 @@ def deploy_project(
503
557
  return resp.json()
504
558
 
505
559
 
560
+ def get_project_preflight(client: httpx.Client, access_token: str, org_id: int, project_id: int) -> dict:
561
+ """GET /api/v1/projects/{project_id}/preflight -> preflight.Preflight.as_dict().
562
+
563
+ Read-only (backend/app/features/projects/router.py:1534's
564
+ project_preflight docstring: "nothing here provisions, binds or
565
+ writes"). Returns:
566
+
567
+ {project_id, project_slug, host, can_deploy,
568
+ blocking: [key, ...], warnings: [key, ...],
569
+ checks: [{key, level, title, detail, action}, ...]}
570
+
571
+ (see backend/app/services/preflight.py's Preflight.as_dict; `level` is
572
+ one of "pass"/"warn"/"fail", `action` is None when there's nothing to
573
+ do). `can_deploy` is `not blocking` — exactly the 10 checks
574
+ run_preflight assembles (placement, host_ready, architecture, dns,
575
+ registry, disk, vault_role, site_placement, secrets, observability).
576
+ This is the SAME gate `deploy_project` above already hits reactively as
577
+ a 409 (`_assert_preflight_clear` calls this same `run_preflight`); this
578
+ wrapper lets a caller run it proactively, before committing to a
579
+ deploy, not instead of that reactive path.
580
+
581
+ Does not detect a project's compose/template mismatch or any other
582
+ app-specific misconfiguration — none of the 10 checks cover that class
583
+ of problem. Callers must not describe this as catching it.
584
+
585
+ The backend's own gate fails OPEN: if preflight itself cannot be
586
+ computed (vault down, host lookup error, etc.), the real deploy is not
587
+ blocked. This read-only endpoint has no gate to fail open around, but a
588
+ caller of this wrapper should apply the same philosophy on its own
589
+ side: treat an ApiError here as "could not check", not as a reason to
590
+ block anything itself.
591
+ """
592
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/preflight"
593
+ try:
594
+ resp = client.get(url, headers=_headers(access_token, org_id))
595
+ except httpx.HTTPError as exc:
596
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
597
+ _raise_for_status(resp, "run pre-deploy checks")
598
+ return resp.json()
599
+
600
+
506
601
  def list_project_operations(
507
602
  client: httpx.Client, access_token: str, org_id: int, project_id: int, *, limit: int = 1
508
603
  ) -> list[dict]:
@@ -541,6 +636,54 @@ def get_operation(client: httpx.Client, access_token: str, org_id: int, task_id:
541
636
  return resp.json()
542
637
 
543
638
 
639
+ def create_service_token(
640
+ client: httpx.Client,
641
+ access_token: str,
642
+ org_id: int,
643
+ *,
644
+ name: str,
645
+ token_type: str,
646
+ scopes: list[str],
647
+ project_id: int | None = None,
648
+ expires_at: str | None = None,
649
+ ) -> dict:
650
+ """POST /api/v1/tokens, body identity.token_router.TokenCreate.
651
+
652
+ Authenticated by the caller's regular LOGIN session access_token, never
653
+ a service token — create_service_token (token_router.py:146) is gated
654
+ by get_current_user and explicitly rejects a service-token caller (403
655
+ "Service tokens cannot mint new tokens"), a deliberate anti-escalation
656
+ design. `org_id` is sent as the X-Organization-Id header for
657
+ consistency with every other call in this module, though this route
658
+ does not itself depend on get_current_org: `org_id` in the JSON body
659
+ only matters for an org-scoped token (the backend 400s without it), and
660
+ is ignored/overwritten server-side for a project-scoped one
661
+ (project.organization_id wins over anything the client sends).
662
+
663
+ `project_id`/`expires_at` are omitted from the body entirely when None,
664
+ same "let the backend's own default apply" convention as trigger_build
665
+ above — expires_at omitted means the token never expires (schemas
666
+ default), project_id omitted means an org-scoped token.
667
+
668
+ Returns TokenCreated: the full token record plus `token`, the raw value
669
+ — shown exactly once here, never retrievable from the API again.
670
+ """
671
+ payload: dict = {"name": name, "type": token_type, "scopes": scopes}
672
+ if project_id is not None:
673
+ payload["project_id"] = project_id
674
+ if token_type == "org":
675
+ payload["org_id"] = org_id
676
+ if expires_at is not None:
677
+ payload["expires_at"] = expires_at
678
+ url = f"{config.API_BASE_URL}{config.TOKENS_PATH}"
679
+ try:
680
+ resp = client.post(url, json=payload, headers=_headers(access_token, org_id))
681
+ except httpx.HTTPError as exc:
682
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
683
+ _raise_for_status(resp, "create the service token")
684
+ return resp.json()
685
+
686
+
544
687
  def operation_stream_url(task_id: str) -> str:
545
688
  """URL for GET /api/v1/projects/operations/{task_id}/stream (SSE).
546
689
 
@@ -0,0 +1,110 @@
1
+ """`mozbridge config` sub-typer — set / get one key on the linked project's config.
2
+
3
+ Same wrapping pattern as cmd_env.py/cmd_registry.py: this is a thin CLI layer
4
+ over a real, already-shipped backend feature — `PUT /api/v1/projects/{id}`
5
+ (backend/app/features/projects/router.py's `update_project`), which merges
6
+ `{"config": {key: value}}` into the project's existing config rather than
7
+ replacing it, and rejects any key not on schemas.PROJECT_CONFIG_ALLOWLIST
8
+ with a 400.
9
+
10
+ Deliberately does NOT duplicate that allowlist here. The backend is the
11
+ single source of truth for what's valid; this CLI just surfaces whatever
12
+ 400 it returns, same posture as the rest of this CLI not re-validating
13
+ what a real endpoint already validates server-side.
14
+
15
+ Deliberately no `config list` / `config unset`: the backend has no
16
+ delete-a-single-config-key operation for arbitrary config (unlike `env`,
17
+ which has a real DELETE route), so there is nothing for either to wrap.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from pathlib import Path
23
+
24
+ import httpx
25
+ import typer
26
+
27
+ from . import api
28
+ from .cmd_shared import require_link_and_session
29
+
30
+ app = typer.Typer(
31
+ name="config",
32
+ help="Manage the linked project's config (set, get).",
33
+ no_args_is_help=True,
34
+ add_completion=False,
35
+ )
36
+
37
+
38
+ @app.command("set")
39
+ def config_set_cmd(
40
+ key: str = typer.Argument(..., help="Config key (must be on the backend's allowlist)."),
41
+ value: str = typer.Argument(..., help="Config value."),
42
+ ) -> None:
43
+ """Set one key on the linked project's config.
44
+
45
+ Requires `mozbridge link` and an active login session, same as `env
46
+ set`. The real endpoint (PUT .../projects/{id}) merges this key into
47
+ the project's existing config — it never touches any other key — and
48
+ rejects a key that isn't on the backend's config allowlist with a 400
49
+ surfaced here verbatim (e.g. "Unknown or privileged config keys
50
+ rejected: <key>").
51
+
52
+ The confirmation reads the value back off the response's `config` dict
53
+ rather than echoing what was typed, so it reflects what the backend
54
+ actually stored.
55
+ """
56
+ cwd = Path.cwd()
57
+
58
+ with httpx.Client(timeout=30.0) as client:
59
+ current_link, active_session = require_link_and_session(client, cwd)
60
+
61
+ try:
62
+ project = api.set_project_config(
63
+ client,
64
+ active_session.access_token,
65
+ current_link.org_id,
66
+ current_link.project_id,
67
+ key,
68
+ value,
69
+ )
70
+ except api.ApiError as exc:
71
+ typer.echo(str(exc), err=True)
72
+ raise typer.Exit(code=1)
73
+
74
+ stored_value = (project.get("config") or {}).get(key)
75
+ typer.echo(f"Set {key} = {stored_value}")
76
+
77
+
78
+ @app.command("get")
79
+ def config_get_cmd(
80
+ key: str = typer.Argument(..., help="Config key to read."),
81
+ ) -> None:
82
+ """Print the current value of one key on the linked project's config.
83
+
84
+ Requires `mozbridge link` and an active login session. This is a
85
+ mirror of reality, not a gatekeeper: it does NOT validate `key`
86
+ against the backend's config allowlist, so it will happily print a
87
+ key that isn't (or is no longer) allowlisted, e.g. a value left over
88
+ from before a key was removed from it. A key that is absent from the
89
+ project's config prints a clear "not set" message, never a raw
90
+ `None`/`null`.
91
+ """
92
+ cwd = Path.cwd()
93
+
94
+ with httpx.Client(timeout=30.0) as client:
95
+ current_link, active_session = require_link_and_session(client, cwd)
96
+
97
+ try:
98
+ project = api.get_project(
99
+ client, active_session.access_token, current_link.org_id, current_link.project_id
100
+ )
101
+ except api.ApiError as exc:
102
+ typer.echo(str(exc), err=True)
103
+ raise typer.Exit(code=1)
104
+
105
+ project_config = project.get("config") or {}
106
+ if key not in project_config:
107
+ typer.echo(f"{key} is not set.")
108
+ return
109
+
110
+ typer.echo(f"{key} = {project_config[key]}")