Skip to content

chore!: rewrite mirrorstack-cli in Rust (login + whoami)#8

Merged
I-am-nothing merged 6 commits into
mainfrom
feat/rust-rewrite
Apr 30, 2026
Merged

chore!: rewrite mirrorstack-cli in Rust (login + whoami)#8
I-am-nothing merged 6 commits into
mainfrom
feat/rust-rewrite

Conversation

@I-am-nothing

@I-am-nothing I-am-nothing commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Closes #5. Replaces #6 (closed) — same OAuth contract, Rust implementation. Module scaffolding (`app module init`) is tracked separately at #9 and lands in a follow-up PR.

What changed

The entire Go codebase is replaced with a Rust workspace using:

Concern Crate
Arg parsing `clap` (derive)
HTTP `reqwest` (blocking, rustls) — no OpenSSL on macOS
JSON `serde` + `serde_json`
Crypto `sha2` + `base64` + `rand`
Paths `dirs`
Atomic file write `tempfile`
Browser open `open`
.env loader `dotenvy`
Errors `thiserror` (lib) + `anyhow` (app)

Surface area

Command What it does
`mirrorstack login` OAuth 2.0 + PKCE (OOB mode); paste code from `/authorize` consent page
`mirrorstack whoami` Print signed-in user's email / name / slug / id (calls `GET /v1/auth/me`)
`mirrorstack --help` / `-V` clap-driven help/version

Module layout

```
src/
├── main.rs entry, dotenvy loader, ExitCode dispatch
├── commands/
│ ├── mod.rs clap Cli root + shared DEFAULT_API_BASE constants
│ ├── login.rs login command
│ └── whoami.rs whoami command
├── auth/
│ ├── mod.rs PKCE, AuthorizeURL, token exchange, typed errors
│ └── tests.rs
├── api.rs authenticated API calls (GET /v1/auth/me)
├── credentials/
│ ├── mod.rs atomic 0600 write to <config_dir>/mirrorstack/cli/credentials.json
│ └── tests.rs
└── browser.rs cross-platform URL opener
```

Security

  • PKCE S256, 32-byte verifier; `subtle`-style constant-time compare in api-platform side
  • Response bodies on /token and /me are read through `Read::take(64KB)` so a hostile endpoint can't OOM the CLI
  • Credentials written atomically via `tempfile::NamedTempFile::persist`, mode 0600 on Unix
  • Typed sentinels for `invalid_grant` / `invalid_request` / `invalid_client` / `unsupported_grant_type` / 5xx so callers can branch

Tests

`cargo test` — 11 pass:

  • auth: PKCE generation correctness + uniqueness, AuthorizeURL params + trailing-slash, token exchange happy + 4 typed sentinels + 5xx → ErrServerError + unknown-code fallthrough
  • api: GET /v1/auth/me happy + 401 → Unauthenticated
  • credentials: save/load roundtrip + ErrNotFound + 0600 file mode (Unix)

Binary size

