mozbridge-cli 0.5.0__tar.gz → 0.6.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 (59) hide show
  1. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/PKG-INFO +1 -1
  2. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/docs/commands.md +121 -0
  3. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/pyproject.toml +1 -1
  4. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/api.py +107 -0
  5. mozbridge_cli-0.6.0/src/mozbridge_cli/cmd_adopt.py +179 -0
  6. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/main.py +11 -8
  7. mozbridge_cli-0.6.0/tests/test_adopt.py +320 -0
  8. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/.gitignore +0 -0
  9. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/README.md +0 -0
  10. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/scripts/release.sh +0 -0
  11. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/__init__.py +0 -0
  12. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/__main__.py +0 -0
  13. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/auth.py +0 -0
  14. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/build.py +0 -0
  15. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_auth.py +0 -0
  16. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_config.py +0 -0
  17. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_deploy.py +0 -0
  18. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_doctor.py +0 -0
  19. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_env.py +0 -0
  20. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_projects.py +0 -0
  21. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_publish.py +0 -0
  22. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_registry.py +0 -0
  23. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_secrets.py +0 -0
  24. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_shared.py +0 -0
  25. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_token.py +0 -0
  26. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/compose.py +0 -0
  27. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/config.py +0 -0
  28. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/link.py +0 -0
  29. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/local_build.py +0 -0
  30. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/runtime_secrets.py +0 -0
  31. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/session.py +0 -0
  32. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/conftest.py +0 -0
  33. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_build.py +0 -0
  34. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_ci_token_auth.py +0 -0
  35. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_compose.py +0 -0
  36. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_config.py +0 -0
  37. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_deploy.py +0 -0
  38. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_deploy_preflight.py +0 -0
  39. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_diff.py +0 -0
  40. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_doctor.py +0 -0
  41. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_env.py +0 -0
  42. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_link.py +0 -0
  43. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_local_build.py +0 -0
  44. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_login.py +0 -0
  45. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_login_cli.py +0 -0
  46. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_logout.py +0 -0
  47. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_logs.py +0 -0
  48. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_publish.py +0 -0
  49. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_publish_local.py +0 -0
  50. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_publish_multi_component.py +0 -0
  51. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_refresh.py +0 -0
  52. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_registry.py +0 -0
  53. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_rollback.py +0 -0
  54. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_runtime_secrets.py +0 -0
  55. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_secrets.py +0 -0
  56. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_session_permissions.py +0 -0
  57. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_status.py +0 -0
  58. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_token.py +0 -0
  59. {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.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.5.0
3
+ Version: 0.6.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
@@ -1031,6 +1031,127 @@ result never fails the command.
1031
1031
 
1032
1032
  ---
1033
1033
 
1034
+ ## `mozbridge adopt` / `mozbridge scaffold`
1035
+
1036
+ Both wrap real, already-shipped backend endpoints
1037
+ (`POST /api/v1/projects/{project_id}/adopt`,
1038
+ `POST /api/v1/projects/{project_id}/scaffold` — D3/D3b in
1039
+ `backend/app/features/projects/router.py`, see
1040
+ `.llm/plans/plane-config-contract.md`) with **zero backend changes**: each
1041
+ renders a `docker-compose.app.yml`, validates it against the platform's D2
1042
+ compose contract, and — only if it passes — opens a PR on the project's
1043
+ own repo adding the file. This is exactly what `mozbridge doctor`'s
1044
+ pre-deploy checks recommend when a project has no declared compose shape
1045
+ (`deploy_shape` in `backend/app/services/preflight.py`) — until this
1046
+ slice, the CLI had no command that could act on that diagnosis.
1047
+
1048
+ Same confirmation weight as `mozbridge rollback`/`deploy`: real but
1049
+ reversible (opening a PR touches nothing in production and nothing merges
1050
+ automatically), so both print what they're about to do and prompt to
1051
+ continue, skippable with `--yes`. **Requires**: link + login, same as
1052
+ `env`/`registry`/`config`.
1053
+
1054
+ Success for either command means **a PR was opened** — printed on its own
1055
+ line, never buried in other output. It does **not** mean anything
1056
+ deployed, and it does **not** point the platform at the new file:
1057
+ `config.app_compose_file` is a deliberately separate, later, manual step
1058
+ (`mozbridge config set app_compose_file <path>`, after reviewing and
1059
+ merging the PR).
1060
+
1061
+ ### `mozbridge adopt`
1062
+
1063
+ **Flags**: `--container` (required — name of the LIVE container to
1064
+ inspect on the project's bound host, e.g. `origyn-app`), `--service`
1065
+ (default `backend`), `--yes`.
1066
+
1067
+ **When to use it**: the project already has a container RUNNING
1068
+ somewhere with no declared compose shape on disk (Origyn is the concrete
1069
+ case this endpoint was built for: 9 real Traefik labels, an `origyn-data`
1070
+ volume, port 5001, none of it on disk — and its real container is named
1071
+ `origyn-app`, not `origyn-backend`). `--container` has **no guessed
1072
+ default** — a project's real container name routinely doesn't match its
1073
+ slug, so guessing one would silently point this at the wrong container.
1074
+
1075
+ **What it does**: `POST /api/v1/projects/{project_id}/adopt`, body
1076
+ `{"container": CONTAINER, "service": SERVICE}`. The backend inspects
1077
+ `CONTAINER` LIVE on the project's bound host (image, ports, env, volumes,
1078
+ Traefik labels), renders a single-service compose file from what's
1079
+ ACTUALLY running, validates it against D2, and opens a PR only if that
1080
+ passes.
1081
+
1082
+ ```
1083
+ $ mozbridge adopt --container origyn-app
1084
+ This will adopt acme/origyn:
1085
+ container -> 'origyn-app' (inspected LIVE on the project's bound host)
1086
+ service -> 'backend'
1087
+ If the rendered compose file passes the D2 contract, a PR will be opened.
1088
+ Continue? [y/N]: y
1089
+ PR opened:
1090
+ https://github.com/acme/origyn/pull/42
1091
+ ```
1092
+
1093
+ **Failure modes**:
1094
+ - Not linked / not logged in: identical messages to every other command
1095
+ above, exit 1.
1096
+ - `--container` not found on the project's host: the backend's own 400
1097
+ message verbatim (`container 'X' not found on <host>: ...`), exit 1.
1098
+ - Host unreachable (no usable SSH credential, tunnel failure): the
1099
+ backend's own 400/502 message verbatim, exit 1.
1100
+ - Rendered compose file fails D2 validation: **no PR is opened**. Every
1101
+ violation prints on its own line, prefixed by the explicit statement
1102
+ that no PR was opened, exit 1:
1103
+
1104
+ ```
1105
+ $ mozbridge adopt --container origyn-app --yes
1106
+ ...
1107
+ Could not adopt the running container: rendered docker-compose.app.yml failed the D2 compose contract validator
1108
+ No PR was opened. D2 violations:
1109
+ - service 'backend' has no healthcheck
1110
+ - image tag 'latest' is not allowed
1111
+ ```
1112
+
1113
+ ### `mozbridge scaffold`
1114
+
1115
+ **Flags**: `--service` (default `backend`), `--yes`. No `--container` —
1116
+ scaffold never inspects a live container.
1117
+
1118
+ **When to use it**: a genuinely new project that has never run anywhere.
1119
+ There's no live container to inspect (that's `adopt`'s job), so this
1120
+ renders a starter compose file from whatever the project's OWN declared
1121
+ config already has (`backend_image`/`image_name`, `backend_port`,
1122
+ `backend_command`, `data_volumes`, `frontend_image`, `alt_domains` — set
1123
+ these first with `mozbridge config set`). Deliberately not run
1124
+ automatically at project creation: none of those config keys exist yet
1125
+ at that point, so an automatic call here would render a backend-only,
1126
+ default-port guess for every project — exactly what this endpoint's
1127
+ design rejects.
1128
+
1129
+ **What it does**: `POST /api/v1/projects/{project_id}/scaffold`, body
1130
+ `{"service": SERVICE}`. Same D2-validate-then-PR pipeline and response
1131
+ shape as `adopt` above, reading only `project.config` — no host, no
1132
+ docker, no SSH.
1133
+
1134
+ ```
1135
+ $ mozbridge scaffold
1136
+ This will scaffold acme/new-app from its own declared config:
1137
+ service -> 'backend'
1138
+ If the rendered compose file passes the D2 contract, a PR will be opened.
1139
+ Continue? [y/N]: y
1140
+ PR opened:
1141
+ https://github.com/acme/new-app/pull/7
1142
+ ```
1143
+
1144
+ **Failure modes**:
1145
+ - Not linked / not logged in: exit 1, same messages as every command
1146
+ above.
1147
+ - Rendered compose file fails D2 validation: **no PR is opened**, every
1148
+ violation on its own line, exit 1 — identical shape to `adopt`'s
1149
+ example above.
1150
+ - Any other backend error (e.g. a 500): the raw `ApiError` message,
1151
+ exit 1.
1152
+
1153
+ ---
1154
+
1034
1155
  ## A note on authorization for `publish` / `rollback`
1035
1156
 
1036
1157
  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.5.0"
7
+ version = "0.6.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"
@@ -557,6 +557,113 @@ def deploy_project(
557
557
  return resp.json()
558
558
 
559
559
 
560
+ def _raise_on_compose_validation_failure(resp: httpx.Response, action: str) -> None:
561
+ """Shared 422 handler for adopt_project/scaffold_project.
562
+
563
+ Both `POST .../adopt` and `POST .../scaffold`
564
+ (backend/app/features/projects/router.py) render a docker-compose.app.yml
565
+ and, if it fails the D2 compose contract validator, respond 422 with
566
+ `detail={"message": "...", "violations": [...]}` — no PR is opened in
567
+ that case. This is a genuinely useful discovery (a live/declared spec
568
+ violating the platform's own guardrails), not a generic failure, so the
569
+ violations are folded verbatim into the raised ApiError's message rather
570
+ than collapsed into "422 Unprocessable Entity" — the caller (cmd_adopt.py)
571
+ just echoes `str(exc)` and every violation prints on its own line.
572
+
573
+ Any other non-2xx status falls through to the normal `_raise_for_status`
574
+ path unchanged.
575
+ """
576
+ if resp.status_code != 422:
577
+ _raise_for_status(resp, action)
578
+ return
579
+
580
+ try:
581
+ detail = resp.json().get("detail")
582
+ except ValueError:
583
+ detail = None
584
+
585
+ if not isinstance(detail, dict) or "violations" not in detail:
586
+ _raise_for_status(resp, action)
587
+ return
588
+
589
+ message = detail.get("message") or (
590
+ "rendered docker-compose.app.yml failed the D2 compose contract validator"
591
+ )
592
+ violations = detail.get("violations") or []
593
+ lines = [f"Could not {action}: {message}", "No PR was opened. D2 violations:"]
594
+ lines.extend(f" - {v}" for v in violations)
595
+ raise ApiError("\n".join(lines))
596
+
597
+
598
+ def adopt_project(
599
+ client: httpx.Client,
600
+ access_token: str,
601
+ org_id: int,
602
+ project_id: int,
603
+ container: str,
604
+ service: str = "backend",
605
+ ) -> dict:
606
+ """POST /api/v1/projects/{project_id}/adopt, body {"container", "service"}.
607
+
608
+ D3 (`.llm/plans/plane-config-contract.md`) — wraps
609
+ `router.py`'s `adopt_running_container`: inspects `container` LIVE on the
610
+ project's bound host, renders a single-service `docker-compose.app.yml`
611
+ from what's ACTUALLY running, validates it against the D2 compose
612
+ contract, and — only if that passes — opens a PR adding the file to the
613
+ project's own repo. Nothing is written anywhere if validation fails (see
614
+ `_raise_on_compose_validation_failure`).
615
+
616
+ `container` has no safe client-side default — e.g. Origyn's real
617
+ container is `origyn-app`, not a guessed `{slug}-backend` — so callers
618
+ (cmd_adopt.py) must always pass a real, user-supplied name.
619
+
620
+ Returns {"pr_url", "pr_number", "branch", "violations": []} on success
621
+ (200). Raises ApiError (with violations folded into the message) on a
622
+ 422, or the usual ApiError for any other failure — including a 400 when
623
+ `container` doesn't exist on the host, or a 502 when the host itself
624
+ can't be reached.
625
+ """
626
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/adopt"
627
+ payload = {"container": container, "service": service}
628
+ try:
629
+ resp = client.post(url, json=payload, headers=_headers(access_token, org_id))
630
+ except httpx.HTTPError as exc:
631
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
632
+ _raise_on_compose_validation_failure(resp, "adopt the running container")
633
+ return resp.json()
634
+
635
+
636
+ def scaffold_project(
637
+ client: httpx.Client,
638
+ access_token: str,
639
+ org_id: int,
640
+ project_id: int,
641
+ service: str = "backend",
642
+ ) -> dict:
643
+ """POST /api/v1/projects/{project_id}/scaffold, body {"service"}.
644
+
645
+ D3b (`.llm/plans/plane-config-contract.md`) — wraps
646
+ `router.py`'s `scaffold_project_compose`: renders a starter
647
+ `docker-compose.app.yml` from the project's OWN declared config
648
+ (`backend_image`/`backend_port`/`backend_command`/`data_volumes`/
649
+ `frontend_image`/`alt_domains` — never a live container, since a
650
+ brand-new project has none), same D2-validate-then-PR pipeline and
651
+ response shape as `adopt_project` above.
652
+
653
+ Returns {"pr_url", "pr_number", "branch", "violations": []} on success
654
+ (200). Raises ApiError (with violations folded into the message) on a
655
+ 422, same handling as `adopt_project`.
656
+ """
657
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/scaffold"
658
+ payload = {"service": service}
659
+ try:
660
+ resp = client.post(url, json=payload, headers=_headers(access_token, org_id))
661
+ except httpx.HTTPError as exc:
662
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
663
+ _raise_on_compose_validation_failure(resp, "scaffold the project compose file")
664
+ return resp.json()
665
+
666
+
560
667
  def get_project_preflight(client: httpx.Client, access_token: str, org_id: int, project_id: int) -> dict:
561
668
  """GET /api/v1/projects/{project_id}/preflight -> preflight.Preflight.as_dict().
562
669
 
@@ -0,0 +1,179 @@
1
+ """`mozbridge adopt` / `mozbridge scaffold` — declare a compose shape for the
2
+ linked project by opening a PR, wrapping two real, already-shipped backend
3
+ endpoints (`POST .../adopt`, `POST .../scaffold` in
4
+ backend/app/features/projects/router.py — D3/D3b,
5
+ `.llm/plans/plane-config-contract.md`) with zero backend changes. Same
6
+ wrapping posture as cmd_registry.py/cmd_config.py/cmd_secrets.py.
7
+
8
+ `mozbridge doctor`'s "Pre-deploy checks" line already surfaces the gap these
9
+ close: when a project has no declared compose shape (`deploy_shape` in
10
+ backend/app/services/preflight.py), it recommends running one of these two
11
+ endpoints — but until now the CLI had no command that could act on that
12
+ diagnosis. This module is that command.
13
+
14
+ Two flat, single-verb commands (same tier as `doctor`/`login`), not a nested
15
+ sub-typer — neither has further subcommands, so there's nothing to group.
16
+
17
+ Confirmation weight: opening a PR is real but reversible — nothing merges
18
+ automatically, nothing touches production — so this matches
19
+ `cmd_deploy.py`'s `rollback`/`deploy` confirmation pattern (print what will
20
+ happen, `typer.confirm`, skippable with `--yes`), not the silent posture of
21
+ `env set`/`config set`.
22
+
23
+ `adopt` vs `scaffold`, when to use which:
24
+ - `adopt`: a project already has a container RUNNING somewhere (Origyn is
25
+ the canonical case — 9 real Traefik labels, a data volume, a port, none
26
+ of it on disk) and you want the platform to read the REAL shape off that
27
+ live container and declare it.
28
+ - `scaffold`: a genuinely new project that has never run anywhere. There is
29
+ no live container to inspect, so this renders a starter compose file from
30
+ whatever the project's OWN config already declares
31
+ (backend_image/backend_port/backend_command/data_volumes/etc).
32
+
33
+ Success for either command means a PR was opened — NOT that anything
34
+ deployed, and NOT that `config.app_compose_file` was updated to point at the
35
+ new file. Both are deliberately separate, later, manual steps (see the
36
+ backend endpoints' own docstrings).
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ from pathlib import Path
42
+
43
+ import httpx
44
+ import typer
45
+
46
+ from . import api
47
+ from .cmd_shared import require_link_and_session
48
+
49
+ app = typer.Typer(add_completion=False)
50
+
51
+
52
+ @app.command("adopt")
53
+ def adopt_cmd(
54
+ container: str = typer.Option(
55
+ ...,
56
+ "--container",
57
+ help=(
58
+ "Name of the LIVE container to inspect on the project's bound host "
59
+ "(e.g. origyn-app). Required — there is no safe default to guess "
60
+ "(a project's real container name routinely doesn't match its slug)."
61
+ ),
62
+ ),
63
+ service: str = typer.Option(
64
+ "backend", "--service", help="Compose service name to render the adopted shape under."
65
+ ),
66
+ yes: bool = typer.Option(False, "--yes", help="Skip the interactive confirmation prompt."),
67
+ ) -> None:
68
+ """Adopt a project that's already running with no declared compose shape.
69
+
70
+ Use this when a project has a container RUNNING somewhere (on its bound
71
+ host) but nothing declaring its shape on disk — the platform inspects
72
+ `--container` LIVE, renders a single-service `docker-compose.app.yml`
73
+ from what's ACTUALLY running (image, ports, env, volumes, Traefik
74
+ labels), validates it against the platform's D2 compose contract, and —
75
+ only if that passes — opens a PR on the project's own repo adding the
76
+ file. Requires `mozbridge link` and an active login session.
77
+
78
+ This is a real, consequential action (same confirmation weight as
79
+ `mozbridge rollback`): it inspects a live production container and opens
80
+ a real PR, though nothing merges automatically and nothing here touches
81
+ a running deployment. Confirmed unless `--yes` is passed.
82
+
83
+ Success means a PR was opened — printed on its own line, impossible to
84
+ miss. It does NOT mean anything was deployed, and it does NOT point the
85
+ platform at the new file (`config.app_compose_file` is a separate,
86
+ later, manual step — see `mozbridge config set`).
87
+
88
+ If the rendered compose file fails the platform's D2 compose contract
89
+ validator, the backend returns each violation instead of opening a PR.
90
+ This command prints every violation on its own line, states plainly that
91
+ no PR was opened, and exits 1 — there is nothing to go looking for.
92
+ """
93
+ cwd = Path.cwd()
94
+
95
+ with httpx.Client(timeout=60.0) as client:
96
+ current_link, active_session = require_link_and_session(client, cwd)
97
+
98
+ project_desc = f"{current_link.org_slug}/{current_link.project_slug}"
99
+ typer.echo(f"This will adopt {project_desc}:")
100
+ typer.echo(f" container -> {container!r} (inspected LIVE on the project's bound host)")
101
+ typer.echo(f" service -> {service!r}")
102
+ typer.echo("If the rendered compose file passes the D2 contract, a PR will be opened.")
103
+ if not yes:
104
+ typer.confirm("Continue?", abort=True)
105
+
106
+ try:
107
+ result = api.adopt_project(
108
+ client,
109
+ active_session.access_token,
110
+ current_link.org_id,
111
+ current_link.project_id,
112
+ container,
113
+ service=service,
114
+ )
115
+ except api.ApiError as exc:
116
+ typer.echo(str(exc), err=True)
117
+ raise typer.Exit(code=1)
118
+
119
+ typer.echo("PR opened:")
120
+ typer.echo(f" {result.get('pr_url')}")
121
+
122
+
123
+ @app.command("scaffold")
124
+ def scaffold_cmd(
125
+ service: str = typer.Option(
126
+ "backend", "--service", help="Compose service name to render the scaffolded shape under."
127
+ ),
128
+ yes: bool = typer.Option(False, "--yes", help="Skip the interactive confirmation prompt."),
129
+ ) -> None:
130
+ """Scaffold a starter compose shape for a project with no running container.
131
+
132
+ Use this for a genuinely new project that has never run anywhere — there
133
+ is no live container to inspect (that's what `mozbridge adopt` is for).
134
+ Instead, this renders a starter `docker-compose.app.yml` from whatever
135
+ the project's OWN declared config already has
136
+ (backend_image/backend_port/backend_command/data_volumes/frontend_image/
137
+ alt_domains — see `mozbridge config set` to declare them first),
138
+ validates it against the platform's D2 compose contract, and — only if
139
+ that passes — opens a PR on the project's own repo adding the file.
140
+ Requires `mozbridge link` and an active login session.
141
+
142
+ Same confirmation weight as `mozbridge adopt`/`rollback`: real but
143
+ reversible (nothing merges automatically), confirmed unless `--yes` is
144
+ passed.
145
+
146
+ Success means a PR was opened — printed on its own line. It does NOT
147
+ mean anything was deployed, and it does NOT point the platform at the
148
+ new file (`config.app_compose_file` is a separate, later, manual step).
149
+
150
+ If the rendered compose file fails the platform's D2 compose contract
151
+ validator, this command prints every violation on its own line, states
152
+ plainly that no PR was opened, and exits 1.
153
+ """
154
+ cwd = Path.cwd()
155
+
156
+ with httpx.Client(timeout=30.0) as client:
157
+ current_link, active_session = require_link_and_session(client, cwd)
158
+
159
+ project_desc = f"{current_link.org_slug}/{current_link.project_slug}"
160
+ typer.echo(f"This will scaffold {project_desc} from its own declared config:")
161
+ typer.echo(f" service -> {service!r}")
162
+ typer.echo("If the rendered compose file passes the D2 contract, a PR will be opened.")
163
+ if not yes:
164
+ typer.confirm("Continue?", abort=True)
165
+
166
+ try:
167
+ result = api.scaffold_project(
168
+ client,
169
+ active_session.access_token,
170
+ current_link.org_id,
171
+ current_link.project_id,
172
+ service=service,
173
+ )
174
+ except api.ApiError as exc:
175
+ typer.echo(str(exc), err=True)
176
+ raise typer.Exit(code=1)
177
+
178
+ typer.echo("PR opened:")
179
+ typer.echo(f" {result.get('pr_url')}")
@@ -1,5 +1,6 @@
1
1
  """Mozbridge CLI: login / whoami / logout / link / build / publish / diff / status /
2
- deploy / rollback / logs / env / registry / secrets / config / token / doctor.
2
+ deploy / rollback / logs / env / registry / secrets / config / token / doctor /
3
+ adopt / scaffold.
3
4
 
4
5
  macOS-only for v1 (no Windows code paths).
5
6
 
@@ -7,16 +8,16 @@ This module is a thin composition root: the root Typer `app` is assembled
7
8
  from the command modules below, each of which owns one feature area's
8
9
  command definitions (see cmd_auth.py, cmd_projects.py, cmd_publish.py,
9
10
  cmd_deploy.py, cmd_env.py, cmd_registry.py, cmd_secrets.py, cmd_config.py,
10
- cmd_token.py, cmd_doctor.py).
11
+ cmd_token.py, cmd_doctor.py, cmd_adopt.py).
11
12
  `app.add_typer(<module>.app)`, called with no explicit `name`, merges a
12
13
  nameless sub-Typer's commands flatly into `app` (confirmed against Typer's
13
14
  real behavior — see cmd_*.py's own module-level Typer() calls, none of
14
15
  which pass `name=` except cmd_env's/cmd_registry's/cmd_secrets's/
15
16
  cmd_config's/cmd_token's, which is why `env`/`registry`/`secrets`/
16
- `config`/`token` stay nested command groups while `doctor` — a single flat
17
- command, same shape as `login`/`whoami`/`logout` — merges directly into
18
- `app`). `from mozbridge_cli.main import app` continues to expose every
19
- command exactly as before.
17
+ `config`/`token` stay nested command groups while `doctor`/`adopt`/
18
+ `scaffold` — single flat commands, same shape as `login`/`whoami`/`logout`
19
+ — merge directly into `app`). `from mozbridge_cli.main import app` continues
20
+ to expose every command exactly as before.
20
21
  """
21
22
 
22
23
  from __future__ import annotations
@@ -24,6 +25,7 @@ from __future__ import annotations
24
25
  import typer
25
26
 
26
27
  from . import (
28
+ cmd_adopt,
27
29
  cmd_auth,
28
30
  cmd_config,
29
31
  cmd_deploy,
@@ -40,10 +42,10 @@ app = typer.Typer(
40
42
  name="mozbridge",
41
43
  help=(
42
44
  "Mozbridge platform CLI: login, whoami, logout, link, build, publish, diff, "
43
- "status, deploy, rollback, logs, env, registry, secrets, config, token, doctor."
45
+ "status, deploy, rollback, logs, env, registry, secrets, config, token, doctor, "
46
+ "adopt, scaffold."
44
47
  ),
45
48
  no_args_is_help=True,
46
- add_completion=False,
47
49
  )
48
50
 
49
51
  app.add_typer(cmd_auth.app)
@@ -56,6 +58,7 @@ app.add_typer(cmd_secrets.app, name="secrets")
56
58
  app.add_typer(cmd_config.app, name="config")
57
59
  app.add_typer(cmd_token.app, name="token")
58
60
  app.add_typer(cmd_doctor.app)
61
+ app.add_typer(cmd_adopt.app)
59
62
 
60
63
 
61
64
  def app_main() -> None:
@@ -0,0 +1,320 @@
1
+ import json
2
+ import time
3
+
4
+ import httpx
5
+ import respx
6
+ from typer.testing import CliRunner
7
+
8
+ from mozbridge_cli import config, session
9
+ from mozbridge_cli.link import Link, save_link
10
+ from mozbridge_cli.main import app
11
+
12
+ runner = CliRunner()
13
+
14
+
15
+ def _seed_session():
16
+ s = session.Session(
17
+ access_token="AT-valid",
18
+ refresh_token="RT-valid",
19
+ id_token="IDT",
20
+ expires_at=time.time() + 3600,
21
+ )
22
+ session.save_session(s)
23
+ return s
24
+
25
+
26
+ def _seed_link(cwd):
27
+ save_link(cwd, Link(org_id=1, org_slug="acme", project_id=10, project_slug="origyn"))
28
+
29
+
30
+ # --- adopt ---------------------------------------------------------------
31
+
32
+
33
+ def test_adopt_without_link_gives_clear_error(tmp_path, monkeypatch):
34
+ monkeypatch.chdir(tmp_path)
35
+ _seed_session()
36
+
37
+ result = runner.invoke(app, ["adopt", "--container", "origyn-app", "--yes"])
38
+
39
+ assert result.exit_code == 1
40
+ assert "mozbridge link" in result.output
41
+
42
+
43
+ def test_adopt_without_login_gives_clear_error(tmp_path, monkeypatch):
44
+ monkeypatch.chdir(tmp_path)
45
+ _seed_link(tmp_path)
46
+
47
+ result = runner.invoke(app, ["adopt", "--container", "origyn-app", "--yes"])
48
+
49
+ assert result.exit_code == 1
50
+ assert "mozbridge login" in result.output
51
+
52
+
53
+ def test_adopt_yes_skips_prompt_and_prints_pr_url(tmp_path, monkeypatch):
54
+ monkeypatch.chdir(tmp_path)
55
+ _seed_session()
56
+ _seed_link(tmp_path)
57
+
58
+ with respx.mock:
59
+ route = respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/adopt").mock(
60
+ return_value=httpx.Response(
61
+ 200,
62
+ json={
63
+ "pr_url": "https://github.com/acme/origyn/pull/42",
64
+ "pr_number": 42,
65
+ "branch": "mozbridge/adopt-compose",
66
+ "violations": [],
67
+ },
68
+ )
69
+ )
70
+
71
+ result = runner.invoke(app, ["adopt", "--container", "origyn-app", "--yes"])
72
+
73
+ assert result.exit_code == 0, result.output
74
+ assert route.called
75
+ assert "https://github.com/acme/origyn/pull/42" in result.output
76
+
77
+ request = route.calls.last.request
78
+ assert request.headers["authorization"] == "Bearer AT-valid"
79
+ assert request.headers["x-organization-id"] == "1"
80
+ body = json.loads(request.content)
81
+ assert body == {"container": "origyn-app", "service": "backend"}
82
+
83
+
84
+ def test_adopt_custom_service_is_sent(tmp_path, monkeypatch):
85
+ monkeypatch.chdir(tmp_path)
86
+ _seed_session()
87
+ _seed_link(tmp_path)
88
+
89
+ with respx.mock:
90
+ route = respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/adopt").mock(
91
+ return_value=httpx.Response(
92
+ 200,
93
+ json={
94
+ "pr_url": "https://github.com/acme/origyn/pull/43",
95
+ "pr_number": 43,
96
+ "branch": "mozbridge/adopt-compose",
97
+ "violations": [],
98
+ },
99
+ )
100
+ )
101
+
102
+ result = runner.invoke(app, ["adopt", "--container", "origyn-app", "--service", "frontend", "--yes"])
103
+
104
+ assert result.exit_code == 0, result.output
105
+ body = json.loads(route.calls.last.request.content)
106
+ assert body == {"container": "origyn-app", "service": "frontend"}
107
+
108
+
109
+ def test_adopt_interactive_confirm_accepted_calls_api(tmp_path, monkeypatch):
110
+ monkeypatch.chdir(tmp_path)
111
+ _seed_session()
112
+ _seed_link(tmp_path)
113
+
114
+ with respx.mock:
115
+ route = respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/adopt").mock(
116
+ return_value=httpx.Response(
117
+ 200,
118
+ json={"pr_url": "https://github.com/acme/origyn/pull/44", "pr_number": 44, "violations": []},
119
+ )
120
+ )
121
+
122
+ result = runner.invoke(app, ["adopt", "--container", "origyn-app"], input="y\n")
123
+
124
+ assert result.exit_code == 0, result.output
125
+ assert route.called
126
+ assert "https://github.com/acme/origyn/pull/44" in result.output
127
+
128
+
129
+ def test_adopt_interactive_confirm_declined_makes_no_api_call(tmp_path, monkeypatch):
130
+ monkeypatch.chdir(tmp_path)
131
+ _seed_session()
132
+ _seed_link(tmp_path)
133
+
134
+ with respx.mock:
135
+ route = respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/adopt").mock(
136
+ return_value=httpx.Response(
137
+ 200, json={"pr_url": "https://github.com/acme/origyn/pull/45", "violations": []}
138
+ )
139
+ )
140
+
141
+ result = runner.invoke(app, ["adopt", "--container", "origyn-app"], input="n\n")
142
+
143
+ assert result.exit_code != 0
144
+ assert not route.called
145
+
146
+
147
+ def test_adopt_prints_what_will_happen_before_prompting(tmp_path, monkeypatch):
148
+ monkeypatch.chdir(tmp_path)
149
+ _seed_session()
150
+ _seed_link(tmp_path)
151
+
152
+ with respx.mock:
153
+ respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/adopt").mock(
154
+ return_value=httpx.Response(200, json={"pr_url": "https://x/pull/1", "violations": []})
155
+ )
156
+ result = runner.invoke(app, ["adopt", "--container", "origyn-app"], input="n\n")
157
+
158
+ assert "acme/origyn" in result.output
159
+ assert "origyn-app" in result.output
160
+
161
+
162
+ def test_adopt_d2_violation_prints_each_violation_and_no_pr_opened_claim(tmp_path, monkeypatch):
163
+ monkeypatch.chdir(tmp_path)
164
+ _seed_session()
165
+ _seed_link(tmp_path)
166
+
167
+ with respx.mock:
168
+ respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/adopt").mock(
169
+ return_value=httpx.Response(
170
+ 422,
171
+ json={
172
+ "detail": {
173
+ "message": "rendered docker-compose.app.yml failed the D2 compose contract validator",
174
+ "violations": [
175
+ "service 'backend' has no healthcheck",
176
+ "image tag 'latest' is not allowed",
177
+ ],
178
+ }
179
+ },
180
+ )
181
+ )
182
+
183
+ result = runner.invoke(app, ["adopt", "--container", "origyn-app", "--yes"])
184
+
185
+ assert result.exit_code == 1
186
+ assert "service 'backend' has no healthcheck" in result.output
187
+ assert "image tag 'latest' is not allowed" in result.output
188
+ assert "No PR was opened" in result.output
189
+
190
+
191
+ def test_adopt_container_not_found_error_surfaced_clearly(tmp_path, monkeypatch):
192
+ monkeypatch.chdir(tmp_path)
193
+ _seed_session()
194
+ _seed_link(tmp_path)
195
+
196
+ with respx.mock:
197
+ respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/adopt").mock(
198
+ return_value=httpx.Response(
199
+ 400, json={"detail": "container 'origyn-app' not found on origyn-host: 404 Client Error"}
200
+ )
201
+ )
202
+
203
+ result = runner.invoke(app, ["adopt", "--container", "origyn-app", "--yes"])
204
+
205
+ assert result.exit_code == 1
206
+ assert "not found on origyn-host" in result.output
207
+
208
+
209
+ # --- scaffold --------------------------------------------------------------
210
+
211
+
212
+ def test_scaffold_without_link_gives_clear_error(tmp_path, monkeypatch):
213
+ monkeypatch.chdir(tmp_path)
214
+ _seed_session()
215
+
216
+ result = runner.invoke(app, ["scaffold", "--yes"])
217
+
218
+ assert result.exit_code == 1
219
+ assert "mozbridge link" in result.output
220
+
221
+
222
+ def test_scaffold_without_login_gives_clear_error(tmp_path, monkeypatch):
223
+ monkeypatch.chdir(tmp_path)
224
+ _seed_link(tmp_path)
225
+
226
+ result = runner.invoke(app, ["scaffold", "--yes"])
227
+
228
+ assert result.exit_code == 1
229
+ assert "mozbridge login" in result.output
230
+
231
+
232
+ def test_scaffold_yes_skips_prompt_and_prints_pr_url(tmp_path, monkeypatch):
233
+ monkeypatch.chdir(tmp_path)
234
+ _seed_session()
235
+ _seed_link(tmp_path)
236
+
237
+ with respx.mock:
238
+ route = respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/scaffold").mock(
239
+ return_value=httpx.Response(
240
+ 200,
241
+ json={
242
+ "pr_url": "https://github.com/acme/origyn/pull/50",
243
+ "pr_number": 50,
244
+ "branch": "mozbridge/scaffold-compose",
245
+ "violations": [],
246
+ },
247
+ )
248
+ )
249
+
250
+ result = runner.invoke(app, ["scaffold", "--yes"])
251
+
252
+ assert result.exit_code == 0, result.output
253
+ assert route.called
254
+ assert "https://github.com/acme/origyn/pull/50" in result.output
255
+
256
+ request = route.calls.last.request
257
+ assert request.headers["authorization"] == "Bearer AT-valid"
258
+ assert request.headers["x-organization-id"] == "1"
259
+ body = json.loads(request.content)
260
+ assert body == {"service": "backend"}
261
+
262
+
263
+ def test_scaffold_custom_service_is_sent(tmp_path, monkeypatch):
264
+ monkeypatch.chdir(tmp_path)
265
+ _seed_session()
266
+ _seed_link(tmp_path)
267
+
268
+ with respx.mock:
269
+ route = respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/scaffold").mock(
270
+ return_value=httpx.Response(
271
+ 200, json={"pr_url": "https://github.com/acme/origyn/pull/51", "violations": []}
272
+ )
273
+ )
274
+
275
+ result = runner.invoke(app, ["scaffold", "--service", "frontend", "--yes"])
276
+
277
+ assert result.exit_code == 0, result.output
278
+ body = json.loads(route.calls.last.request.content)
279
+ assert body == {"service": "frontend"}
280
+
281
+
282
+ def test_scaffold_interactive_confirm_declined_makes_no_api_call(tmp_path, monkeypatch):
283
+ monkeypatch.chdir(tmp_path)
284
+ _seed_session()
285
+ _seed_link(tmp_path)
286
+
287
+ with respx.mock:
288
+ route = respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/scaffold").mock(
289
+ return_value=httpx.Response(200, json={"pr_url": "https://x/pull/2", "violations": []})
290
+ )
291
+
292
+ result = runner.invoke(app, ["scaffold"], input="n\n")
293
+
294
+ assert result.exit_code != 0
295
+ assert not route.called
296
+
297
+
298
+ def test_scaffold_d2_violation_prints_each_violation_and_no_pr_opened_claim(tmp_path, monkeypatch):
299
+ monkeypatch.chdir(tmp_path)
300
+ _seed_session()
301
+ _seed_link(tmp_path)
302
+
303
+ with respx.mock:
304
+ respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/scaffold").mock(
305
+ return_value=httpx.Response(
306
+ 422,
307
+ json={
308
+ "detail": {
309
+ "message": "rendered docker-compose.app.yml failed the D2 compose contract validator",
310
+ "violations": ["backend_port is not set"],
311
+ }
312
+ },
313
+ )
314
+ )
315
+
316
+ result = runner.invoke(app, ["scaffold", "--yes"])
317
+
318
+ assert result.exit_code == 1
319
+ assert "backend_port is not set" in result.output
320
+ assert "No PR was opened" in result.output
File without changes
File without changes