docs: CLAUDE.md, CONTRIBUTING.md, CHANGELOG.md, docs/
* CLAUDE.md: project layout, key conventions, where to look first. Captures the non-obvious things a future session needs. * CONTRIBUTING.md: build/test workflow, how to add a subcommand (concrete walkthrough), code style. * CHANGELOG.md: history. 0.1.0 entry covers initial feature set; Unreleased captures stability + optimization batch. * docs/architecture.md: module graph, layering rules, the HTTP funnel, pager + SIGINT, repo resolution, test strategy. * docs/jq.md: --jq syntax cheatsheet (dot paths, brackets, negative indices, pipes, what's not supported). * docs/troubleshooting.md: keychain re-prompts, debug logging, pager opt-out, alias precedence, hook bypass, common 401s. * README.md: links into docs/ and updates binary size to 4 MB. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a6fbf45ba9
commit
d87a30bb29
63
CHANGELOG.md
Normal file
63
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes will be recorded here. The format follows
|
||||||
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions follow
|
||||||
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `tokio::signal::ctrl_c()` race in `cli::run` so the pager guard drops
|
||||||
|
cleanly on SIGINT.
|
||||||
|
- 9 wiremock-backed HTTP client integration tests covering retry
|
||||||
|
behavior, header forwarding, pagination, and panic-free error paths.
|
||||||
|
- `Client::for_base_url` test constructor pointing at an arbitrary URL.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Trimmed dependencies (no more `indicatif`, `futures-util`,
|
||||||
|
`is-terminal`, `textwrap`, `tempfile`). Dropped reqwest features we
|
||||||
|
don't use (`stream`, `brotli`). Release profile now uses `lto = "fat"`
|
||||||
|
and `panic = "abort"`.
|
||||||
|
- HTTP retry loop builds the request once and clones via
|
||||||
|
`reqwest::Request::try_clone` per attempt (was rebuilding the full
|
||||||
|
RequestBuilder each time).
|
||||||
|
- Binary size: 5.94 MB → 4.15 MB stripped (-30%).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Removed the unsafe `std::env::set_var("FJ_NO_PAGER")` from dispatch.
|
||||||
|
The `--no-pager` flag is now threaded into `pager::maybe_start`.
|
||||||
|
- Replaced the panicking `.expect("token contains invalid header chars")`
|
||||||
|
in `auth_headers` with a typed error that tells the user how to
|
||||||
|
recover.
|
||||||
|
|
||||||
|
## 0.1.0 — 2026-05-13
|
||||||
|
|
||||||
|
Initial release. Multi-host Forgejo CLI with feature parity to `gh`
|
||||||
|
across the surface Forgejo exposes. Commands:
|
||||||
|
|
||||||
|
- `auth`: login, status, logout, list, switch, token, refresh, setup-git
|
||||||
|
- `repo`: list, view, clone, create, fork, sync, edit, rename, archive,
|
||||||
|
unarchive, delete, branches, topics, mirror, mirror-sync
|
||||||
|
- `issue`: list, view, create, edit, close, reopen, comment, develop
|
||||||
|
- `pr`: list, view, create, edit, diff, commits, files, checks, ready,
|
||||||
|
review, status, checkout, merge, close, reopen
|
||||||
|
- `release`: list, view, create, edit, delete, upload, download
|
||||||
|
- `label`, `run`, `secret`, `variable`, `search`, `browse`, `status`,
|
||||||
|
`org`, `ssh-key`, `gpg-key`, `alias`, `config`, `protect`, `hook`,
|
||||||
|
`extension`, `gist`, `api`, `completion`, `man`
|
||||||
|
|
||||||
|
Other highlights:
|
||||||
|
|
||||||
|
- Repo auto-detection from `upstream` / `origin` git remote.
|
||||||
|
- `--web` flag on all list/view subcommands.
|
||||||
|
- `$EDITOR` integration for body inputs.
|
||||||
|
- `fj api` with `-H`, `-X`, `-f`, `-F`, `--paginate`, `--include`,
|
||||||
|
`--silent`, `--jq` (dot-paths, `[N]`/`[-N]`, pipes).
|
||||||
|
- `--debug` / `FJ_DEBUG` request logging.
|
||||||
|
- Tokens in the OS keychain.
|
||||||
|
- Pager via `dup2` redirect to `$FJ_PAGER` / `$PAGER` / `less -FRX`.
|
||||||
|
- Pre-push hook running fmt, clippy `-D warnings`, tests, and release
|
||||||
|
build before any push. Live API smoke gated on `FJ_E2E=1`.
|
||||||
83
CLAUDE.md
Normal file
83
CLAUDE.md
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
# fj — project notes for Claude
|
||||||
|
|
||||||
|
`fj` is a Forgejo CLI, in the spirit of GitHub's `gh`. Multi-host, tokens
|
||||||
|
in the OS keychain, ~10k LOC Rust with full feature parity to `gh` for
|
||||||
|
the surface Forgejo exposes.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
main.rs entry: alias expansion, fj-<name> plugin dispatch
|
||||||
|
cli/ clap subcommand tree
|
||||||
|
mod.rs top-level Cli + dispatch + Ctrl+C race + pager
|
||||||
|
context.rs RepoFlag + resolve_repo (`-R` or git-remote detect)
|
||||||
|
editor.rs $EDITOR + body resolution helpers
|
||||||
|
web.rs cross-platform `--web` opener
|
||||||
|
<subcommand>.rs one file per command group (auth, repo, pr, …)
|
||||||
|
api/ typed wrappers around /api/v1
|
||||||
|
mod.rs split_repo + shared serde helpers
|
||||||
|
<resource>.rs one file per resource (issue, pull, release, …)
|
||||||
|
client/ HTTP client
|
||||||
|
mod.rs Client, retry-on-5xx, debug logging, headers
|
||||||
|
pagination.rs Page<T> + Link header parser
|
||||||
|
error.rs ApiError
|
||||||
|
integration_tests wiremock-based tests
|
||||||
|
config/ hosts.toml shape + load/save
|
||||||
|
auth/ OS keychain wrapper
|
||||||
|
git/ git invocation + remote URL parser
|
||||||
|
output/ tables, colors, relative_time, state_pill, pager
|
||||||
|
hooks/pre-push local CI gate
|
||||||
|
scripts/ install-hooks.sh, e2e-smoke.sh
|
||||||
|
docs/ architecture, jq syntax, troubleshooting
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build / test / hook
|
||||||
|
|
||||||
|
- `cargo build --release` produces a ~4 MB binary at `target/release/fj`.
|
||||||
|
- `cargo test --all` runs 60 tests (51 unit + 9 wiremock integration).
|
||||||
|
- `cargo clippy --all-targets --all-features -- -D warnings` is the lint
|
||||||
|
bar. CI (the pre-push hook) treats warnings as errors.
|
||||||
|
- `./scripts/install-hooks.sh` symlinks `hooks/pre-push` into `.git/hooks`.
|
||||||
|
- `FJ_E2E=1` enables the live API smoke against `stephen/fj-cli-test`.
|
||||||
|
- `FJ_SKIP_PREPUSH=1` bypasses the hook for genuine emergencies only.
|
||||||
|
|
||||||
|
## Key conventions
|
||||||
|
|
||||||
|
- Every repo-scoped subcommand accepts `-R/--repo` AND auto-detects from
|
||||||
|
the git remote (`upstream` then `origin`). The resolver is
|
||||||
|
`cli::context::resolve_repo`.
|
||||||
|
- Bodies (`--body`) support: an inline string, `-` for stdin, or omit to
|
||||||
|
open `$EDITOR`. See `cli::editor`.
|
||||||
|
- Lists with `--limit > 50` transparently follow `Link: rel=next` via
|
||||||
|
`Client::get_all`. Per-page cap is the Forgejo default (50).
|
||||||
|
- The simple jq projector lives in `cli::api::extract_path`. Supports
|
||||||
|
`.field`, `.0`, `.[0]`, `[-1]`, and `|` pipes. See `docs/jq.md`.
|
||||||
|
- All commands respect `--host` / `FJ_HOST`, `--debug` / `FJ_DEBUG`,
|
||||||
|
`--no-pager`, `FJ_PAGER` / `PAGER`.
|
||||||
|
|
||||||
|
## Things to remember
|
||||||
|
|
||||||
|
- Tokens are stored in the OS keychain (service `fj`, key = hostname).
|
||||||
|
Never serialized to disk. Re-prompts after rebuilds are expected on
|
||||||
|
macOS because the binary hash changes; click "Always Allow."
|
||||||
|
- `cargo test` builds a separate test binary with a different hash, so
|
||||||
|
it gets its own keychain prompt. Tests that need keychain access are
|
||||||
|
the wiremock-backed ones in `src/client/integration_tests.rs`, which
|
||||||
|
bypass the keychain via `Client::for_base_url`.
|
||||||
|
- The repo URL in `Cargo.toml` is `https://rasterhub.com/rasterstate/fj`.
|
||||||
|
- Commit author is `Stephen Way <stephen@rasterstate.com>`. The user has
|
||||||
|
a CLAUDE.md preference: no em-dashes, terse responses, no preamble.
|
||||||
|
- MSRV is 1.82 (we use `Option::is_none_or`).
|
||||||
|
|
||||||
|
## Where to look first
|
||||||
|
|
||||||
|
- Adding a new subcommand: copy an existing `cli/<x>.rs` (e.g. `label`).
|
||||||
|
Wire into `cli/mod.rs` (`Command` enum, `dispatch`) and `main.rs`
|
||||||
|
(`KNOWN_SUBCOMMANDS`). Typed API wrapper in `api/<x>.rs`.
|
||||||
|
- Adding a new Forgejo endpoint: `api/<resource>.rs` is the right home.
|
||||||
|
- Touching HTTP behavior: `client/mod.rs::request_with_headers` is the
|
||||||
|
one spot all requests funnel through. Don't add a parallel code path.
|
||||||
|
- Test a new API surface: add to `src/client/integration_tests.rs`
|
||||||
|
rather than `tests/` (this is a binary crate, no `[lib]` target, so
|
||||||
|
integration tests live inline).
|
||||||
77
CONTRIBUTING.md
Normal file
77
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# Contributing to fj
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone ssh://git@rasterhub.com:2222/rasterstate/fj.git
|
||||||
|
cd fj
|
||||||
|
./scripts/install-hooks.sh # symlinks hooks/pre-push into .git/hooks
|
||||||
|
cargo build --release # 4 MB binary at target/release/fj
|
||||||
|
```
|
||||||
|
|
||||||
|
The hook gates every push on fmt, clippy (warnings as errors), the full
|
||||||
|
test suite, and a release build. Set `FJ_E2E=1` to additionally run the
|
||||||
|
live API smoke (requires `fj auth login` already done).
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Branch off `main`.
|
||||||
|
2. Make changes. Keep commits focused.
|
||||||
|
3. `cargo fmt --all` before committing.
|
||||||
|
4. The pre-push hook runs the full bar; if it fails, fix the issue
|
||||||
|
rather than passing `FJ_SKIP_PREPUSH=1` unless you genuinely need
|
||||||
|
to bypass.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- 60 tests live in the source tree (51 unit + 9 wiremock integration).
|
||||||
|
- Unit tests stay close to the code they cover, in inline `#[cfg(test)]`
|
||||||
|
modules.
|
||||||
|
- HTTP integration tests live in `src/client/integration_tests.rs` and
|
||||||
|
use a wiremock server. Construct test clients via
|
||||||
|
`Client::for_base_url(base, token)`.
|
||||||
|
- Live API tests live in `scripts/e2e-smoke.sh` and exercise read-only
|
||||||
|
operations against the configured Forgejo. Toggle with `FJ_E2E=1`.
|
||||||
|
|
||||||
|
## Adding a subcommand
|
||||||
|
|
||||||
|
Concrete example: adding `fj sticker list`.
|
||||||
|
|
||||||
|
1. `src/api/sticker.rs` — typed API wrappers and request/response
|
||||||
|
structs. Use the `serde_util::deserialize_null_to_default` helper
|
||||||
|
for fields that Forgejo returns as `null` when empty.
|
||||||
|
2. `src/cli/sticker.rs` — clap `Args` / `Subcommand` definitions and
|
||||||
|
the `run` dispatch.
|
||||||
|
3. `src/api/mod.rs` — `pub mod sticker;`
|
||||||
|
4. `src/cli/mod.rs` — `pub mod sticker;`, add to `Command`, add the
|
||||||
|
dispatch arm in `dispatch()`.
|
||||||
|
5. `src/main.rs` — add `"sticker"` to `KNOWN_SUBCOMMANDS` so plugin
|
||||||
|
discovery doesn't shadow it.
|
||||||
|
6. Add inline `#[cfg(test)] mod tests` for any non-trivial pure code.
|
||||||
|
7. If you touched HTTP behavior, add a wiremock test in
|
||||||
|
`src/client/integration_tests.rs`.
|
||||||
|
|
||||||
|
## Code style
|
||||||
|
|
||||||
|
- See `CLAUDE.md` for in-tree conventions.
|
||||||
|
- One small line of comment max per non-obvious thing. Don't narrate
|
||||||
|
what code does, only why.
|
||||||
|
- No `.unwrap()` / `.expect()` in non-test code paths. Use `?` and
|
||||||
|
`anyhow::Error::context` to attach actionable messages.
|
||||||
|
- Prefer `Result` over panics. The one exception is `unreachable!()`
|
||||||
|
for genuinely impossible match arms, but flag it in review.
|
||||||
|
|
||||||
|
## Releasing
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release
|
||||||
|
./target/release/fj completion zsh > ~/.zfunc/_fj
|
||||||
|
./target/release/fj man -o ./man
|
||||||
|
```
|
||||||
|
|
||||||
|
No cargo-dist or Homebrew tap yet. Binary lives at `target/release/fj`;
|
||||||
|
symlink into a user-level bin dir.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. By contributing you agree to the same license.
|
||||||
12
README.md
12
README.md
|
|
@ -112,11 +112,21 @@ fj api /repos/migrate --input '{"clone_addr":"...","repo_name":"x","repo_owner":
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo build --release # 5-6 MB stripped binary at target/release/fj
|
cargo build --release # ~4 MB stripped binary at target/release/fj
|
||||||
./target/release/fj completion zsh > ~/.zfunc/_fj
|
./target/release/fj completion zsh > ~/.zfunc/_fj
|
||||||
./target/release/fj man -o ~/man/man1
|
./target/release/fj man -o ~/man/man1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [`docs/architecture.md`](docs/architecture.md) — module graph, HTTP
|
||||||
|
funnel, pager + SIGINT, repo resolution
|
||||||
|
- [`docs/jq.md`](docs/jq.md) — `fj api --jq` projection syntax
|
||||||
|
- [`docs/troubleshooting.md`](docs/troubleshooting.md) — keychain
|
||||||
|
prompts, 401s, hook bypass, pager opt-out, alias precedence
|
||||||
|
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — build / test / release workflow
|
||||||
|
- [`CHANGELOG.md`](CHANGELOG.md) — release notes
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
|
||||||
11
docs/README.md
Normal file
11
docs/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# `fj` documentation
|
||||||
|
|
||||||
|
- [`architecture.md`](architecture.md) — module graph, layering rules,
|
||||||
|
the HTTP funnel, pager + SIGINT, repo resolution, test strategy.
|
||||||
|
- [`jq.md`](jq.md) — the `fj api --jq` syntax. Dot paths, brackets,
|
||||||
|
negative indices, pipes.
|
||||||
|
- [`troubleshooting.md`](troubleshooting.md) — keychain prompts, hangs,
|
||||||
|
401 errors, `--debug`, pager opt-out, alias precedence, hook bypass.
|
||||||
|
|
||||||
|
For build/test workflow see [`../CONTRIBUTING.md`](../CONTRIBUTING.md).
|
||||||
|
For project conventions see [`../CLAUDE.md`](../CLAUDE.md).
|
||||||
138
docs/architecture.md
Normal file
138
docs/architecture.md
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
# Architecture
|
||||||
|
|
||||||
|
`fj` is a thin Rust CLI over Forgejo's `/api/v1`. The whole binary is
|
||||||
|
~10k LOC and shapes itself like `gh`: clap-parsed subcommands, one
|
||||||
|
typed wrapper per API resource, one HTTP client funnel for everything.
|
||||||
|
|
||||||
|
## Module graph
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ main.rs │ alias expansion + fj-<x> plugin dispatch
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
┌──────▼──────┐
|
||||||
|
│ cli::run │ Ctrl+C race + pager guard
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
┌─────────────┼────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
┌────▼─────┐ ┌─────▼─────┐ ┌──────▼──────┐
|
||||||
|
│ cli::auth│ │ cli::repo │ ... │ cli::api │ raw escape hatch
|
||||||
|
└────┬─────┘ └─────┬─────┘ └──────┬──────┘
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────▼─────┐ │
|
||||||
|
│ │ context:: │ -R or git-remote autodetect
|
||||||
|
│ │resolve_repo│
|
||||||
|
│ └─────┬─────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────▼─────┐ │
|
||||||
|
└──────►│ api::* │◄─────────────┘
|
||||||
|
│ typed │
|
||||||
|
│ wrappers │
|
||||||
|
└─────┬─────┘
|
||||||
|
│
|
||||||
|
┌─────▼─────┐
|
||||||
|
│ client │ one funnel: auth headers, retry, pagination,
|
||||||
|
│ │ debug logging, custom headers
|
||||||
|
└─────┬─────┘
|
||||||
|
│
|
||||||
|
┌─────▼─────┐
|
||||||
|
│ reqwest │ rustls-tls, JSON, multipart
|
||||||
|
└───────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layering rules
|
||||||
|
|
||||||
|
- `cli::*` handles flags, argument validation, output formatting. It
|
||||||
|
never makes HTTP calls directly: it calls `api::*`.
|
||||||
|
- `api::*` is one file per Forgejo resource. Each function takes a
|
||||||
|
`&Client` and returns typed `Result<T>`.
|
||||||
|
- `client::*` is the only place that touches `reqwest`. All HTTP goes
|
||||||
|
through `Client::request_with_headers`, which handles auth headers,
|
||||||
|
retries, debug logging, and custom header merging. There is no
|
||||||
|
parallel HTTP path (other than the multipart asset upload in
|
||||||
|
`api::release::upload_asset`, which goes through `client.http()` but
|
||||||
|
still uses `client.token()` for auth).
|
||||||
|
- `config::*` is the on-disk shape of `hosts.toml`. Tokens are NOT
|
||||||
|
stored here. They live in the OS keychain via `auth::*`.
|
||||||
|
- `git::*` shells out to the `git` binary. The only place we call git.
|
||||||
|
|
||||||
|
## Repo resolution
|
||||||
|
|
||||||
|
Every command that operates on a specific repo calls
|
||||||
|
`cli::context::resolve_repo(explicit_repo, explicit_host)`. The
|
||||||
|
algorithm:
|
||||||
|
|
||||||
|
1. If `-R/--repo` was given, parse it as `owner/name`.
|
||||||
|
2. Otherwise call `git remote -v` and pick the first remote that parses
|
||||||
|
as a Forgejo URL. Preference order is `upstream`, then `origin`,
|
||||||
|
then any other.
|
||||||
|
3. The host comes from `--host`, then the detected remote's host, then
|
||||||
|
the configured default host in `hosts.toml`.
|
||||||
|
|
||||||
|
This is the magic that lets `fj pr list` work without any flags inside
|
||||||
|
a clone.
|
||||||
|
|
||||||
|
## HTTP retry
|
||||||
|
|
||||||
|
In `client::request_with_headers`:
|
||||||
|
|
||||||
|
- The request is built once and stored as `reqwest::Request`.
|
||||||
|
- Idempotent methods (GET, HEAD, OPTIONS, PUT, DELETE) get 3 attempts.
|
||||||
|
- POST and PATCH get 1 attempt — they're never retried.
|
||||||
|
- Backoff is `200ms * 2^attempt` between retries.
|
||||||
|
- On 5xx or transport error, we retry. On any other status, we return.
|
||||||
|
|
||||||
|
## Auto-pagination
|
||||||
|
|
||||||
|
`Client::get_all(path, query, total_limit)` follows `Link: rel=next`
|
||||||
|
until either `total_limit` items have accumulated or there are no more
|
||||||
|
pages. List commands switch to this path automatically when the user
|
||||||
|
asks for `--limit > 50` (Forgejo's per-page cap).
|
||||||
|
|
||||||
|
## Pager
|
||||||
|
|
||||||
|
When `cli::run` dispatches a command whose output can run long (list,
|
||||||
|
view, diff, search, status, api), it calls
|
||||||
|
`output::pager::maybe_start(force_disabled)`. That function:
|
||||||
|
|
||||||
|
1. Bails if stdout isn't a TTY or `--no-pager` / `FJ_NO_PAGER` is set.
|
||||||
|
2. Spawns `$FJ_PAGER` / `$PAGER` / `less -FRX`.
|
||||||
|
3. `dup`s our stdout fd (saving it for restore) then `dup2`s the pager
|
||||||
|
child's stdin fd onto stdout (fd 1).
|
||||||
|
4. Returns a `PagerGuard` whose `Drop` impl flushes stdout, restores
|
||||||
|
the saved fd, closes the saved fd, and waits on the child.
|
||||||
|
|
||||||
|
This means the rest of `fj` keeps using `println!`/`print!` unmodified
|
||||||
|
even though it's now writing to the pager.
|
||||||
|
|
||||||
|
## SIGINT
|
||||||
|
|
||||||
|
`cli::run` `tokio::select!`s the command future against
|
||||||
|
`tokio::signal::ctrl_c()`. On SIGINT the command future is dropped,
|
||||||
|
which propagates to the `_pager` binding's `Drop` and tears down the
|
||||||
|
pager cleanly.
|
||||||
|
|
||||||
|
## Aliases and extensions
|
||||||
|
|
||||||
|
`main.rs` runs two passes before clap:
|
||||||
|
|
||||||
|
1. **Alias expansion** via `cli::alias::expand_argv`. Reads
|
||||||
|
`aliases.toml`. If `argv[1]` matches a defined alias, replaces it
|
||||||
|
with the tokenized expansion.
|
||||||
|
2. **Plugin dispatch** via `main::detect_plugin`. If `argv[1]` isn't a
|
||||||
|
known subcommand AND `fj-<name>` is on PATH, exec it.
|
||||||
|
|
||||||
|
Only if neither pass triggers do we hand off to clap.
|
||||||
|
|
||||||
|
## Test strategy
|
||||||
|
|
||||||
|
- **Unit tests** stay inline next to the code they cover. Pure
|
||||||
|
functions only (no I/O).
|
||||||
|
- **Integration tests** for HTTP behavior live in
|
||||||
|
`src/client/integration_tests.rs` and use a wiremock server.
|
||||||
|
Construct test clients via `Client::for_base_url`.
|
||||||
|
- **Live E2E** lives in `scripts/e2e-smoke.sh`, gated on `FJ_E2E=1`
|
||||||
|
in the pre-push hook. It hits the real Forgejo at `rasterhub.com`
|
||||||
|
against `stephen/fj-cli-test`.
|
||||||
76
docs/jq.md
Normal file
76
docs/jq.md
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# `fj api --jq` syntax
|
||||||
|
|
||||||
|
`fj api` ships a small jq-flavored JSON projector. It's not a full jq
|
||||||
|
implementation; it covers the 95% of cases gh users use `--jq` for.
|
||||||
|
|
||||||
|
If you need anything more complex, pipe the raw JSON through real `jq`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
fj api /repos/foo/bar | jq '.<full expression>'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stages
|
||||||
|
|
||||||
|
Expressions are split on `|` into stages. Each stage runs against the
|
||||||
|
output of the previous stage.
|
||||||
|
|
||||||
|
```
|
||||||
|
fj api /repos/foo/bar | head
|
||||||
|
# vs
|
||||||
|
fj api /repos/foo/bar -q '.owner | .login'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Path segments
|
||||||
|
|
||||||
|
Within a stage, dot-separated segments traverse the JSON tree. Each
|
||||||
|
segment is one of:
|
||||||
|
|
||||||
|
| Segment | Meaning |
|
||||||
|
| ------------ | --------------------------------------------- |
|
||||||
|
| `.field` | Object field lookup |
|
||||||
|
| `.0` | Array index (positional) |
|
||||||
|
| `.[0]` | Same as `.0`, bracket form |
|
||||||
|
| `.[-1]` | Last element of an array |
|
||||||
|
| `.[-2]` | Second-to-last element |
|
||||||
|
|
||||||
|
Bracket form and dot form are interchangeable: `.data.0.name` and
|
||||||
|
`.data[0].name` mean the same thing.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Given:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{"id": 1, "owner": {"login": "alice"}},
|
||||||
|
{"id": 2, "owner": {"login": "bob"}}
|
||||||
|
],
|
||||||
|
"total": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Query | Output |
|
||||||
|
| ------------------------------ | ------------------ |
|
||||||
|
| `.total` | `2` |
|
||||||
|
| `.data.0.id` | `1` |
|
||||||
|
| `.data[0].owner.login` | `"alice"` |
|
||||||
|
| `.data[-1].owner.login` | `"bob"` |
|
||||||
|
| `.data | .[0]` | first element |
|
||||||
|
| `.data[0] | .owner | .login` | `"alice"` |
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
- A missing field returns a hard error with the field name.
|
||||||
|
- An out-of-range index returns a hard error with the index.
|
||||||
|
- Negative indices that overshoot (e.g. `[-5]` on a 3-element array)
|
||||||
|
also error.
|
||||||
|
|
||||||
|
## Not supported
|
||||||
|
|
||||||
|
The following are intentionally NOT implemented; reach for real jq:
|
||||||
|
|
||||||
|
- Filters like `.[] | select(.state == "open")`
|
||||||
|
- Functions: `map`, `length`, `keys`, etc.
|
||||||
|
- String interpolation
|
||||||
|
- Multiple outputs from a single expression
|
||||||
132
docs/troubleshooting.md
Normal file
132
docs/troubleshooting.md
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
## macOS keeps prompting for keychain access
|
||||||
|
|
||||||
|
After every `cargo build --release` the binary's hash changes, so the
|
||||||
|
macOS keychain treats it as a new application and re-prompts. Click
|
||||||
|
"Always Allow" once and it'll stick until the next rebuild.
|
||||||
|
|
||||||
|
For automated test environments, see
|
||||||
|
`src/client/integration_tests.rs` — those tests use
|
||||||
|
`Client::for_base_url` to bypass the keychain entirely.
|
||||||
|
|
||||||
|
## `fj` hangs on the first command after a rebuild
|
||||||
|
|
||||||
|
Same root cause as above. macOS is prompting for keychain access in a
|
||||||
|
modal dialog you can't see (the binary doesn't have a TTY). Bring the
|
||||||
|
Keychain Access prompt to the foreground and approve it, or run
|
||||||
|
`fj auth status` from a regular terminal first to surface the prompt.
|
||||||
|
|
||||||
|
## `error: HTTP 401 ... authentication failed`
|
||||||
|
|
||||||
|
The stored token is invalid or revoked. Re-authenticate:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
fj auth refresh # re-verify the existing token
|
||||||
|
fj auth refresh --token NEW # replace the stored token
|
||||||
|
fj auth login --host <host> # full re-login (overwrites)
|
||||||
|
```
|
||||||
|
|
||||||
|
## I want to see the raw HTTP requests fj is making
|
||||||
|
|
||||||
|
```sh
|
||||||
|
fj --debug pr list -R foo/bar
|
||||||
|
# or
|
||||||
|
FJ_DEBUG=1 fj pr list -R foo/bar
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll get `→ METHOD URL?query` and `← STATUS url` for every request,
|
||||||
|
plus a 200-character preview of the request body if one was sent.
|
||||||
|
|
||||||
|
## A command is hanging in my CI pipeline
|
||||||
|
|
||||||
|
CI runners often don't have an `$EDITOR`. `fj issue create` and friends
|
||||||
|
will hang waiting for `$EDITOR` to return if you omit `--body`.
|
||||||
|
|
||||||
|
- Pass `--body "text"` explicitly.
|
||||||
|
- Or pass `--body -` to read from stdin.
|
||||||
|
- Set `$VISUAL` or `$EDITOR` to a non-interactive program.
|
||||||
|
|
||||||
|
## The pager doesn't exit / output gets paged when I don't want it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
fj --no-pager repo list # opt out per-command
|
||||||
|
FJ_NO_PAGER=1 fj repo list # opt out in your shell
|
||||||
|
```
|
||||||
|
|
||||||
|
The default pager is `less -FRX`, which exits when output fits one
|
||||||
|
screen. Override with `$FJ_PAGER` or `$PAGER`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
FJ_PAGER='less -R' fj repo list # don't auto-quit
|
||||||
|
FJ_PAGER=cat fj repo list # disable paging
|
||||||
|
```
|
||||||
|
|
||||||
|
## `error: unexpected argument '-R' found`
|
||||||
|
|
||||||
|
Some `fj repo` subcommands take a positional `repo` arg AND accept `-R`
|
||||||
|
(for symmetry with the rest of the CLI). If you see this on an older
|
||||||
|
build, update to a build after commit `eb716ee`. All commands now accept
|
||||||
|
both forms.
|
||||||
|
|
||||||
|
## `-R` works in some shells, not in others (zsh globbing)
|
||||||
|
|
||||||
|
If `fj api /repos/foo` returns a "no matches found" error before fj
|
||||||
|
even runs, you've hit shell globbing on `/`. Quote the path:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
fj api '/repos/foo/bar'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Aliases aren't expanding
|
||||||
|
|
||||||
|
Aliases are looked up before clap parses, but only the FIRST positional
|
||||||
|
is matched. So:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
fj alias set co "pr checkout"
|
||||||
|
fj co 42 # works: co → pr checkout
|
||||||
|
fj --host other.host co 42 # works: --host is parsed by clap after expansion
|
||||||
|
fj help co # does NOT expand co; "help" is a builtin
|
||||||
|
```
|
||||||
|
|
||||||
|
If your alias name collides with a real subcommand, the real one wins.
|
||||||
|
Aliases never shadow built-in commands.
|
||||||
|
|
||||||
|
## Extension `fj-foo` isn't being found
|
||||||
|
|
||||||
|
`fj` looks for `fj-<name>` on PATH only when `<name>` isn't a known
|
||||||
|
subcommand. The list of known subcommands lives in
|
||||||
|
`src/main.rs::KNOWN_SUBCOMMANDS`. If your plugin name collides with a
|
||||||
|
built-in, rename it.
|
||||||
|
|
||||||
|
## `fj pr ready` returns "pull does not allow editing draft state"
|
||||||
|
|
||||||
|
Forgejo only lets you flip `draft: false` via PATCH if the API user is
|
||||||
|
the PR author or has write permissions. If you're not the author and
|
||||||
|
not a maintainer, this fails. There's no workaround on the client side.
|
||||||
|
|
||||||
|
## The hook hangs on `cargo test`
|
||||||
|
|
||||||
|
Likely a keychain re-prompt for the test binary's new hash. Either
|
||||||
|
approve the prompt or rebuild outside the hook context first, then push:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test --all # accept any keychain prompts
|
||||||
|
git push # hook reruns; tests fast-cache hit
|
||||||
|
```
|
||||||
|
|
||||||
|
If genuinely stuck:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
FJ_SKIP_PREPUSH=1 git push # bypass once, only when necessary
|
||||||
|
```
|
||||||
|
|
||||||
|
## My commit was rejected because clippy complains about new code
|
||||||
|
|
||||||
|
The hook runs `cargo clippy --all-targets --all-features -- -D warnings`.
|
||||||
|
Every warning is an error. Either fix it or, very rarely and with a
|
||||||
|
comment explaining why, add a targeted `#[allow(clippy::lint_name)]`.
|
||||||
|
|
||||||
|
Don't add `#[allow(dead_code)]` to silence "never used" warnings on
|
||||||
|
real code — that's a signal you have unused code paths to remove.
|
||||||
Loading…
Reference in a new issue