Metadata-Version: 2.5
Name: mozbridge-cli
Version: 0.10.0
Summary: Command-line client for the Mozbridge deployment platform: SSO login, build, publish, status, and rollback for any project hosted on Mozbridge.
Project-URL: Homepage, https://mozbridge.com
Project-URL: Documentation, https://github.com/jessin01/mozbridge/blob/main/cli/docs/commands.md
Project-URL: Repository, https://github.com/jessin01/mozbridge
Author: Mozbridge
License: Proprietary
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Build Tools
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: pyyaml>=6.0
Requires-Dist: typer>=0.12
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Description-Content-Type: text/markdown

# mozbridge-cli

A laptop CLI for the Mozbridge deployment platform. You sign in once via
browser-based SSO (Logto's device-authorization flow — no password, no
long-lived API key typed into a dotfile), link a local directory to a
Mozbridge org + project, and push builds from there. It never holds a
production credential of its own: every request it makes rides the same
human Logto session `mozbridge login` cached, hitting the same REST API the
Mozbridge web dashboard uses.

macOS-only for v1 — there are no Windows code paths.

## Install

There is no published package yet (no PyPI entry, no `mozbridge-cli` on any
index) — install from this checkout:

```bash
pip install -e cli/
# or, for an isolated global install:
pipx install ./cli
```

