diff --git a/CHANGELOG.md b/CHANGELOG.md index 0565379..170c5ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,107 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0] - 2026-05-02 + +Audit-driven hardening pass. Three reviewers (Codex + two Claude agents +with non-overlapping focuses) found 13 issues across cmd/ and internal/; +this release ships fixes for all 13. + +### BREAKING + +- `--json=fields` syntax removed. The flag was a string with + `NoOptDefVal=" "` sentinel — `--json` alone meant "everything", + `--json=fields` projected. That produced `--json string[=" "]` in + `--help` and required a literal `=` because `--json fields` was parsed + as the bare flag plus a positional. **Migration**: `--json=fields` → + `--json-fields fields`. Bare `--json` still means "all fields as JSON". + `--json` and `--json-fields` are mutually exclusive; `--jq` composes + with either. + +### Added + +- `fj api --json` / `--json-fields` / `--jq` — projection and jq filtering + for raw API responses. Routes through the same `addJSONFlags` helpers + as the other list commands. Closes the inconsistency where `fj api` + was the only command returning raw JSON without these knobs. +- `fj api --paginate` — follows RFC 5988 `Link: rel="next"` headers and + concatenates JSON array pages, gh-compatible. Validates same-origin + before forwarding the bearer token to the next URL. +- `cmd/paginate.go` — generic `paginateGitea[T any]` helper. Applied to + `repo list`, `pr list`, `issue list`. Previously only `release list` + walked pages; the others passed `PageSize: limit` directly to the + gitea SDK, which silently caps PageSize at 50, so `--limit > 50` was + truncated without warning. +- `CLAUDE.md` — guide for Claude Code sessions: layout, codex review + pattern, release process, homebrew tap update steps. + +### Changed + +- `--json` flag rebuilt as a plain `Bool`. `--json-fields` keeps + comma-separated projection. Both registered via `addJSONFlags` and + marked `MutuallyExclusive`. +- `cmd/actions.go` — `run` and `workflow` subtrees converted from + package-level `var`s to factory functions (`newRunCmd`, + `newWorkflowCmd`, ...). `cmd/aliases.go` shrank from 142 → 17 lines + and now calls those same factories with a `parentLabel` parameter that + disambiguates the alias variant. Result: `diff` of `fj run list + --help` flags vs `fj actions run list --help` flags is now empty. + Drift between the two paths is structurally impossible. +- `fj api` now uses `internal/api.SharedHTTPClient` (30s timeout, pooled + connections) instead of a zero-value `&http.Client{}` with no timeout. + A hung Forgejo no longer pins the CLI indefinitely. +- `fj api` response body bounded by `io.LimitReader` at 64 MB to prevent + OOM-on-self. +- `cmd/auth.go` removed redundant local `--hostname` declarations on + three subcommands. The persistent flag on rootCmd is now the only + declaration; previously local declarations shadowed it, so + `fj --hostname=X auth login` and `fj auth login --hostname=X` went + through different code paths. +- `--token` on `auth login` emits a stderr warning when used (visible + in `ps auxe` and shell history). Flag not removed; just discoverable. +- Error handling: `Hint` is now a structured field on `CLIError`. + JSON-error consumers get clean structure; the human renderer still + appends `\nHint: ...`. Dropped substring matching of `"401"`/`"403"` + against rendered error strings (would match issue #403); now relies + exclusively on typed `*api.APIError`. +- Network errors (`no such host`, `connection refused`, `i/o timeout`) + return a structured `CLIError` with code `ErrNetworkError` and a hint. +- Config dir created with mode 0700 instead of 0755. + +### Fixed + +- `--config ` now actually honored. Previously fed only into + Viper; every command that touched config went through + `internal/config.Load()` / `Save()` which always read the default + path. So `fj --config other.yaml auth login` writes to other.yaml now. +- `fj run list --json`, `fj workflow list --json`, `fj wiki view --json` + now produce JSON. `cmd/aliases.go` registered `--json` as `Bool` but + handlers called `wantJSON()` which does `GetString("json")` — pflag + returned a type-error that `wantJSON` silently swallowed. + `cmd/wiki.go` had the inverse bug (`GetBool` against an + `addJSONFlags`-registered string flag). Both routed through + `addJSONFlags`/`wantJSON`/`outputJSON` consistently now. +- `migrateConfigDir` opens dst with `O_TRUNC`. Previously a partially- + pre-existing dst file would have legacy contents overwrite a prefix + and leave stale tail bytes — silent YAML/token corruption. Refactored + close handling into `copyOneConfigFile`. + +### Security + +- `fj api` endpoint path traversal closed. `fj api '/../admin/users'` + previously normalized through `http.NewRequest` to + `https://host/admin/users` — silently sending authenticated traffic + to non-API paths. Endpoint is now parsed via `url.Parse`, `..` + segments rejected, then `JoinPath` onto the `/api/v1` base. + URL-encoded `%2E%2E` is also caught because Go decodes before our + split. +- `fj api --paginate` validates same-origin before forwarding the + bearer token to a `Link: rel="next"` URL. Refuses to reattach + `Authorization` if the next URL's scheme isn't `https` or its host + doesn't match the configured one. +- `initConfig` warns on stderr if the resolved config file is world or + group readable (`mode & 0o077 != 0`). + ## [0.3.0c] - 2026-03-21 ### Added diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dfece55 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,166 @@ +# fj — guide for Claude Code sessions + +`fj` is a personal Forgejo/Gitea CLI tool, modeled on GitHub's `gh`. It targets `forgejo.zerova.net` (and Codeberg). The user (sid) owns it; the canonical repo is `public/fj` on forgejo.zerova.net (mirrored from there to nowhere else). + +This file is read first by Claude Code when working in `~/repos/fj`. Goal: get a session productive quickly without re-deriving the dev workflow each time. + +## Layout + +``` +~/repos/fj/ +├── cmd/ cobra command definitions, one file per subject area +│ ├── root.go rootCmd, --config plumbing, OnInitialize +│ ├── auth.go login/status/logout/token (uses persistent --hostname) +│ ├── api.go raw API access; --json/--json-fields/--jq/--paginate +│ ├── json.go shared JSON output helpers (addJSONFlags/wantJSON/outputJSON) +│ ├── paginate.go generic paginateGitea[T] helper for list commands +│ ├── errors.go CLIError with structured Hint field +│ ├── actions.go Forgejo Actions; runs/workflows via factory functions +│ ├── aliases.go top-level `fj run` / `fj workflow` aliases — calls actions.go factories +│ ├── repo.go pr.go issue.go release.go wiki.go label.go milestone.go +│ └── ... +├── internal/ +│ ├── api/client.go SharedHTTPClient (30s timeout); GetJSON/DoJSON/DownloadFile +│ ├── config/config.go YAML config; honors --config via SetExplicitConfigPath +│ ├── git/ repo + host detection from `git remote` +│ ├── iostreams/ wrapped stdin/stdout/stderr + spinner + pager + colors +│ └── text/ formatting helpers +├── main.go thin entrypoint; ContextualError + JSON-error rendering +├── Makefile build / lint / test (no release automation) +├── CHANGELOG.md Keep-a-Changelog format +└── README.md +``` + +## Build, install, test + +```bash +go build ./... # quick build check +go test ./... # unit tests +go install . # build + install to ~/go/bin/fj (the binary that's on PATH) +make lint # golangci-lint, if you have it +``` + +After any change in cmd/ or internal/, run `go install .` and the global `fj` reflects it immediately. There's no daemon/restart. + +## Auth + +The user is authenticated as `sid` on `forgejo.zerova.net`. Token lives in `~/.config/fj/config.yaml` (mode 0600). For HTTPS git pushes from this host, the token can be injected via `git -c "http.extraHeader=Authorization: token " push` — the local SSH key (`sid@debian` on forgejo) is also registered, so `git@forgejo.zerova.net:public/fj.git` works directly. + +## Code review pattern (use this for non-trivial changes) + +For audits or significant refactors, run **three reviewers in parallel** with non-overlapping focuses (we did this in the v0.4.0 cycle and it found bugs none would have caught alone): + +- **Codex** — read-only sandbox, peer-AI cross-check + ```bash + codex exec --skip-git-repo-check --sandbox read-only \ + -m gpt-5.4-mini --config model_reasoning_effort="medium" "" 2>/dev/null + ``` + For follow-up rounds resume the same session: `echo "" | codex exec --skip-git-repo-check resume --last 2>/dev/null`. Codex remembers prior critique. + +- **Claude general-purpose agent A** — architecture / UX / code-quality +- **Claude general-purpose agent B** — security / correctness / error handling + +Tell each reviewer what the **siblings** are covering so they don't duplicate. Cap reports at ~600 words. Consolidate findings by severity (HIGH / MEDIUM / LOW) before presenting to the user. + +## Release process + +We use semver. **Pre-1.0**: breaking change → minor bump (e.g. v0.3.x → v0.4.0). + +1. **Bump version** + ```go + // cmd/root.go + Version: "0.4.0", + ``` + +2. **Update CHANGELOG.md** — prepend a new section. Format: + ```markdown + ## [0.4.0] - YYYY-MM-DD + + ### BREAKING + - + + ### Added + - ... + ### Changed + - ... + ### Fixed + - ... + ### Security + - ... + ``` + +3. **Commit** the version+changelog bump as a single commit: + ```bash + git commit -m "chore: bump version to 0.4.0" + ``` + +4. **Tag** the commit: + ```bash + git tag -a v0.4.0 -m "Release v0.4.0: " + ``` + +5. **Push** commits and tag: + ```bash + git push origin main + git push origin v0.4.0 + ``` + +6. **Create the Forgejo release page** via fj itself: + ```bash + fj release create v0.4.0 \ + --title "v0.4.0: " \ + --notes "$(awk '/^## \[0.4.0\]/{flag=1;next} /^## /{flag=0} flag' CHANGELOG.md)" + ``` + (The awk one-liner extracts the just-added CHANGELOG section as release notes.) + +7. **Update the homebrew tap** — see the next section. + +## Updating the homebrew tap (`public/homebrew-sid`) + +The tap lives at `~/repos/homebrew-sid` (or `git@forgejo.zerova.net:public/homebrew-sid.git`). The `Formula/fj.rb` formula references the source by `tag:` + `revision:` (SHA), so a release bump touches three lines: + +```ruby +url "ssh://git@forgejo.zerova.net/public/fj.git", + tag: "v0.4.0", # was v0.3.2 + revision: "" # update + +test do + assert_match "0.4.0", shell_output("#{bin}/fj --version") # update +end +``` + +To get the SHA: +```bash +git -C ~/repos/fj rev-parse v0.4.0 +``` + +Then in `~/repos/homebrew-sid`: +```bash +# edit Formula/fj.rb (the three lines above) +git commit -am "fj: bump to v0.4.0" +git push origin main +``` + +After push, users can `brew update && brew upgrade fj` to pick up the new version. + +## Common footguns + +- **`fj` reads the current dir's `git origin`** to detect the host. In a directory whose origin points at github.com (e.g. /opt/stacks/claude-code-proxy/build), bare `fj api ...` errors with "no configuration found for host github.com". Pass `--hostname forgejo.zerova.net` explicitly, or `cd` somewhere else. +- **`--json=fields` was removed in v0.4.0** in favor of `--json-fields fields` (or `--json-fields=fields`). The old `=fields` form was a `NoOptDefVal=" "` sentinel hack. `--json` is now a plain Bool meaning "as JSON". +- **`--config` was silently ignored before v0.4.0.** Old fj versions read --config into Viper but `internal/config.Load()` always read the default path. Fixed; `fj --config other.yaml auth login` now writes to other.yaml. +- **The `actions` and `run`/`workflow` command trees share factory functions** in `cmd/actions.go` (`newRunCmd`, `newWorkflowCmd`). Don't add flags directly to `runListCmd` style globals — they don't exist anymore. Edit the factory and both `fj actions run list` and `fj run list` get the change. + +## Useful commands + +```bash +# Live test against forgejo (using the new flags) +fj --hostname forgejo.zerova.net api repos/public/fj --json-fields full_name,description + +# Walk paginated endpoints +fj --hostname forgejo.zerova.net api 'repos/public/fj/commits?limit=10' --paginate --jq '.[].sha[0:8]' + +# Confirm both command trees stay in sync after edits +diff <(fj run list --help | grep -E "^ -|^ --" | sort) \ + <(fj actions run list --help | grep -E "^ -|^ --" | sort) +# Empty diff = trees agree. Any output = factory drift. +``` diff --git a/cmd/root.go b/cmd/root.go index d0b06bb..8b4dcef 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,7 +22,7 @@ var rootCmd = &cobra.Command{ Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line", Long: `fj is a command line tool for Forgejo instances (including Codeberg). It brings pull requests, issues, and other Forgejo concepts to the terminal.`, - Version: "0.3.2", + Version: "0.4.0", SilenceErrors: true, }