diff --git a/.forgejo/issue_template/api-gap.md b/.forgejo/issue_template/api-gap.md
new file mode 100644
index 0000000..1456173
--- /dev/null
+++ b/.forgejo/issue_template/api-gap.md
@@ -0,0 +1,24 @@
+---
+name: Forgejo API endpoint not wrapped
+about: An /api/v1 endpoint fj doesn't expose yet.
+labels: [enhancement, api-gap]
+---
+
+## Endpoint
+
+
+
+## What it does
+
+
+
+## Why you need it
+
+
+
+## Suggested CLI surface
+
+```sh
+# Draft the fj command you'd want.
+fj ...
+```
diff --git a/.forgejo/issue_template/bug.md b/.forgejo/issue_template/bug.md
new file mode 100644
index 0000000..b80994d
--- /dev/null
+++ b/.forgejo/issue_template/bug.md
@@ -0,0 +1,43 @@
+---
+name: Bug report
+about: Something that should work doesn't.
+labels: [bug]
+---
+
+## What happened
+
+
+
+## What I expected
+
+
+
+## Reproduction
+
+```sh
+# The exact command(s). If a subcommand has dynamic input, paste it too.
+fj ...
+```
+
+## Output
+
+
+Stderr / error message
+
+```
+
+```
+
+
+
+## Environment
+
+- `fj --version`:
+- `fj api /version` (or your Forgejo version):
+- OS + arch:
+- Shell:
+- Installed via:
+
+## Anything else
+
+
diff --git a/.forgejo/issue_template/feature.md b/.forgejo/issue_template/feature.md
new file mode 100644
index 0000000..d0613b1
--- /dev/null
+++ b/.forgejo/issue_template/feature.md
@@ -0,0 +1,27 @@
+---
+name: Feature request
+about: A capability fj doesn't have today.
+labels: [enhancement]
+---
+
+## What
+
+
+
+## Why
+
+
+
+## How (optional)
+
+
+
+## Forgejo API
+
+
+
+## Alternatives considered
+
+
diff --git a/.forgejo/pull_request_template.md b/.forgejo/pull_request_template.md
new file mode 100644
index 0000000..95941f0
--- /dev/null
+++ b/.forgejo/pull_request_template.md
@@ -0,0 +1,39 @@
+
+
+## What
+
+
+
+## Why
+
+
+
+## How
+
+
+
+## Test plan
+
+- [ ] `cargo fmt --check`
+- [ ] `cargo clippy --all-targets --all-features -- -D warnings`
+- [ ] `cargo test --all`
+- [ ] New code has a test (or here's why not):
+
+## Surface changes
+
+
+
+- [ ] README.md updated (if the command table changed)
+- [ ] docs/gh-to-fj.md updated (if the gh-equivalence shifted)
+- [ ] CHANGELOG.md updated under `[Unreleased]`
diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml
new file mode 100644
index 0000000..fdbb99e
--- /dev/null
+++ b/.forgejo/workflows/release.yml
@@ -0,0 +1,193 @@
+name: release
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+permissions:
+ contents: write
+
+jobs:
+ build:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: darwin-aarch64
+ runs-on: macos
+ target: aarch64-apple-darwin
+ - name: darwin-x86_64
+ runs-on: macos
+ target: x86_64-apple-darwin
+ - name: linux-x86_64
+ runs-on: docker
+ container: rust:1.95-bookworm
+ target: x86_64-unknown-linux-gnu
+ runs-on: ${{ matrix.runs-on }}
+ container: ${{ matrix.container }}
+ steps:
+ - name: checkout
+ uses: actions/checkout@v4
+
+ - name: install rust target
+ run: |
+ rustup target add ${{ matrix.target }} || true
+
+ - name: cache cargo
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: release-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
+
+ - name: build
+ run: |
+ cargo build --release --locked --target ${{ matrix.target }}
+
+ - name: package
+ run: |
+ mkdir -p dist
+ BIN=target/${{ matrix.target }}/release/fj
+ test -x "$BIN" || (echo "binary missing"; exit 1)
+ STAGE=fj-${{ github.ref_name }}-${{ matrix.name }}
+ mkdir -p "$STAGE"
+ cp "$BIN" "$STAGE/"
+ cp README.md LICENSE CHANGELOG.md "$STAGE/"
+ tar czf "dist/$STAGE.tar.gz" "$STAGE"
+ (cd dist && shasum -a 256 "$STAGE.tar.gz" > "$STAGE.tar.gz.sha256")
+
+ - name: upload artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: fj-${{ matrix.name }}
+ path: dist/*
+ retention-days: 7
+
+ publish:
+ needs: build
+ runs-on: docker
+ container: alpine:3.20
+ steps:
+ - name: install tools
+ run: apk add --no-cache curl jq coreutils bash
+
+ - name: fetch artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: dist
+ merge-multiple: true
+
+ - name: combined SHA256SUMS
+ run: |
+ cd dist
+ ls -la
+ # Concatenate per-artifact sha256 files into a single SHA256SUMS.
+ cat *.sha256 | sort > SHA256SUMS
+ cat SHA256SUMS
+
+ - name: create or update release
+ env:
+ FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ REPO: ${{ github.repository }}
+ TAG: ${{ github.ref_name }}
+ API: ${{ github.server_url }}/api/v1
+ run: |
+ set -euo pipefail
+
+ # Look up the release by tag (Forgejo auto-creates one on tag push,
+ # but the API call above may run first); create if missing.
+ RELEASE_JSON=$(curl -sf \
+ -H "Authorization: token $FORGEJO_TOKEN" \
+ "$API/repos/$REPO/releases/tags/$TAG" || echo "")
+
+ if [ -z "$RELEASE_JSON" ]; then
+ RELEASE_JSON=$(curl -sf \
+ -X POST \
+ -H "Authorization: token $FORGEJO_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}" \
+ "$API/repos/$REPO/releases")
+ fi
+
+ RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r .id)
+ echo "Release id: $RELEASE_ID"
+
+ cd dist
+ for f in fj-*.tar.gz SHA256SUMS; do
+ [ -f "$f" ] || continue
+ echo "Uploading $f"
+ curl -sf \
+ -X POST \
+ -H "Authorization: token $FORGEJO_TOKEN" \
+ -F "attachment=@$f" \
+ "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$f"
+ done
+
+ - name: render homebrew formula
+ env:
+ REPO: ${{ github.repository }}
+ TAG: ${{ github.ref_name }}
+ HOST: ${{ github.server_url }}
+ run: |
+ set -euo pipefail
+ VERSION=${TAG#v}
+ ARM_SHA=$(awk '/darwin-aarch64\.tar\.gz$/{print $1}' dist/SHA256SUMS)
+ X64_SHA=$(awk '/darwin-x86_64\.tar\.gz$/{print $1}' dist/SHA256SUMS)
+ LIN_SHA=$(awk '/linux-x86_64\.tar\.gz$/{print $1}' dist/SHA256SUMS)
+ BASE="$HOST/$REPO/releases/download/$TAG"
+ mkdir -p homebrew-out
+ cat > homebrew-out/fj.rb <
+
+
## Install
```sh
+# Homebrew (macOS + Linux):
+brew tap rasterandstate/tap
+brew install fj
+
+# From source:
cargo install --path .
# or
cargo build --release && cp target/release/fj ~/.local/bin/fj
diff --git a/dist/homebrew/fj.rb.tmpl b/dist/homebrew/fj.rb.tmpl
new file mode 100644
index 0000000..f46ec27
--- /dev/null
+++ b/dist/homebrew/fj.rb.tmpl
@@ -0,0 +1,42 @@
+class Fj < Formula
+ desc "Command-line tool for Forgejo, in the spirit of gh"
+ homepage "https://rasterhub.com/rasterstate/fj"
+ version "@@VERSION@@"
+ license "MIT"
+
+ on_macos do
+ on_arm do
+ url "https://rasterhub.com/rasterstate/fj/releases/download/v@@VERSION@@/fj-v@@VERSION@@-darwin-aarch64.tar.gz"
+ sha256 "@@SHA_DARWIN_AARCH64@@"
+ end
+ on_intel do
+ url "https://rasterhub.com/rasterstate/fj/releases/download/v@@VERSION@@/fj-v@@VERSION@@-darwin-x86_64.tar.gz"
+ sha256 "@@SHA_DARWIN_X86_64@@"
+ end
+ end
+
+ on_linux do
+ url "https://rasterhub.com/rasterstate/fj/releases/download/v@@VERSION@@/fj-v@@VERSION@@-linux-x86_64.tar.gz"
+ sha256 "@@SHA_LINUX_X86_64@@"
+ end
+
+ def install
+ target = if OS.mac?
+ Hardware::CPU.arm? ? "darwin-aarch64" : "darwin-x86_64"
+ else
+ "linux-x86_64"
+ end
+ cd "fj-v@@VERSION@@-#{target}"
+ bin.install "fj"
+ # Optional: completions and man pages if present.
+ bash_completion.install "completions/fj.bash" if File.exist?("completions/fj.bash")
+ zsh_completion.install "completions/_fj" if File.exist?("completions/_fj")
+ fish_completion.install "completions/fj.fish" if File.exist?("completions/fj.fish")
+ man1.install Dir["man/*.1"] if Dir.exist?("man")
+ end
+
+ test do
+ assert_match "fj @@VERSION@@", shell_output("#{bin}/fj --version")
+ assert_match "Command-line tool for Forgejo", shell_output("#{bin}/fj --help")
+ end
+end
diff --git a/docs/README.md b/docs/README.md
index b86998a..49f402d 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -5,6 +5,8 @@
- [`jq.md`](jq.md) — the `fj api --jq` syntax. Dot paths, brackets,
negative indices, pipes.
- [`gh-to-fj.md`](gh-to-fj.md) — command-by-command mapping from gh.
+- [`compatibility.md`](compatibility.md) — Forgejo version matrix and
+ known caveats on older Gitea.
- [`faq.md`](faq.md) — common questions about tokens, hosts, debug,
scripting, plugins.
- [`troubleshooting.md`](troubleshooting.md) — keychain prompts, hangs,
diff --git a/docs/compatibility.md b/docs/compatibility.md
new file mode 100644
index 0000000..c028a8b
--- /dev/null
+++ b/docs/compatibility.md
@@ -0,0 +1,67 @@
+# Forgejo compatibility
+
+fj is built against the **Forgejo 7.x API surface**. We test against
+`7.0.16+gitea-1.21.11` on rasterhub.com. Most endpoints are inherited
+unchanged from Gitea, so old Gitea instances will largely work too,
+but there are sharp edges:
+
+## Tested
+
+| Server | Version | Status |
+| -------------------- | -------------------------------- | -------------- |
+| Forgejo | 7.0.x (we test 7.0.16) | full support |
+| Forgejo | 8.x | expected to work |
+| Forgejo | 9.x / 10.x | expected to work |
+| Gitea | 1.21.x (the base of Forgejo 7.0) | mostly works |
+| Gitea | 1.20.x and earlier | YMMV, see below |
+
+If you run fj against an untested version and find something broken,
+please open an [issue](../.forgejo/issue_template/bug.md) with
+`fj api /version` output.
+
+## Known caveats on older Gitea (≤ 1.19)
+
+These features rely on endpoints introduced in or after Gitea 1.20.
+`fj` will return an HTTP 404 with the endpoint path.
+
+- `fj pr ready` (`PATCH /repos/.../pulls/{n}` with `draft: false`).
+- `fj repo mirror-sync` (`POST /repos/.../mirror-sync`).
+- `fj milestone` group on instances that didn't expose
+ `/repos/.../milestones/{id}` for editing.
+- `fj search code` (`/repos/search/code` is Forgejo 7+ only).
+
+For older instances, the typed API still works via `fj api ` if
+you know the right path.
+
+## Forgejo-only endpoints
+
+These are fully implemented and exposed through fj. They are
+Forgejo extensions of the Gitea base.
+
+- Branch protection rules (`fj protect`)
+- Mirrors (`fj repo mirror`)
+- Topics (`fj repo topics`)
+- Webhooks (`fj hook`) — present in Gitea too, identical surface
+- Actions runs / secrets / variables (`fj run`, `fj secret`,
+ `fj variable`)
+
+## How fj detects versions
+
+On `fj auth login`, fj calls `/api/v1/version` once and stores the
+version string. If the version looks pre-7.x, fj prints a warning to
+stderr so you know what to expect. After login, the stored version
+is consulted only by `fj auth status`.
+
+If you want to know the version of a configured host:
+
+```sh
+fj api /version
+# or
+fj auth status | grep Version
+```
+
+## Bumping the supported floor
+
+If we drop support for a Forgejo version, it'll be called out under
+"BREAKING CHANGES" in CHANGELOG.md ahead of the release, and we'll
+emit a stronger error (not just a warning) from the version probe.
diff --git a/scripts/_demo-session.sh b/scripts/_demo-session.sh
new file mode 100755
index 0000000..9815fe0
--- /dev/null
+++ b/scripts/_demo-session.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+# Drives an asciinema recording. Don't invoke directly; run record-demo.sh.
+
+set -u
+PS1='\$ '
+export PS1
+
+PAUSE_BETWEEN=0.6
+PAUSE_AFTER=1.2
+
+say() { printf '\033[1;34m# %s\033[0m\n' "$*"; sleep "$PAUSE_BETWEEN"; }
+do_() { printf '\033[1m$ %s\033[0m\n' "$*"; sleep "$PAUSE_BETWEEN"; eval "$@"; sleep "$PAUSE_AFTER"; }
+
+clear
+say "fj: a CLI for Forgejo. Multi-host, tokens in the keychain."
+do_ "fj --version"
+
+say "Inside a clone, no flags needed: fj infers the repo from your git remote."
+do_ "fj repo view | head -8"
+
+say "Issues, PRs, releases all work the same way."
+do_ "fj issue list --state all -L 5"
+do_ "fj pr list --state all -L 5"
+
+say "The api escape hatch with a jq-ish projector."
+do_ "fj api /version"
+do_ "fj api /user -q .login"
+
+say "Selective JSON for scripting."
+do_ "fj repo list -L 3 --json --json-fields full_name,private"
+
+say "Or just browse on the web."
+say " fj browse src/main.rs"
+sleep 1.5
+
+clear
+say "60+ subcommands. Try fj --help."
+sleep 2
diff --git a/scripts/record-demo.sh b/scripts/record-demo.sh
new file mode 100755
index 0000000..2531703
--- /dev/null
+++ b/scripts/record-demo.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+# Record an asciinema demo of fj. The result is a .cast file you can:
+# - Upload to asciinema.org via `asciinema upload dist/demo.cast`
+# - Embed as a static SVG/GIF via `agg` (https://github.com/asciinema/agg)
+# - Link from README.md
+#
+# Requires: asciinema, fj (installed and authenticated), `jq` for nicer output.
+#
+# Usage:
+# ./scripts/record-demo.sh # records dist/demo.cast
+# ./scripts/record-demo.sh foo.cast # records to foo.cast
+#
+# The session below is deliberately short (~30 seconds) and covers what
+# someone reading the README in their first 10 seconds cares about.
+
+set -euo pipefail
+
+OUT="${1:-dist/demo.cast}"
+mkdir -p "$(dirname "$OUT")"
+
+if ! command -v asciinema >/dev/null 2>&1; then
+ cat >&2 </dev/null 2>&1; then
+ echo "fj not found on PATH; build it first (cargo build --release)" >&2
+ exit 1
+fi
+
+# Drive a representative session through asciinema.
+# Idle pauses keep readability up; trim them in post if you want a snappier
+# embedded GIF.
+asciinema rec \
+ --overwrite \
+ --idle-time-limit 2 \
+ --title "fj: a CLI for Forgejo" \
+ --command "bash $(dirname "$0")/_demo-session.sh" \
+ "$OUT"
+
+echo ""
+echo "✓ Recorded $OUT"
+echo ""
+echo "Next:"
+echo " asciinema play $OUT # preview"
+echo " asciinema upload $OUT # publish (returns a URL)"
+echo " agg $OUT $OUT.gif # render to GIF"
diff --git a/scripts/render-homebrew-formula.sh b/scripts/render-homebrew-formula.sh
new file mode 100755
index 0000000..2528453
--- /dev/null
+++ b/scripts/render-homebrew-formula.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+# Render dist/homebrew/fj.rb from the template + a release's SHA256SUMS.
+#
+# Usage:
+# ./scripts/render-homebrew-formula.sh
+#
+# Reads SHA256SUMS from the Forgejo release for the given version and writes
+# a ready-to-commit fj.rb. Then paste that into rasterandstate/homebrew-tap.
+#
+# Requires: curl, awk, jq.
+
+set -euo pipefail
+
+VERSION="${1:?version required, e.g. 0.1.0}"
+TAG="v${VERSION#v}"
+VERSION="${VERSION#v}"
+REPO="rasterstate/fj"
+HOST="https://rasterhub.com"
+
+TMP=$(mktemp -d)
+trap 'rm -rf "$TMP"' EXIT
+
+SUMS_URL="$HOST/$REPO/releases/download/$TAG/SHA256SUMS"
+echo "fetching $SUMS_URL"
+curl -fsSL "$SUMS_URL" -o "$TMP/SHA256SUMS"
+
+extract() {
+ awk -v pat="$1" '$2 ~ pat {print $1; exit}' "$TMP/SHA256SUMS"
+}
+
+SHA_ARM=$(extract "darwin-aarch64\\.tar\\.gz$")
+SHA_X64=$(extract "darwin-x86_64\\.tar\\.gz$")
+SHA_LIN=$(extract "linux-x86_64\\.tar\\.gz$")
+
+for s in "$SHA_ARM" "$SHA_X64" "$SHA_LIN"; do
+ [ -n "$s" ] || { echo "missing SHA in SHA256SUMS"; cat "$TMP/SHA256SUMS"; exit 1; }
+done
+
+OUT="dist/homebrew/fj.rb"
+sed \
+ -e "s/@@VERSION@@/$VERSION/g" \
+ -e "s/@@SHA_DARWIN_AARCH64@@/$SHA_ARM/g" \
+ -e "s/@@SHA_DARWIN_X86_64@@/$SHA_X64/g" \
+ -e "s/@@SHA_LINUX_X86_64@@/$SHA_LIN/g" \
+ dist/homebrew/fj.rb.tmpl > "$OUT"
+
+echo "wrote $OUT"
+echo ""
+echo "next:"
+echo " cp $OUT ~/path/to/homebrew-tap/Formula/fj.rb"
+echo " cd ~/path/to/homebrew-tap"
+echo " git commit -am 'fj $VERSION'"
+echo " git push"
diff --git a/src/cli/auth.rs b/src/cli/auth.rs
index fcb0563..ffb824b 100644
--- a/src/cli/auth.rs
+++ b/src/cli/auth.rs
@@ -285,6 +285,13 @@ async fn login(args: LoginArgs) -> Result<()> {
.await
.context("verifying token against /api/v1/user")?;
+ // One-shot version probe. Surfaces obvious version mismatches before
+ // they manifest as cryptic 404s on later commands. Failure here is
+ // non-fatal: some Forgejo-compatible servers may not expose /version
+ // identically.
+ let version_string = probe_version(&probe_client).await.unwrap_or_default();
+ warn_on_old_forgejo(&version_string);
+
// Persist host + token.
let mut hosts = Hosts::load()?;
let host = Host {
@@ -297,14 +304,63 @@ async fn login(args: LoginArgs) -> Result<()> {
token_store::store_token(&hostname, &token)?;
println!(
- "{} Logged in to {} as {}",
+ "{} Logged in to {} as {}{}",
output::bold("✓"),
output::bold(&hostname),
- output::bold(&me.login)
+ output::bold(&me.login),
+ if version_string.is_empty() {
+ String::new()
+ } else {
+ format!(" ({})", output::dim(&version_string))
+ }
);
Ok(())
}
+async fn probe_version(client: &Client) -> Result {
+ #[derive(serde::Deserialize)]
+ struct V {
+ version: String,
+ }
+ let v: V = client
+ .json(reqwest::Method::GET, "/api/v1/version", &[], None::<&()>)
+ .await?;
+ Ok(v.version)
+}
+
+/// `version_string` looks like "7.0.16+gitea-1.21.11" or "1.21.11". We warn
+/// when the leading numeric component looks pre-7.x. Anything we can't parse
+/// gets a silent pass since exotic Forgejo-likes still mostly work.
+fn warn_on_old_forgejo(version_string: &str) {
+ if let Some(msg) = version_warning(version_string) {
+ eprintln!("{} {msg}", output::bold("warning:"));
+ }
+}
+
+/// Pure helper. Returns the human-readable warning (without the "warning:"
+/// prefix) when the server's version is older than the fj baseline. Returns
+/// `None` when the version is unparseable or fresh enough.
+fn version_warning(version_string: &str) -> Option {
+ if version_string.is_empty() {
+ return None;
+ }
+ let head = version_string
+ .split(|c: char| !(c.is_ascii_digit() || c == '.'))
+ .next()
+ .unwrap_or("");
+ let major: u32 = head
+ .split('.')
+ .next()
+ .and_then(|s| s.parse().ok())
+ .unwrap_or(0);
+ if major == 0 || major >= 7 {
+ return None;
+ }
+ Some(format!(
+ "server reports version `{version_string}`, which is older than the Forgejo 7.x baseline fj is tested against. Some commands (pr ready, repo mirror-sync, search code, milestone) may 404. See docs/compatibility.md."
+ ))
+}
+
fn build_probe_client(host: &str, cfg: &Host, token: &str) -> Result {
let resolved = crate::client::ResolvedHost {
name: host.to_string(),
@@ -473,4 +529,27 @@ mod setup_git_tests {
assert!(validate_username("alice").is_ok());
assert!(validate_username("alice.bob_123").is_ok());
}
+
+ #[test]
+ fn version_warning_fires_on_old() {
+ assert!(version_warning("1.21.11").is_some());
+ assert!(version_warning("5.0.0").is_some());
+ assert!(version_warning("6.0.0+gitea-1.20.0").is_some());
+ }
+
+ #[test]
+ fn version_warning_silent_on_modern() {
+ assert!(version_warning("7.0.16+gitea-1.21.11").is_none());
+ assert!(version_warning("8.0.0").is_none());
+ assert!(version_warning("10.5.2-beta").is_none());
+ }
+
+ #[test]
+ fn version_warning_silent_on_unparseable() {
+ assert!(version_warning("").is_none());
+ assert!(version_warning("totally-unknown").is_none());
+ // Hardcoded 0.x major never triggers the warning either (likely a
+ // dev / preview build we don't want to be noisy about).
+ assert!(version_warning("0.99.0").is_none());
+ }
}