Either one registers a `mozbridge` console script (from
`cli/pyproject.toml`'s `[project.scripts]`: `mozbridge =
"mozbridge_cli.main:app_main"`). Confirm it's on your `PATH`:

```bash
mozbridge --help
```

Requires Python >= 3.10.

## Quickstart

```bash
mozbridge login                                   # opens a browser to sign in
cd ~/code/my-project
mozbridge link --org acme --project web-app        # bind this directory
mozbridge publish                                  # build + upload + trigger a build
mozbridge status                                   # watch it land
```

Run `mozbridge link` with no flags to pick org/project from a numbered list
interactively instead of naming slugs up front.

## Commands

```
mozbridge login                                   # device-code SSO via Logto
mozbridge whoami                                  # show the signed-in identity (refreshes the token if needed)
mozbridge logout                                  # delete the cached local session
mozbridge link [--org SLUG] [--project SLUG]      # link this directory to a Mozbridge org + project
mozbridge build                                   # zip the current directory (does not upload)
mozbridge publish                                 # build + upload + trigger a build on the linked project
mozbridge diff                                    # preview what `publish` would do right now — makes no mutating call
mozbridge status                                  # show the linked project + its 5 most recent deployments
mozbridge rollback [--to ID] [--yes]              # roll back the linked project's deployment
```

Full flag-by-flag reference, example output, and every documented failure
mode: **[docs/commands.md](docs/commands.md)**.

## How project linking works

`mozbridge link` writes `.mozbridge/link.json` in the **current directory**
— not in the CLI's own `~/.config/mozbridge`. It records which org/project
that specific repo checkout is bound to:

```json
{
  "org_id": 1,
  "org_slug": "acme",
  "project_id": 10,
  "project_slug": "web-app"
}
```

This is per-repo working-tree state, not per-user CLI state — a fresh clone
of the same repo on another machine has no link file until `mozbridge link`
is run there too. **It is not committed.** `link` best-effort appends
`.mozbridge/` to an existing `.gitignore` in the current directory (and
tells you so); if there's no `.gitignore` there yet, it prints a note asking
you to add the entry yourself rather than creating one. Either way: make
sure `.mozbridge/` is actually gitignored in real repos, since the file
contains real org/project identifiers.

`build`, `publish`, `status`, and `rollback` all read this file and fail
with a clear message if it's missing (see
[docs/commands.md](docs/commands.md) for the exact text).

## How multi-component publish works

Most real Mozbridge tenants are multi-service (a backend + a frontend +
workers), not a single whole-directory image. `mozbridge publish` detects
this from a Compose file in the current directory and, when it finds one,
publishes each buildable service as its own image — one
`create_source_upload` → `PUT` → `trigger_build` cycle per component —
instead of the single zip-the-whole-directory flow.

**Discovery order** (Docker Compose's own file-discovery order, checked in
this exact sequence, first match wins): `docker-compose.yml`,
`compose.yaml`, `docker-compose.yaml`.

**What counts as a "component"**: any top-level service in that file with a
`build:` key — either the short form (`build: ./backend`) or the mapping
form (`build: {context: ..., dockerfile: ...}`). A service with only an
`image:` key (no `build:`) is not a component and is skipped — that's how a
`redis:` or `postgres:` service in the same compose file is correctly
ignored.

**Shared-context de-duplication**: two components whose resolved build
context is the same directory — even if spelled differently in YAML (`.`
vs `./`) — are zipped and uploaded exactly once, then each triggers its own
build reusing that one `upload_id`. This is genuinely a byte-savings and
correctness behavior, not just a nicety: two services building from the
same monorepo root would otherwise ship two identical uploads.

**Per-component image naming**: each component's image name is computed
client-side as
`ghcr.io/{GHCR_ORG}/mozbridge-tenants/{org_slug}/{project_slug}-{service_name}`
(the same base the backend computes by default for a single-component
publish, with `-{service_name}` appended so N components never collide on
one image).

**Partial failure**: every component is attempted even if an earlier one
fails — one broken service's build failing does not stop the others from
being attempted. At the end, `publish` prints a summary line
(`Publish summary: N succeeded, M failed (of TOTAL)`) followed by one `OK`
or `FAIL` line per component, and exits non-zero if anything failed.

**No compose file, or none of its services build**: `publish` falls back
unchanged to the original single-component flow — zip the whole current
directory, one upload, one `trigger_build` call, with no `image_name`,
`context_path`, or `dockerfile_path` override sent, so the backend's own
defaults apply exactly as they always have. This is the fallback path, not
a separate mode you opt into — an ordinary non-Compose project (a single
Dockerfile app, or no Dockerfile at all) publishes exactly as it did before
multi-component support existed.

## Security model

- **Device-flow SSO, not a stored password or long-lived API key.**
  `mozbridge login` runs Logto's OAuth 2.0 Device Authorization Grant (RFC
  8628) against a real, provisioned Logto Native (public) client. You
  approve the login in a browser; the CLI never sees or handles your
  credentials directly.
- **Session cache**: the resulting access/refresh/ID tokens are cached at
  `~/.config/mozbridge/session.json`. The directory is created `0700` and
  the file is written `0600` on every save (verified against real
  filesystem state, not mocked — see `cli/tests/test_session_permissions.py`),
  the same posture the project's SSH-broker design used for private key
  material. An expired access token is refreshed transparently before any
  command that needs one; if refresh itself fails (dead/revoked refresh
  token, or none was ever cached) you're told to `mozbridge login` again
  rather than being left in a half-authenticated state.
- **What the CLI does NOT hold**: no service-token, no deploy credential, no
  SSH key, no registry password. Every request uses the plain human Logto
  bearer token `login` cached, sent exactly like a browser session would
  send it.
- **A real, documented platform gap — stated plainly, not alarmingly**:
  today, request-level authorization for mutating project actions (publish,
  rollback) beyond ordinary org/project membership is not yet enforced on
  the backend for human-authenticated callers. Concretely:
  `require_deploy_scope` (`backend/app/features/identity/token_scopes.py`)
  is built on `require_token_scopes(...)`, whose check body returns
  immediately — a complete no-op — for any caller that is not a
  `mz_`-prefixed ServiceToken (`is_service_identity(user)` is `False` for a
  plain human Logto token). The real gate a human session hits is ordinary
  org/project membership (`get_current_org` + `get_valid_project`), not a
  deploy-scope check. The `rollback/{deployment_id}` route has no scope
  dependency on it at all. This is a **backend finding**, not something
  introduced by or specific to this CLI — it applies equally to the web
  dashboard's own session tokens, and it was surfaced (independently,
  twice) while building this CLI's `publish` and `rollback` commands. It's
  tracked as a known platform gap in `.llm/plans/gap-register.md` and in
  the session notes at the end of `.llm/plans/mozbridge-agent.md` — not
  something this doc oversells or that the CLI works around.

## What's NOT built yet

- **`mozbridge deploy`** — the platform-orchestrated deploy path
  (`/cicd/deploy`). `publish` uses a different, already-working mechanism
  (`create_source_upload` + `trigger_build` with `source_type="upload"`)
  that builds fresh source into a new image — it does not go through
  `/cicd/deploy` today.
- Build-time secrets for multi-component publish.
- Windows support.

`mozbridge diff` (preview what `publish` would do, no mutating calls) is
now built — see [docs/commands.md](docs/commands.md#mozbridge-diff). It
compares local components against the most recent deployment *record*
only, not the actually-deployed compose content — the platform doesn't
expose that anywhere yet, so a full semantic compose diff is still not
built.

See `.llm/plans/mozbridge-agent.md` (the "Session progress and
reconciliation" section at the end) for the full roadmap and how the CLI's
scope compares to that plan's original acceptance criteria — not
duplicated here.

## CI / headless use

Every command above assumes an interactive human at a terminal —
`mozbridge login` opens a browser. A CI pipeline has neither, so instead of
that stored session, set `MOZBRIDGE_TOKEN` in the pipeline environment:

```bash
export MOZBRIDGE_TOKEN=<service-token>
mozbridge link --org acme --project web-app
mozbridge publish
```

When `MOZBRIDGE_TOKEN` is set, it is used **directly as the bearer token for
every command** — `whoami`, `link`, `status`, `rollback`, `diff`, and both
`publish` and `publish --local`'s upload/registration calls — instead of the
on-disk session `mozbridge login` produces. It's checked before anything
session-file-related, so nothing under `~/.config/mozbridge/` is read,
written, or permission-checked in this path at all. Because of that,
`mozbridge login` itself refuses cleanly when `MOZBRIDGE_TOKEN` is set
(these are mutually exclusive auth modes — a CI token in the environment
means "don't attempt an interactive login") — unset it first if you actually
want to run the device flow.

**Where the token comes from**: a project- or org-scoped Mozbridge
`ServiceToken`. Mint one directly from this CLI — `mozbridge token create
--scope deploy` (requires an active `mozbridge login` session; see
`mozbridge token create --help`) — or out of band via the dashboard/API
(`create_service_token`). Set it as your CI platform's secret store, never
as a `--token` flag (it would leak into shell history and process
listings) and never committed to the repo.

**`MOZBRIDGE_TOKEN` vs `MOZBRIDGE_BUILD_TOKEN` — do not confuse these**:

| | `MOZBRIDGE_TOKEN` | `MOZBRIDGE_BUILD_TOKEN` |
|---|---|---|
| Authenticates | **every** command, as a project-scoped identity | only `publish --local`'s one `GET /cicd/runtime-secrets` call |
| Replaces | the entire `mozbridge login` session | nothing — it's used alongside your regular session, which still does the actual build registration |
| 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) |

They are two separate credentials for two separate purposes and are never
read from, or substituted for, one another anywhere in this CLI — see
`session.py` and `runtime_secrets.py`'s module docstrings.

**A realistic Woodpecker pipeline step**:
```yaml
steps:
  publish:
    image: python:3.12-slim
    environment:
      MOZBRIDGE_TOKEN:
        from_secret: mozbridge_token
    commands:
      - pip install --quiet mozbridge-cli   # or: pip install -e ./cli, per your setup
      - mozbridge link --org acme --project web-app
      - mozbridge publish
```

A GitHub Actions step looks the same, just spelled with `env:` and
`${{ secrets.MOZBRIDGE_TOKEN }}`:
```yaml
      - name: Publish to Mozbridge
        env:
          MOZBRIDGE_TOKEN: ${{ secrets.MOZBRIDGE_TOKEN }}
        run: |
          mozbridge link --org acme --project web-app
          mozbridge publish
```

`mozbridge link` still needs to run once per checkout (it writes
`.mozbridge/link.json` into the working directory, which a fresh CI checkout
never has) — there is no way to skip it by pre-seeding the link file today,
so it belongs in the same pipeline step, before `publish`.

## Configuration (advanced / override)

| Env var | Default | Purpose |
|---|---|---|
| `MOZBRIDGE_LOGTO_ENDPOINT` | `https://auth.mozbridge.com` | Logto tenant endpoint |
| `MOZBRIDGE_LOGTO_CLIENT_ID` | `7zkurxlp5x98iw217wrhw` | Logto Native app client id (public client, device-flow-capable) |
| `MOZBRIDGE_PUBLIC_API_URL` (falls back to `PUBLIC_API_URL`) | `https://api.mozbridge.com` | Mozbridge API base |
| `MOZBRIDGE_GHCR_ORG` | `jessin01` | GHCR org used to compute default image names for multi-component publish |
| `MOZBRIDGE_CONFIG_DIR` | `~/.config/mozbridge` | Where the session file lives (mainly for tests) |
| `MOZBRIDGE_TOKEN` | unset | CI/headless auth — see [CI / headless use](#ci--headless-use) above |

All of these have real, working defaults — the CLI works out of the box
against production Mozbridge. The env vars exist for pointing at a
non-production instance (and for test isolation).

## Troubleshooting

**"Not logged in. Run `mozbridge login` first."** — no session file
exists yet at `~/.config/mozbridge/session.json` (or `whoami`/`link`/etc.
was run before ever logging in). Run `mozbridge login`.

**"Session expired and cannot be refreshed. Run `mozbridge login` again."**
— your cached session has no refresh token. Re-run `mozbridge login`.

**"Session refresh failed: ... — run `mozbridge login` again."** — the
access token was expired and the cached refresh token was rejected by
Logto (revoked, or itself expired). Re-run `mozbridge login`.

**"Session is no longer valid. Run `mozbridge login` again."** (from
`whoami` specifically) — the API returned 401 even with a token that looked
valid client-side (e.g. revoked server-side). Re-run `mozbridge login`.

**"This directory is not linked to a Mozbridge project. Run `mozbridge
link` first."** — `build`/`publish`/`status`/`rollback` all require a
`.mozbridge/link.json` in the current directory. Run `mozbridge link`
(from the same directory you'll run these commands in).

**A failed `publish`** — each step (`create_source_upload`, the `PUT`
upload, `trigger_build`) surfaces the backend's own error `detail` text
verbatim, prefixed with what it was trying to do, e.g.:

```
Could not upload the build source: Zip too large
Could not create a source upload session: Not a member of this organization
Could not trigger the build: <backend detail text>
```

For a multi-component publish, a failure on one component does not stop
the others — you get a per-component `OK`/`FAIL` summary at the end (see
[docs/commands.md](docs/commands.md#publish) for the exact shape) and the
process exits non-zero if anything failed.
