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.
- mozbridge_cli-0.1.0/.gitignore +7 -0
- mozbridge_cli-0.1.0/PKG-INFO +351 -0
- mozbridge_cli-0.1.0/README.md +323 -0
- mozbridge_cli-0.1.0/docs/commands.md +558 -0
- mozbridge_cli-0.1.0/pyproject.toml +53 -0
- mozbridge_cli-0.1.0/src/mozbridge_cli/__init__.py +3 -0
- mozbridge_cli-0.1.0/src/mozbridge_cli/__main__.py +4 -0
- mozbridge_cli-0.1.0/src/mozbridge_cli/api.py +274 -0
- mozbridge_cli-0.1.0/src/mozbridge_cli/auth.py +179 -0
- mozbridge_cli-0.1.0/src/mozbridge_cli/build.py +108 -0
- mozbridge_cli-0.1.0/src/mozbridge_cli/compose.py +181 -0
- mozbridge_cli-0.1.0/src/mozbridge_cli/config.py +76 -0
- mozbridge_cli-0.1.0/src/mozbridge_cli/link.py +90 -0
- mozbridge_cli-0.1.0/src/mozbridge_cli/local_build.py +140 -0
- mozbridge_cli-0.1.0/src/mozbridge_cli/main.py +821 -0
- mozbridge_cli-0.1.0/src/mozbridge_cli/runtime_secrets.py +140 -0
- mozbridge_cli-0.1.0/src/mozbridge_cli/session.py +156 -0
- mozbridge_cli-0.1.0/tests/conftest.py +19 -0
- mozbridge_cli-0.1.0/tests/test_build.py +84 -0
- mozbridge_cli-0.1.0/tests/test_ci_token_auth.py +191 -0
- mozbridge_cli-0.1.0/tests/test_compose.py +151 -0
- mozbridge_cli-0.1.0/tests/test_diff.py +306 -0
- mozbridge_cli-0.1.0/tests/test_link.py +130 -0
- mozbridge_cli-0.1.0/tests/test_local_build.py +173 -0
- mozbridge_cli-0.1.0/tests/test_login.py +162 -0
- mozbridge_cli-0.1.0/tests/test_login_cli.py +53 -0
- mozbridge_cli-0.1.0/tests/test_logout.py +37 -0
- mozbridge_cli-0.1.0/tests/test_publish.py +147 -0
- mozbridge_cli-0.1.0/tests/test_publish_local.py +371 -0
- mozbridge_cli-0.1.0/tests/test_publish_multi_component.py +311 -0
- mozbridge_cli-0.1.0/tests/test_refresh.py +37 -0
- mozbridge_cli-0.1.0/tests/test_rollback.py +158 -0
- mozbridge_cli-0.1.0/tests/test_runtime_secrets.py +172 -0
- mozbridge_cli-0.1.0/tests/test_session_permissions.py +62 -0
- mozbridge_cli-0.1.0/tests/test_status.py +183 -0
- mozbridge_cli-0.1.0/tests/test_whoami.py +97 -0
|
@@ -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.
|