mozbridge-cli 0.1.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 (36) hide show
  1. mozbridge_cli-0.1.0/.gitignore +7 -0
  2. mozbridge_cli-0.1.0/PKG-INFO +351 -0
  3. mozbridge_cli-0.1.0/README.md +323 -0
  4. mozbridge_cli-0.1.0/docs/commands.md +558 -0
  5. mozbridge_cli-0.1.0/pyproject.toml +53 -0
  6. mozbridge_cli-0.1.0/src/mozbridge_cli/__init__.py +3 -0
  7. mozbridge_cli-0.1.0/src/mozbridge_cli/__main__.py +4 -0
  8. mozbridge_cli-0.1.0/src/mozbridge_cli/api.py +274 -0
  9. mozbridge_cli-0.1.0/src/mozbridge_cli/auth.py +179 -0
  10. mozbridge_cli-0.1.0/src/mozbridge_cli/build.py +108 -0
  11. mozbridge_cli-0.1.0/src/mozbridge_cli/compose.py +181 -0
  12. mozbridge_cli-0.1.0/src/mozbridge_cli/config.py +76 -0
  13. mozbridge_cli-0.1.0/src/mozbridge_cli/link.py +90 -0
  14. mozbridge_cli-0.1.0/src/mozbridge_cli/local_build.py +140 -0
  15. mozbridge_cli-0.1.0/src/mozbridge_cli/main.py +821 -0
  16. mozbridge_cli-0.1.0/src/mozbridge_cli/runtime_secrets.py +140 -0
  17. mozbridge_cli-0.1.0/src/mozbridge_cli/session.py +156 -0
  18. mozbridge_cli-0.1.0/tests/conftest.py +19 -0
  19. mozbridge_cli-0.1.0/tests/test_build.py +84 -0
  20. mozbridge_cli-0.1.0/tests/test_ci_token_auth.py +191 -0
  21. mozbridge_cli-0.1.0/tests/test_compose.py +151 -0
  22. mozbridge_cli-0.1.0/tests/test_diff.py +306 -0
  23. mozbridge_cli-0.1.0/tests/test_link.py +130 -0
  24. mozbridge_cli-0.1.0/tests/test_local_build.py +173 -0
  25. mozbridge_cli-0.1.0/tests/test_login.py +162 -0
  26. mozbridge_cli-0.1.0/tests/test_login_cli.py +53 -0
  27. mozbridge_cli-0.1.0/tests/test_logout.py +37 -0
  28. mozbridge_cli-0.1.0/tests/test_publish.py +147 -0
  29. mozbridge_cli-0.1.0/tests/test_publish_local.py +371 -0
  30. mozbridge_cli-0.1.0/tests/test_publish_multi_component.py +311 -0
  31. mozbridge_cli-0.1.0/tests/test_refresh.py +37 -0
  32. mozbridge_cli-0.1.0/tests/test_rollback.py +158 -0
  33. mozbridge_cli-0.1.0/tests/test_runtime_secrets.py +172 -0
  34. mozbridge_cli-0.1.0/tests/test_session_permissions.py +62 -0
  35. mozbridge_cli-0.1.0/tests/test_status.py +183 -0
  36. mozbridge_cli-0.1.0/tests/test_whoami.py +97 -0
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ *.egg-info/
6
+ dist/
7
+ build/
@@ -0,0 +1,351 @@
1
+ Metadata-Version: 2.5
2
+ Name: mozbridge-cli
3
+ Version: 0.1.0
4
+ Summary: Command-line client for the Mozbridge deployment platform: SSO login, build, publish, status, and rollback for any project hosted on Mozbridge.
5
+ Project-URL: Homepage, https://mozbridge.com
6
+ Project-URL: Documentation, https://github.com/jessin01/mozbridge/blob/main/cli/docs/commands.md
7
+ Project-URL: Repository, https://github.com/jessin01/mozbridge
8
+ Author: Mozbridge
9
+ License: Proprietary
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Build Tools
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: httpx>=0.27
22
+ Requires-Dist: pyyaml>=6.0
23
+ Requires-Dist: typer>=0.12
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8.0; extra == 'dev'
26
+ Requires-Dist: respx>=0.21; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # mozbridge-cli
30
+
31
+ A laptop CLI for the Mozbridge deployment platform. You sign in once via
32
+ browser-based SSO (Logto's device-authorization flow — no password, no
33
+ long-lived API key typed into a dotfile), link a local directory to a
34
+ Mozbridge org + project, and push builds from there. It never holds a
35
+ production credential of its own: every request it makes rides the same
36
+ human Logto session `mozbridge login` cached, hitting the same REST API the
37
+ Mozbridge web dashboard uses.
38
+
39
+ macOS-only for v1 — there are no Windows code paths.
40
+
41
+ ## Install
42
+
43
+ There is no published package yet (no PyPI entry, no `mozbridge-cli` on any
44
+ index) — install from this checkout:
45
+
46
+ ```bash
47
+ pip install -e cli/
48
+ # or, for an isolated global install:
49
+ pipx install ./cli
50
+ ```
51
+
52
+ Either one registers a `mozbridge` console script (from
53
+ `cli/pyproject.toml`'s `[project.scripts]`: `mozbridge =
54
+ "mozbridge_cli.main:app_main"`). Confirm it's on your `PATH`:
55
+
56
+ ```bash
57
+ mozbridge --help
58
+ ```
59
+
60
+ Requires Python >= 3.10.
61
+
62
+ ## Quickstart
63
+
64
+ ```bash
65
+ mozbridge login # opens a browser to sign in
66
+ cd ~/code/my-project
67
+ mozbridge link --org acme --project web-app # bind this directory
68
+ mozbridge publish # build + upload + trigger a build
69
+ mozbridge status # watch it land
70
+ ```
71
+
72
+ Run `mozbridge link` with no flags to pick org/project from a numbered list
73
+ interactively instead of naming slugs up front.
74
+
75
+ ## Commands
76
+
77
+ ```
78
+ mozbridge login # device-code SSO via Logto
79
+ mozbridge whoami # show the signed-in identity (refreshes the token if needed)
80
+ mozbridge logout # delete the cached local session
81
+ mozbridge link [--org SLUG] [--project SLUG] # link this directory to a Mozbridge org + project
82
+ mozbridge build # zip the current directory (does not upload)
83
+ mozbridge publish # build + upload + trigger a build on the linked project
84
+ mozbridge diff # preview what `publish` would do right now — makes no mutating call
85
+ mozbridge status # show the linked project + its 5 most recent deployments
86
+ mozbridge rollback [--to ID] [--yes] # roll back the linked project's deployment
87
+ ```
88
+
89
+ Full flag-by-flag reference, example output, and every documented failure
90
+ mode: **[docs/commands.md](docs/commands.md)**.
91
+
92
+ ## How project linking works
93
+
94
+ `mozbridge link` writes `.mozbridge/link.json` in the **current directory**
95
+ — not in the CLI's own `~/.config/mozbridge`. It records which org/project
96
+ that specific repo checkout is bound to:
97
+
98
+ ```json
99
+ {
100
+ "org_id": 1,
101
+ "org_slug": "acme",
102
+ "project_id": 10,
103
+ "project_slug": "web-app"
104
+ }
105
+ ```
106
+
107
+ This is per-repo working-tree state, not per-user CLI state — a fresh clone
108
+ of the same repo on another machine has no link file until `mozbridge link`
109
+ is run there too. **It is not committed.** `link` best-effort appends
110
+ `.mozbridge/` to an existing `.gitignore` in the current directory (and
111
+ tells you so); if there's no `.gitignore` there yet, it prints a note asking
112
+ you to add the entry yourself rather than creating one. Either way: make
113
+ sure `.mozbridge/` is actually gitignored in real repos, since the file
114
+ contains real org/project identifiers.
115
+
116
+ `build`, `publish`, `status`, and `rollback` all read this file and fail
117
+ with a clear message if it's missing (see
118
+ [docs/commands.md](docs/commands.md) for the exact text).
119
+
120
+ ## How multi-component publish works
121
+
122
+ Most real Mozbridge tenants are multi-service (a backend + a frontend +
123
+ workers), not a single whole-directory image. `mozbridge publish` detects
124
+ this from a Compose file in the current directory and, when it finds one,
125
+ publishes each buildable service as its own image — one
126
+ `create_source_upload` → `PUT` → `trigger_build` cycle per component —
127
+ instead of the single zip-the-whole-directory flow.
128
+
129
+ **Discovery order** (Docker Compose's own file-discovery order, checked in
130
+ this exact sequence, first match wins): `docker-compose.yml`,
131
+ `compose.yaml`, `docker-compose.yaml`.
132
+
133
+ **What counts as a "component"**: any top-level service in that file with a
134
+ `build:` key — either the short form (`build: ./backend`) or the mapping
135
+ form (`build: {context: ..., dockerfile: ...}`). A service with only an
136
+ `image:` key (no `build:`) is not a component and is skipped — that's how a
137
+ `redis:` or `postgres:` service in the same compose file is correctly
138
+ ignored.
139
+
140
+ **Shared-context de-duplication**: two components whose resolved build
141
+ context is the same directory — even if spelled differently in YAML (`.`
142
+ vs `./`) — are zipped and uploaded exactly once, then each triggers its own
143
+ build reusing that one `upload_id`. This is genuinely a byte-savings and
144
+ correctness behavior, not just a nicety: two services building from the
145
+ same monorepo root would otherwise ship two identical uploads.
146
+
147
+ **Per-component image naming**: each component's image name is computed
148
+ client-side as
149
+ `ghcr.io/{GHCR_ORG}/mozbridge-tenants/{org_slug}/{project_slug}-{service_name}`
150
+ (the same base the backend computes by default for a single-component
151
+ publish, with `-{service_name}` appended so N components never collide on
152
+ one image).
153
+
154
+ **Partial failure**: every component is attempted even if an earlier one
155
+ fails — one broken service's build failing does not stop the others from
156
+ being attempted. At the end, `publish` prints a summary line
157
+ (`Publish summary: N succeeded, M failed (of TOTAL)`) followed by one `OK`
158
+ or `FAIL` line per component, and exits non-zero if anything failed.
159
+
160
+ **No compose file, or none of its services build**: `publish` falls back
161
+ unchanged to the original single-component flow — zip the whole current
162
+ directory, one upload, one `trigger_build` call, with no `image_name`,
163
+ `context_path`, or `dockerfile_path` override sent, so the backend's own
164
+ defaults apply exactly as they always have. This is the fallback path, not
165
+ a separate mode you opt into — an ordinary non-Compose project (a single
166
+ Dockerfile app, or no Dockerfile at all) publishes exactly as it did before
167
+ multi-component support existed.
168
+
169
+ ## Security model
170
+
171
+ - **Device-flow SSO, not a stored password or long-lived API key.**
172
+ `mozbridge login` runs Logto's OAuth 2.0 Device Authorization Grant (RFC
173
+ 8628) against a real, provisioned Logto Native (public) client. You
174
+ approve the login in a browser; the CLI never sees or handles your
175
+ credentials directly.
176
+ - **Session cache**: the resulting access/refresh/ID tokens are cached at
177
+ `~/.config/mozbridge/session.json`. The directory is created `0700` and
178
+ the file is written `0600` on every save (verified against real
179
+ filesystem state, not mocked — see `cli/tests/test_session_permissions.py`),
180
+ the same posture the project's SSH-broker design used for private key
181
+ material. An expired access token is refreshed transparently before any
182
+ command that needs one; if refresh itself fails (dead/revoked refresh
183
+ token, or none was ever cached) you're told to `mozbridge login` again
184
+ rather than being left in a half-authenticated state.
185
+ - **What the CLI does NOT hold**: no service-token, no deploy credential, no
186
+ SSH key, no registry password. Every request uses the plain human Logto
187
+ bearer token `login` cached, sent exactly like a browser session would
188
+ send it.
189
+ - **A real, documented platform gap — stated plainly, not alarmingly**:
190
+ today, request-level authorization for mutating project actions (publish,
191
+ rollback) beyond ordinary org/project membership is not yet enforced on
192
+ the backend for human-authenticated callers. Concretely:
193
+ `require_deploy_scope` (`backend/app/features/identity/token_scopes.py`)
194
+ is built on `require_token_scopes(...)`, whose check body returns
195
+ immediately — a complete no-op — for any caller that is not a
196
+ `mz_`-prefixed ServiceToken (`is_service_identity(user)` is `False` for a
197
+ plain human Logto token). The real gate a human session hits is ordinary
198
+ org/project membership (`get_current_org` + `get_valid_project`), not a
199
+ deploy-scope check. The `rollback/{deployment_id}` route has no scope
200
+ dependency on it at all. This is a **backend finding**, not something
201
+ introduced by or specific to this CLI — it applies equally to the web
202
+ dashboard's own session tokens, and it was surfaced (independently,
203
+ twice) while building this CLI's `publish` and `rollback` commands. It's
204
+ tracked as a known platform gap in `.llm/plans/gap-register.md` and in
205
+ the session notes at the end of `.llm/plans/mozbridge-agent.md` — not
206
+ something this doc oversells or that the CLI works around.
207
+
208
+ ## What's NOT built yet
209
+
210
+ - **`mozbridge deploy`** — the platform-orchestrated deploy path
211
+ (`/cicd/deploy`). `publish` uses a different, already-working mechanism
212
+ (`create_source_upload` + `trigger_build` with `source_type="upload"`)
213
+ that builds fresh source into a new image — it does not go through
214
+ `/cicd/deploy` today.
215
+ - Build-time secrets for multi-component publish.
216
+ - Windows support.
217
+
218
+ `mozbridge diff` (preview what `publish` would do, no mutating calls) is
219
+ now built — see [docs/commands.md](docs/commands.md#mozbridge-diff). It
220
+ compares local components against the most recent deployment *record*
221
+ only, not the actually-deployed compose content — the platform doesn't
222
+ expose that anywhere yet, so a full semantic compose diff is still not
223
+ built.
224
+
225
+ See `.llm/plans/mozbridge-agent.md` (the "Session progress and
226
+ reconciliation" section at the end) for the full roadmap and how the CLI's
227
+ scope compares to that plan's original acceptance criteria — not
228
+ duplicated here.
229
+
230
+ ## CI / headless use
231
+
232
+ Every command above assumes an interactive human at a terminal —
233
+ `mozbridge login` opens a browser. A CI pipeline has neither, so instead of
234
+ that stored session, set `MOZBRIDGE_TOKEN` in the pipeline environment:
235
+
236
+ ```bash
237
+ export MOZBRIDGE_TOKEN=<service-token>
238
+ mozbridge link --org acme --project web-app
239
+ mozbridge publish
240
+ ```
241
+
242
+ When `MOZBRIDGE_TOKEN` is set, it is used **directly as the bearer token for
243
+ every command** — `whoami`, `link`, `status`, `rollback`, `diff`, and both
244
+ `publish` and `publish --local`'s upload/registration calls — instead of the
245
+ on-disk session `mozbridge login` produces. It's checked before anything
246
+ session-file-related, so nothing under `~/.config/mozbridge/` is read,
247
+ written, or permission-checked in this path at all. Because of that,
248
+ `mozbridge login` itself refuses cleanly when `MOZBRIDGE_TOKEN` is set
249
+ (these are mutually exclusive auth modes — a CI token in the environment
250
+ means "don't attempt an interactive login") — unset it first if you actually
251
+ want to run the device flow.
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.
258
+
259
+ **`MOZBRIDGE_TOKEN` vs `MOZBRIDGE_BUILD_TOKEN` — do not confuse these**:
260
+
261
+ | | `MOZBRIDGE_TOKEN` | `MOZBRIDGE_BUILD_TOKEN` |
262
+ |---|---|---|
263
+ | Authenticates | **every** command, as a project-scoped identity | only `publish --local`'s one `GET /cicd/runtime-secrets` call |
264
+ | Replaces | the entire `mozbridge login` session | nothing — it's used alongside your regular session, which still does the actual build registration |
265
+ | Scope needed | whatever the platform grants a CI/service identity for the commands you run (link/publish/status/rollback) | the `build` scope specifically (registry/build credentials only, never a deploy host key) |
266
+
267
+ They are two separate credentials for two separate purposes and are never
268
+ read from, or substituted for, one another anywhere in this CLI — see
269
+ `session.py` and `runtime_secrets.py`'s module docstrings.
270
+
271
+ **A realistic Woodpecker pipeline step**:
272
+ ```yaml
273
+ steps:
274
+ publish:
275
+ image: python:3.12-slim
276
+ environment:
277
+ MOZBRIDGE_TOKEN:
278
+ from_secret: mozbridge_token
279
+ commands:
280
+ - pip install --quiet mozbridge-cli # or: pip install -e ./cli, per your setup
281
+ - mozbridge link --org acme --project web-app
282
+ - mozbridge publish
283
+ ```
284
+
285
+ A GitHub Actions step looks the same, just spelled with `env:` and
286
+ `${{ secrets.MOZBRIDGE_TOKEN }}`:
287
+ ```yaml
288
+ - name: Publish to Mozbridge
289
+ env:
290
+ MOZBRIDGE_TOKEN: ${{ secrets.MOZBRIDGE_TOKEN }}
291
+ run: |
292
+ mozbridge link --org acme --project web-app
293
+ mozbridge publish
294
+ ```
295
+
296
+ `mozbridge link` still needs to run once per checkout (it writes
297
+ `.mozbridge/link.json` into the working directory, which a fresh CI checkout
298
+ never has) — there is no way to skip it by pre-seeding the link file today,
299
+ so it belongs in the same pipeline step, before `publish`.
300
+
301
+ ## Configuration (advanced / override)
302
+
303
+ | Env var | Default | Purpose |
304
+ |---|---|---|
305
+ | `MOZBRIDGE_LOGTO_ENDPOINT` | `https://auth.mozbridge.com` | Logto tenant endpoint |
306
+ | `MOZBRIDGE_LOGTO_CLIENT_ID` | `swdtsuiz6dgzack1g75x3` | Logto Native app client id (public client) |
307
+ | `MOZBRIDGE_PUBLIC_API_URL` (falls back to `PUBLIC_API_URL`) | `https://api.mozbridge.com` | Mozbridge API base |
308
+ | `MOZBRIDGE_GHCR_ORG` | `jessin01` | GHCR org used to compute default image names for multi-component publish |
309
+ | `MOZBRIDGE_CONFIG_DIR` | `~/.config/mozbridge` | Where the session file lives (mainly for tests) |
310
+ | `MOZBRIDGE_TOKEN` | unset | CI/headless auth — see [CI / headless use](#ci--headless-use) above |
311
+
312
+ All of these have real, working defaults — the CLI works out of the box
313
+ against production Mozbridge. The env vars exist for pointing at a
314
+ non-production instance (and for test isolation).
315
+
316
+ ## Troubleshooting
317
+
318
+ **"Not logged in. Run `mozbridge login` first."** — no session file
319
+ exists yet at `~/.config/mozbridge/session.json` (or `whoami`/`link`/etc.
320
+ was run before ever logging in). Run `mozbridge login`.
321
+
322
+ **"Session expired and cannot be refreshed. Run `mozbridge login` again."**
323
+ — your cached session has no refresh token. Re-run `mozbridge login`.
324
+
325
+ **"Session refresh failed: ... — run `mozbridge login` again."** — the
326
+ access token was expired and the cached refresh token was rejected by
327
+ Logto (revoked, or itself expired). Re-run `mozbridge login`.
328
+
329
+ **"Session is no longer valid. Run `mozbridge login` again."** (from
330
+ `whoami` specifically) — the API returned 401 even with a token that looked
331
+ valid client-side (e.g. revoked server-side). Re-run `mozbridge login`.
332
+
333
+ **"This directory is not linked to a Mozbridge project. Run `mozbridge
334
+ link` first."** — `build`/`publish`/`status`/`rollback` all require a
335
+ `.mozbridge/link.json` in the current directory. Run `mozbridge link`
336
+ (from the same directory you'll run these commands in).
337
+
338
+ **A failed `publish`** — each step (`create_source_upload`, the `PUT`
339
+ upload, `trigger_build`) surfaces the backend's own error `detail` text
340
+ verbatim, prefixed with what it was trying to do, e.g.:
341
+
342
+ ```
343
+ Could not upload the build source: Zip too large
344
+ Could not create a source upload session: Not a member of this organization
345
+ Could not trigger the build: <backend detail text>
346
+ ```
347
+
348
+ For a multi-component publish, a failure on one component does not stop
349
+ the others — you get a per-component `OK`/`FAIL` summary at the end (see
350
+ [docs/commands.md](docs/commands.md#publish) for the exact shape) and the
351
+ process exits non-zero if anything failed.
@@ -0,0 +1,323 @@
1
+ # mozbridge-cli
2
+
3
+ A laptop CLI for the Mozbridge deployment platform. You sign in once via
4
+ browser-based SSO (Logto's device-authorization flow — no password, no
5
+ long-lived API key typed into a dotfile), link a local directory to a
6
+ Mozbridge org + project, and push builds from there. It never holds a
7
+ production credential of its own: every request it makes rides the same
8
+ human Logto session `mozbridge login` cached, hitting the same REST API the
9
+ Mozbridge web dashboard uses.
10
+
11
+ macOS-only for v1 — there are no Windows code paths.
12
+
13
+ ## Install
14
+
15
+ There is no published package yet (no PyPI entry, no `mozbridge-cli` on any
16
+ index) — install from this checkout:
17
+
18
+ ```bash
19
+ pip install -e cli/
20
+ # or, for an isolated global install:
21
+ pipx install ./cli
22
+ ```
23
+
24
+ Either one registers a `mozbridge` console script (from
25
+ `cli/pyproject.toml`'s `[project.scripts]`: `mozbridge =
26
+ "mozbridge_cli.main:app_main"`). Confirm it's on your `PATH`:
27
+
28
+ ```bash
29
+ mozbridge --help
30
+ ```
31
+
32
+ Requires Python >= 3.10.
33
+
34
+ ## Quickstart
35
+
36
+ ```bash
37
+ mozbridge login # opens a browser to sign in
38
+ cd ~/code/my-project
39
+ mozbridge link --org acme --project web-app # bind this directory
40
+ mozbridge publish # build + upload + trigger a build
41
+ mozbridge status # watch it land
42
+ ```
43
+
44
+ Run `mozbridge link` with no flags to pick org/project from a numbered list
45
+ interactively instead of naming slugs up front.
46
+
47
+ ## Commands
48
+
49
+ ```
50
+ mozbridge login # device-code SSO via Logto
51
+ mozbridge whoami # show the signed-in identity (refreshes the token if needed)
52
+ mozbridge logout # delete the cached local session
53
+ mozbridge link [--org SLUG] [--project SLUG] # link this directory to a Mozbridge org + project
54
+ mozbridge build # zip the current directory (does not upload)
55
+ mozbridge publish # build + upload + trigger a build on the linked project
56
+ mozbridge diff # preview what `publish` would do right now — makes no mutating call
57
+ mozbridge status # show the linked project + its 5 most recent deployments
58
+ mozbridge rollback [--to ID] [--yes] # roll back the linked project's deployment
59
+ ```
60
+
61
+ Full flag-by-flag reference, example output, and every documented failure
62
+ mode: **[docs/commands.md](docs/commands.md)**.
63
+
64
+ ## How project linking works
65
+
66
+ `mozbridge link` writes `.mozbridge/link.json` in the **current directory**
67
+ — not in the CLI's own `~/.config/mozbridge`. It records which org/project
68
+ that specific repo checkout is bound to:
69
+
70
+ ```json
71
+ {
72
+ "org_id": 1,
73
+ "org_slug": "acme",
74
+ "project_id": 10,
75
+ "project_slug": "web-app"
76
+ }
77
+ ```
78
+
79
+ This is per-repo working-tree state, not per-user CLI state — a fresh clone
80
+ of the same repo on another machine has no link file until `mozbridge link`
81
+ is run there too. **It is not committed.** `link` best-effort appends
82
+ `.mozbridge/` to an existing `.gitignore` in the current directory (and
83
+ tells you so); if there's no `.gitignore` there yet, it prints a note asking
84
+ you to add the entry yourself rather than creating one. Either way: make
85
+ sure `.mozbridge/` is actually gitignored in real repos, since the file
86
+ contains real org/project identifiers.
87
+
88
+ `build`, `publish`, `status`, and `rollback` all read this file and fail
89
+ with a clear message if it's missing (see
90
+ [docs/commands.md](docs/commands.md) for the exact text).
91
+
92
+ ## How multi-component publish works
93
+
94
+ Most real Mozbridge tenants are multi-service (a backend + a frontend +
95
+ workers), not a single whole-directory image. `mozbridge publish` detects
96
+ this from a Compose file in the current directory and, when it finds one,
97
+ publishes each buildable service as its own image — one
98
+ `create_source_upload` → `PUT` → `trigger_build` cycle per component —
99
+ instead of the single zip-the-whole-directory flow.
100
+
101
+ **Discovery order** (Docker Compose's own file-discovery order, checked in
102
+ this exact sequence, first match wins): `docker-compose.yml`,
103
+ `compose.yaml`, `docker-compose.yaml`.
104
+
105
+ **What counts as a "component"**: any top-level service in that file with a
106
+ `build:` key — either the short form (`build: ./backend`) or the mapping
107
+ form (`build: {context: ..., dockerfile: ...}`). A service with only an
108
+ `image:` key (no `build:`) is not a component and is skipped — that's how a
109
+ `redis:` or `postgres:` service in the same compose file is correctly
110
+ ignored.
111
+
112
+ **Shared-context de-duplication**: two components whose resolved build
113
+ context is the same directory — even if spelled differently in YAML (`.`
114
+ vs `./`) — are zipped and uploaded exactly once, then each triggers its own
115
+ build reusing that one `upload_id`. This is genuinely a byte-savings and
116
+ correctness behavior, not just a nicety: two services building from the
117
+ same monorepo root would otherwise ship two identical uploads.
118
+
119
+ **Per-component image naming**: each component's image name is computed
120
+ client-side as
121
+ `ghcr.io/{GHCR_ORG}/mozbridge-tenants/{org_slug}/{project_slug}-{service_name}`
122
+ (the same base the backend computes by default for a single-component
123
+ publish, with `-{service_name}` appended so N components never collide on
124
+ one image).
125
+
126
+ **Partial failure**: every component is attempted even if an earlier one
127
+ fails — one broken service's build failing does not stop the others from
128
+ being attempted. At the end, `publish` prints a summary line
129
+ (`Publish summary: N succeeded, M failed (of TOTAL)`) followed by one `OK`
130
+ or `FAIL` line per component, and exits non-zero if anything failed.
131
+
132
+ **No compose file, or none of its services build**: `publish` falls back
133
+ unchanged to the original single-component flow — zip the whole current
134
+ directory, one upload, one `trigger_build` call, with no `image_name`,
135
+ `context_path`, or `dockerfile_path` override sent, so the backend's own
136
+ defaults apply exactly as they always have. This is the fallback path, not
137
+ a separate mode you opt into — an ordinary non-Compose project (a single
138
+ Dockerfile app, or no Dockerfile at all) publishes exactly as it did before
139
+ multi-component support existed.
140
+
141
+ ## Security model
142
+
143
+ - **Device-flow SSO, not a stored password or long-lived API key.**
144
+ `mozbridge login` runs Logto's OAuth 2.0 Device Authorization Grant (RFC
145
+ 8628) against a real, provisioned Logto Native (public) client. You
146
+ approve the login in a browser; the CLI never sees or handles your
147
+ credentials directly.
148
+ - **Session cache**: the resulting access/refresh/ID tokens are cached at
149
+ `~/.config/mozbridge/session.json`. The directory is created `0700` and
150
+ the file is written `0600` on every save (verified against real
151
+ filesystem state, not mocked — see `cli/tests/test_session_permissions.py`),
152
+ the same posture the project's SSH-broker design used for private key
153
+ material. An expired access token is refreshed transparently before any
154
+ command that needs one; if refresh itself fails (dead/revoked refresh
155
+ token, or none was ever cached) you're told to `mozbridge login` again
156
+ rather than being left in a half-authenticated state.
157
+ - **What the CLI does NOT hold**: no service-token, no deploy credential, no
158
+ SSH key, no registry password. Every request uses the plain human Logto
159
+ bearer token `login` cached, sent exactly like a browser session would
160
+ send it.
161
+ - **A real, documented platform gap — stated plainly, not alarmingly**:
162
+ today, request-level authorization for mutating project actions (publish,
163
+ rollback) beyond ordinary org/project membership is not yet enforced on
164
+ the backend for human-authenticated callers. Concretely:
165
+ `require_deploy_scope` (`backend/app/features/identity/token_scopes.py`)
166
+ is built on `require_token_scopes(...)`, whose check body returns
167
+ immediately — a complete no-op — for any caller that is not a
168
+ `mz_`-prefixed ServiceToken (`is_service_identity(user)` is `False` for a
169
+ plain human Logto token). The real gate a human session hits is ordinary
170
+ org/project membership (`get_current_org` + `get_valid_project`), not a
171
+ deploy-scope check. The `rollback/{deployment_id}` route has no scope
172
+ dependency on it at all. This is a **backend finding**, not something
173
+ introduced by or specific to this CLI — it applies equally to the web
174
+ dashboard's own session tokens, and it was surfaced (independently,
175
+ twice) while building this CLI's `publish` and `rollback` commands. It's
176
+ tracked as a known platform gap in `.llm/plans/gap-register.md` and in
177
+ the session notes at the end of `.llm/plans/mozbridge-agent.md` — not
178
+ something this doc oversells or that the CLI works around.
179
+
180
+ ## What's NOT built yet
181
+
182
+ - **`mozbridge deploy`** — the platform-orchestrated deploy path
183
+ (`/cicd/deploy`). `publish` uses a different, already-working mechanism
184
+ (`create_source_upload` + `trigger_build` with `source_type="upload"`)
185
+ that builds fresh source into a new image — it does not go through
186
+ `/cicd/deploy` today.
187
+ - Build-time secrets for multi-component publish.
188
+ - Windows support.
189
+
190
+ `mozbridge diff` (preview what `publish` would do, no mutating calls) is
191
+ now built — see [docs/commands.md](docs/commands.md#mozbridge-diff). It
192
+ compares local components against the most recent deployment *record*
193
+ only, not the actually-deployed compose content — the platform doesn't
194
+ expose that anywhere yet, so a full semantic compose diff is still not
195
+ built.
196
+
197
+ See `.llm/plans/mozbridge-agent.md` (the "Session progress and
198
+ reconciliation" section at the end) for the full roadmap and how the CLI's
199
+ scope compares to that plan's original acceptance criteria — not
200
+ duplicated here.
201
+
202
+ ## CI / headless use
203
+
204
+ Every command above assumes an interactive human at a terminal —
205
+ `mozbridge login` opens a browser. A CI pipeline has neither, so instead of
206
+ that stored session, set `MOZBRIDGE_TOKEN` in the pipeline environment:
207
+
208
+ ```bash
209
+ export MOZBRIDGE_TOKEN=<service-token>
210
+ mozbridge link --org acme --project web-app
211
+ mozbridge publish
212
+ ```
213
+
214
+ When `MOZBRIDGE_TOKEN` is set, it is used **directly as the bearer token for
215
+ every command** — `whoami`, `link`, `status`, `rollback`, `diff`, and both
216
+ `publish` and `publish --local`'s upload/registration calls — instead of the
217
+ on-disk session `mozbridge login` produces. It's checked before anything
218
+ session-file-related, so nothing under `~/.config/mozbridge/` is read,
219
+ written, or permission-checked in this path at all. Because of that,
220
+ `mozbridge login` itself refuses cleanly when `MOZBRIDGE_TOKEN` is set
221
+ (these are mutually exclusive auth modes — a CI token in the environment
222
+ means "don't attempt an interactive login") — unset it first if you actually
223
+ want to run the device flow.
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.
230
+
231
+ **`MOZBRIDGE_TOKEN` vs `MOZBRIDGE_BUILD_TOKEN` — do not confuse these**:
232
+
233
+ | | `MOZBRIDGE_TOKEN` | `MOZBRIDGE_BUILD_TOKEN` |
234
+ |---|---|---|
235
+ | Authenticates | **every** command, as a project-scoped identity | only `publish --local`'s one `GET /cicd/runtime-secrets` call |
236
+ | Replaces | the entire `mozbridge login` session | nothing — it's used alongside your regular session, which still does the actual build registration |
237
+ | Scope needed | whatever the platform grants a CI/service identity for the commands you run (link/publish/status/rollback) | the `build` scope specifically (registry/build credentials only, never a deploy host key) |
238
+
239
+ They are two separate credentials for two separate purposes and are never
240
+ read from, or substituted for, one another anywhere in this CLI — see
241
+ `session.py` and `runtime_secrets.py`'s module docstrings.
242
+
243
+ **A realistic Woodpecker pipeline step**:
244
+ ```yaml
245
+ steps:
246
+ publish:
247
+ image: python:3.12-slim
248
+ environment:
249
+ MOZBRIDGE_TOKEN:
250
+ from_secret: mozbridge_token
251
+ commands:
252
+ - pip install --quiet mozbridge-cli # or: pip install -e ./cli, per your setup
253
+ - mozbridge link --org acme --project web-app
254
+ - mozbridge publish
255
+ ```
256
+
257
+ A GitHub Actions step looks the same, just spelled with `env:` and
258
+ `${{ secrets.MOZBRIDGE_TOKEN }}`:
259
+ ```yaml
260
+ - name: Publish to Mozbridge
261
+ env:
262
+ MOZBRIDGE_TOKEN: ${{ secrets.MOZBRIDGE_TOKEN }}
263
+ run: |
264
+ mozbridge link --org acme --project web-app
265
+ mozbridge publish
266
+ ```
267
+
268
+ `mozbridge link` still needs to run once per checkout (it writes
269
+ `.mozbridge/link.json` into the working directory, which a fresh CI checkout
270
+ never has) — there is no way to skip it by pre-seeding the link file today,
271
+ so it belongs in the same pipeline step, before `publish`.
272
+
273
+ ## Configuration (advanced / override)
274
+
275
+ | Env var | Default | Purpose |
276
+ |---|---|---|
277
+ | `MOZBRIDGE_LOGTO_ENDPOINT` | `https://auth.mozbridge.com` | Logto tenant endpoint |
278
+ | `MOZBRIDGE_LOGTO_CLIENT_ID` | `swdtsuiz6dgzack1g75x3` | Logto Native app client id (public client) |
279
+ | `MOZBRIDGE_PUBLIC_API_URL` (falls back to `PUBLIC_API_URL`) | `https://api.mozbridge.com` | Mozbridge API base |
280
+ | `MOZBRIDGE_GHCR_ORG` | `jessin01` | GHCR org used to compute default image names for multi-component publish |
281
+ | `MOZBRIDGE_CONFIG_DIR` | `~/.config/mozbridge` | Where the session file lives (mainly for tests) |
282
+ | `MOZBRIDGE_TOKEN` | unset | CI/headless auth — see [CI / headless use](#ci--headless-use) above |
283
+
284
+ All of these have real, working defaults — the CLI works out of the box
285
+ against production Mozbridge. The env vars exist for pointing at a
286
+ non-production instance (and for test isolation).
287
+
288
+ ## Troubleshooting
289
+
290
+ **"Not logged in. Run `mozbridge login` first."** — no session file
291
+ exists yet at `~/.config/mozbridge/session.json` (or `whoami`/`link`/etc.
292
+ was run before ever logging in). Run `mozbridge login`.
293
+
294
+ **"Session expired and cannot be refreshed. Run `mozbridge login` again."**
295
+ — your cached session has no refresh token. Re-run `mozbridge login`.
296
+
297
+ **"Session refresh failed: ... — run `mozbridge login` again."** — the
298
+ access token was expired and the cached refresh token was rejected by
299
+ Logto (revoked, or itself expired). Re-run `mozbridge login`.
300
+
301
+ **"Session is no longer valid. Run `mozbridge login` again."** (from
302
+ `whoami` specifically) — the API returned 401 even with a token that looked
303
+ valid client-side (e.g. revoked server-side). Re-run `mozbridge login`.
304
+
305
+ **"This directory is not linked to a Mozbridge project. Run `mozbridge
306
+ link` first."** — `build`/`publish`/`status`/`rollback` all require a
307
+ `.mozbridge/link.json` in the current directory. Run `mozbridge link`
308
+ (from the same directory you'll run these commands in).
309
+
310
+ **A failed `publish`** — each step (`create_source_upload`, the `PUT`
311
+ upload, `trigger_build`) surfaces the backend's own error `detail` text
312
+ verbatim, prefixed with what it was trying to do, e.g.:
313
+
314
+ ```
315
+ Could not upload the build source: Zip too large
316
+ Could not create a source upload session: Not a member of this organization
317
+ Could not trigger the build: <backend detail text>
318
+ ```
319
+
320
+ For a multi-component publish, a failure on one component does not stop
321
+ the others — you get a per-component `OK`/`FAIL` summary at the end (see
322
+ [docs/commands.md](docs/commands.md#publish) for the exact shape) and the
323
+ process exits non-zero if anything failed.