Build Size (stripped, LTO)
Go (closed PR #6) ~6.0 MB
Rust (this PR) ~3.2 MB

Local testing

```bash
cargo test
cp .env.example .env # MIRRORSTACK_API_URL=http://localhost:8081, MIRRORSTACK_WEB_URL=http://localhost:3000
cargo run -- login
cargo run -- whoami
```

Out of scope

🤖 Generated with Claude Code

I-am-nothing and others added 4 commits April 30, 2026 13:38
Replaces the entire Go codebase with a clap-based Rust implementation.
Same surface area (login + app module init), same OAuth contract
against api-platform.

Why: prefer Rust for the CLI's distribution story (smaller statically
linked binary, polished release tooling) and per-platform OS handler
registration story for the upcoming custom-scheme work (#7).

Crates:
- clap (derive) for arg parsing — no more hand-rolled switch dispatch
- reqwest (blocking, rustls) for HTTP — avoids OpenSSL on macOS
- serde + serde_json — token response decoding
- sha2 + base64 + rand — PKCE generation
- dirs — cross-platform config_dir
- tempfile — atomic credentials write
- open — best-effort browser launcher
- thiserror + anyhow — typed library errors + application errors

Modules:
- src/auth/         OAuth 2.0 + PKCE client (port of internal/auth)
- src/credentials/  Atomic 0600 token persistence (port of internal/credentials)
- src/browser.rs    Cross-platform URL opener (port of internal/browser)
- src/scaffold/     Module scaffold + validation + templates (port of scaffold/)
- src/commands/     Subcommand modules (login, app)

Tests:
- 14 cargo tests cover PKCE generation, AuthorizeURL shape, token
  exchange happy + 4 typed sentinels (invalid_grant / invalid_request /
  invalid_client / unsupported_grant_type) + 5xx ErrServerError +
  unknown OAuth code, credentials roundtrip + 0600 mode + ErrNotFound,
  scaffold name validation + to_title transformations.

Release binary is 3.2MB stripped (was ~6MB for the Go version).

Closes #5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Local-dev convenience: copy .env.example to .env and the CLI picks up
MIRRORSTACK_API_URL / MIRRORSTACK_WEB_URL without prefixing every
invocation. Process env still wins when both are set, so per-call
overrides keep working.

- dotenvy::dotenv().ok() at the top of main, before clap parses args
- .env.example checked in with the localhost defaults
- .env added to .gitignore (real values stay local)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prints the authenticated user identity. Reads access_token from the
saved credentials file, calls GET /v1/auth/me with Authorization:
Bearer, and renders email + name + slug + id.

Friendly errors:
- "not signed in. Run mirrorstack login..." when no credentials file
- "session expired. Run mirrorstack login..." on 401/403

New module src/api.rs hosts authenticated API calls; whoami is the
first caller. Future commands (logout, apps list, etc.) will reuse
the same module.

Auto-refresh on token expiry is a follow-up — for v1 the user
re-runs login.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Findings from the multi-agent review applied:

- /token and /me responses now read through Read::take(64KB) so a
  hostile endpoint can't OOM the CLI with a giant body. Success path
  uses reqwest's resp.json::<T>() helper; the error path bounds the
  read explicitly.
- DEFAULT_API_BASE, DEFAULT_WEB_BASE, ENV_API_URL, ENV_WEB_URL hoisted
  to commands/mod.rs. login + whoami now share one definition.
- ApiError::BuildClient (which discarded the source error) replaced by
  the existing #[from] reqwest::Error path. Reqwest builder failures
  (e.g. TLS config) keep their cause.
- scaffold::run_init no longer Path::exists()-pre-checks before
  fs::create_dir; just calls create_dir and matches AlreadyExists.
  Sidesteps the TOCTOU race between check and write.

Skipped with reason:
- Shared error base for auth+api: only 2 modules; premature
- Removing url dep: tests and AuthorizeURL builder use it directly,
  reqwest's re-export version pinning is fragile
- Lazy iterator over scaffold templates: 18 entries; not worth it
- AuthError::Other vs Unexpected collapse: mild overlap, not worth churn
- authorize_url unwrap_or_else fallback: defensive, will revisit if it
  ever masks a bug

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trim PR #8 to its essential scope. The scaffold command is tracked
in #9 and lands in a follow-up PR — the validation regex and the 18
file templates are the chunky parts and aren't load-bearing for the
auth pipeline this PR is about.

Removes:
- src/commands/app.rs (the App subcommand wrapper)
- src/scaffold/{mod,templates,validate}.rs (~380 lines)
- App variant + dispatch arm in src/commands/mod.rs
- mod scaffold; in src/main.rs
- "app module init" reference in README

The dropped code can be lifted from the prior commits in this branch
when #9 lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@I-am-nothing I-am-nothing changed the title chore!: rewrite mirrorstack-cli in Rust chore!: rewrite mirrorstack-cli in Rust (login + whoami) Apr 30, 2026
Three jobs on every push to main and every PR:

- lint: cargo fmt --check + cargo clippy --all-targets with
  -D warnings (RUSTFLAGS) so any warning fails CI
- test: cargo test --all-features --no-fail-fast on Linux
- build: cargo build --release --locked across
  ubuntu-latest / macos-latest / windows-latest, fail-fast disabled
  so we see all platform issues at once

Swatinem/rust-cache@v2 caches ~/.cargo/{registry,git} + target/
between runs to keep CI cycle time reasonable. CARGO_INCREMENTAL=0
because incremental compilation is wasted in a fresh-checkout CI run.

Source touched only by `cargo fmt --all` (no behavior changes); the
workflow is the substantive add.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@I-am-nothing I-am-nothing merged commit 39bb370 into main Apr 30, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: mirrorstack login (OAuth 2.0 + PKCE)

1 participant