Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0069198ca6 | ||
|
|
373c769d2c | ||
|
|
155ddb97ba | ||
|
|
133fb2fea4 | ||
|
|
0c181df1d1 | ||
|
|
f75b831a53 | ||
|
|
0fda0b8679 |
17 changed files with 861 additions and 407 deletions
101
CHANGELOG.md
101
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/),
|
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).
|
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 <path>` 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
|
## [0.3.0c] - 2026-03-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
166
CLAUDE.md
Normal file
166
CLAUDE.md
Normal file
|
|
@ -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 <T>" 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" "<prompt>" 2>/dev/null
|
||||||
|
```
|
||||||
|
For follow-up rounds resume the same session: `echo "<prompt>" | 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
|
||||||
|
- <thing that broke>
|
||||||
|
|
||||||
|
### 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: <one-line summary>"
|
||||||
|
```
|
||||||
|
|
||||||
|
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: <summary>" \
|
||||||
|
--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: "<SHA of v0.4.0 tag>" # 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.
|
||||||
|
```
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
### macOS (Homebrew)
|
### macOS (Homebrew)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew tap sid/fj https://forgejo.zerova.net/sid/homebrew-fj.git
|
brew tap public/sid git@forgejo.zerova.net:public/homebrew-sid.git
|
||||||
brew install fj
|
brew install fj
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
161
cmd/actions.go
161
cmd/actions.go
|
|
@ -87,14 +87,30 @@ var actionsCmd = &cobra.Command{
|
||||||
Long: "View and manage workflows, runs, secrets, and variables for Forgejo Actions in your repositories.",
|
Long: "View and manage workflows, runs, secrets, and variables for Forgejo Actions in your repositories.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run commands (compatible with gh run)
|
// Run and Workflow command trees are built via factory functions
|
||||||
var runCmd = &cobra.Command{
|
// (newRunCmd / newWorkflowCmd) so cmd/aliases.go can build an identical
|
||||||
|
// top-level tree under rootCmd without duplicating Use/Short/Long/Example/
|
||||||
|
// flag declarations. Single source of truth — drift impossible.
|
||||||
|
|
||||||
|
// newRunCmd builds the `run` subtree. parentLabel is interpolated into the
|
||||||
|
// parent's Short/Long so the alias-tree variant can advertise itself as
|
||||||
|
// "alias for 'actions run'" without diverging on the children.
|
||||||
|
func newRunCmd(parentLabel string) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
Use: "run",
|
Use: "run",
|
||||||
Short: "View and manage workflow runs",
|
Short: "View and manage workflow runs" + parentLabel,
|
||||||
Long: "List, view, and manage workflow runs.",
|
Long: "List, view, and manage workflow runs." + parentLabel,
|
||||||
|
}
|
||||||
|
cmd.AddCommand(newRunListCmd())
|
||||||
|
cmd.AddCommand(newRunViewCmd())
|
||||||
|
cmd.AddCommand(newRunWatchCmd())
|
||||||
|
cmd.AddCommand(newRunRerunCmd())
|
||||||
|
cmd.AddCommand(newRunCancelCmd())
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
var runListCmd = &cobra.Command{
|
func newRunListCmd() *cobra.Command {
|
||||||
|
c := &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List recent workflow runs",
|
Short: "List recent workflow runs",
|
||||||
Long: "List recent workflow runs for a repository.",
|
Long: "List recent workflow runs for a repository.",
|
||||||
|
|
@ -107,9 +123,15 @@ var runListCmd = &cobra.Command{
|
||||||
# Output as JSON
|
# Output as JSON
|
||||||
fj actions run list --json`,
|
fj actions run list --json`,
|
||||||
RunE: runRunList,
|
RunE: runRunList,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
c.Flags().IntP("limit", "L", 20, "Maximum number of runs to list")
|
||||||
|
addJSONFlags(c, "Output workflow runs as JSON")
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
var runViewCmd = &cobra.Command{
|
func newRunViewCmd() *cobra.Command {
|
||||||
|
c := &cobra.Command{
|
||||||
Use: "view <run-id>",
|
Use: "view <run-id>",
|
||||||
Short: "View a workflow run",
|
Short: "View a workflow run",
|
||||||
Long: "View details about a specific workflow run.",
|
Long: "View details about a specific workflow run.",
|
||||||
|
|
@ -126,9 +148,18 @@ var runViewCmd = &cobra.Command{
|
||||||
fj actions run view 123 --log-failed`,
|
fj actions run view 123 --log-failed`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runRunView,
|
RunE: runRunView,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
c.Flags().BoolP("verbose", "v", false, "Show job steps")
|
||||||
|
c.Flags().BoolP("log", "", false, "View full log for either a run or specific job")
|
||||||
|
c.Flags().StringP("job", "j", "", "View a specific job ID from a run")
|
||||||
|
c.Flags().BoolP("log-failed", "", false, "View the log for any failed steps in a run or specific job")
|
||||||
|
addJSONFlags(c, "Output workflow run as JSON")
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
var runWatchCmd = &cobra.Command{
|
func newRunWatchCmd() *cobra.Command {
|
||||||
|
c := &cobra.Command{
|
||||||
Use: "watch <run-id>",
|
Use: "watch <run-id>",
|
||||||
Short: "Watch a workflow run",
|
Short: "Watch a workflow run",
|
||||||
Long: "Poll a workflow run until it completes.",
|
Long: "Poll a workflow run until it completes.",
|
||||||
|
|
@ -139,9 +170,14 @@ var runWatchCmd = &cobra.Command{
|
||||||
fj actions run watch 123 -i 10s`,
|
fj actions run watch 123 -i 10s`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runRunWatch,
|
RunE: runRunWatch,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
c.Flags().DurationP("interval", "i", 5*time.Second, "Polling interval")
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
var runRerunCmd = &cobra.Command{
|
func newRunRerunCmd() *cobra.Command {
|
||||||
|
c := &cobra.Command{
|
||||||
Use: "rerun <run-id>",
|
Use: "rerun <run-id>",
|
||||||
Short: "Rerun a workflow run",
|
Short: "Rerun a workflow run",
|
||||||
Long: "Trigger a rerun for a specific workflow run.",
|
Long: "Trigger a rerun for a specific workflow run.",
|
||||||
|
|
@ -149,9 +185,13 @@ var runRerunCmd = &cobra.Command{
|
||||||
fj actions run rerun 123`,
|
fj actions run rerun 123`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runRunRerun,
|
RunE: runRunRerun,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
var runCancelCmd = &cobra.Command{
|
func newRunCancelCmd() *cobra.Command {
|
||||||
|
c := &cobra.Command{
|
||||||
Use: "cancel <run-id>",
|
Use: "cancel <run-id>",
|
||||||
Short: "Cancel a workflow run",
|
Short: "Cancel a workflow run",
|
||||||
Long: "Cancel a running workflow run.",
|
Long: "Cancel a running workflow run.",
|
||||||
|
|
@ -159,16 +199,29 @@ var runCancelCmd = &cobra.Command{
|
||||||
fj actions run cancel 123`,
|
fj actions run cancel 123`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runRunCancel,
|
RunE: runRunCancel,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workflow commands
|
// newWorkflowCmd builds the `workflow` subtree. parentLabel is interpolated
|
||||||
var workflowCmd = &cobra.Command{
|
// the same way as newRunCmd's, so the alias variant can self-identify.
|
||||||
|
func newWorkflowCmd(parentLabel string) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
Use: "workflow",
|
Use: "workflow",
|
||||||
Short: "Manage workflows",
|
Short: "Manage workflows" + parentLabel,
|
||||||
Long: "List, view, and run workflows.",
|
Long: "List, view, and run workflows." + parentLabel,
|
||||||
|
}
|
||||||
|
cmd.AddCommand(newWorkflowListCmd())
|
||||||
|
cmd.AddCommand(newWorkflowViewCmd())
|
||||||
|
cmd.AddCommand(newWorkflowRunCmd())
|
||||||
|
cmd.AddCommand(newWorkflowEnableCmd())
|
||||||
|
cmd.AddCommand(newWorkflowDisableCmd())
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
var workflowListCmd = &cobra.Command{
|
func newWorkflowListCmd() *cobra.Command {
|
||||||
|
c := &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List workflows",
|
Short: "List workflows",
|
||||||
Long: "List all workflows in a repository.",
|
Long: "List all workflows in a repository.",
|
||||||
|
|
@ -181,9 +234,15 @@ var workflowListCmd = &cobra.Command{
|
||||||
# List workflows for a specific repo
|
# List workflows for a specific repo
|
||||||
fj actions workflow list -R owner/repo`,
|
fj actions workflow list -R owner/repo`,
|
||||||
RunE: runWorkflowList,
|
RunE: runWorkflowList,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
c.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list")
|
||||||
|
addJSONFlags(c, "Output workflows as JSON")
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
var workflowViewCmd = &cobra.Command{
|
func newWorkflowViewCmd() *cobra.Command {
|
||||||
|
c := &cobra.Command{
|
||||||
Use: "view <workflow>",
|
Use: "view <workflow>",
|
||||||
Short: "View a workflow",
|
Short: "View a workflow",
|
||||||
Long: "View details about a specific workflow. You can specify the workflow by name or filename.",
|
Long: "View details about a specific workflow. You can specify the workflow by name or filename.",
|
||||||
|
|
@ -194,9 +253,14 @@ var workflowViewCmd = &cobra.Command{
|
||||||
fj actions workflow view ci.yml --json`,
|
fj actions workflow view ci.yml --json`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runWorkflowView,
|
RunE: runWorkflowView,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
addJSONFlags(c, "Output workflow as JSON")
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
var workflowRunCmd = &cobra.Command{
|
func newWorkflowRunCmd() *cobra.Command {
|
||||||
|
c := &cobra.Command{
|
||||||
Use: "run <workflow>",
|
Use: "run <workflow>",
|
||||||
Short: "Run a workflow",
|
Short: "Run a workflow",
|
||||||
Long: "Trigger a workflow_dispatch event for a workflow. The workflow must support the workflow_dispatch trigger.",
|
Long: "Trigger a workflow_dispatch event for a workflow. The workflow must support the workflow_dispatch trigger.",
|
||||||
|
|
@ -207,9 +271,16 @@ var workflowRunCmd = &cobra.Command{
|
||||||
fj actions workflow run deploy.yml -r staging -f environment=staging -f version=1.2.3`,
|
fj actions workflow run deploy.yml -r staging -f environment=staging -f version=1.2.3`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runWorkflowRun,
|
RunE: runWorkflowRun,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
c.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)")
|
||||||
|
c.Flags().StringSliceP("field", "f", nil, "Add a string parameter in key=value format (can be used multiple times)")
|
||||||
|
c.Flags().StringSliceP("raw-field", "F", nil, "Add a string parameter in key=value format, reading from file if value starts with @ (can be used multiple times)")
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
var workflowEnableCmd = &cobra.Command{
|
func newWorkflowEnableCmd() *cobra.Command {
|
||||||
|
c := &cobra.Command{
|
||||||
Use: "enable <workflow>",
|
Use: "enable <workflow>",
|
||||||
Short: "Enable a workflow",
|
Short: "Enable a workflow",
|
||||||
Long: "Enable a workflow so it can be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to enable workflows.",
|
Long: "Enable a workflow so it can be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to enable workflows.",
|
||||||
|
|
@ -217,9 +288,13 @@ var workflowEnableCmd = &cobra.Command{
|
||||||
fj actions workflow enable ci.yml`,
|
fj actions workflow enable ci.yml`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runWorkflowEnable,
|
RunE: runWorkflowEnable,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
var workflowDisableCmd = &cobra.Command{
|
func newWorkflowDisableCmd() *cobra.Command {
|
||||||
|
c := &cobra.Command{
|
||||||
Use: "disable <workflow>",
|
Use: "disable <workflow>",
|
||||||
Short: "Disable a workflow",
|
Short: "Disable a workflow",
|
||||||
Long: "Disable a workflow so it cannot be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to disable workflows.",
|
Long: "Disable a workflow so it cannot be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to disable workflows.",
|
||||||
|
|
@ -227,6 +302,9 @@ var workflowDisableCmd = &cobra.Command{
|
||||||
fj actions workflow disable ci.yml`,
|
fj actions workflow disable ci.yml`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runWorkflowDisable,
|
RunE: runWorkflowDisable,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secret commands
|
// Secret commands
|
||||||
|
|
@ -336,21 +414,10 @@ var actionsVariableDeleteCmd = &cobra.Command{
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(actionsCmd)
|
rootCmd.AddCommand(actionsCmd)
|
||||||
|
|
||||||
// Add run commands (gh run compatible)
|
// Run and Workflow trees come from the factory functions defined above
|
||||||
actionsCmd.AddCommand(runCmd)
|
// so cmd/aliases.go can build identical top-level trees under rootCmd.
|
||||||
runCmd.AddCommand(runListCmd)
|
actionsCmd.AddCommand(newRunCmd(""))
|
||||||
runCmd.AddCommand(runViewCmd)
|
actionsCmd.AddCommand(newWorkflowCmd(""))
|
||||||
runCmd.AddCommand(runWatchCmd)
|
|
||||||
runCmd.AddCommand(runRerunCmd)
|
|
||||||
runCmd.AddCommand(runCancelCmd)
|
|
||||||
|
|
||||||
// Add workflow commands (gh workflow compatible)
|
|
||||||
actionsCmd.AddCommand(workflowCmd)
|
|
||||||
workflowCmd.AddCommand(workflowListCmd)
|
|
||||||
workflowCmd.AddCommand(workflowViewCmd)
|
|
||||||
workflowCmd.AddCommand(workflowRunCmd)
|
|
||||||
workflowCmd.AddCommand(workflowEnableCmd)
|
|
||||||
workflowCmd.AddCommand(workflowDisableCmd)
|
|
||||||
|
|
||||||
// Add secret commands
|
// Add secret commands
|
||||||
actionsCmd.AddCommand(actionsSecretCmd)
|
actionsCmd.AddCommand(actionsSecretCmd)
|
||||||
|
|
@ -366,34 +433,6 @@ func init() {
|
||||||
actionsVariableCmd.AddCommand(actionsVariableUpdateCmd)
|
actionsVariableCmd.AddCommand(actionsVariableUpdateCmd)
|
||||||
actionsVariableCmd.AddCommand(actionsVariableDeleteCmd)
|
actionsVariableCmd.AddCommand(actionsVariableDeleteCmd)
|
||||||
|
|
||||||
// Add flags for run commands
|
|
||||||
addRepoFlags(runListCmd)
|
|
||||||
runListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list")
|
|
||||||
addJSONFlags(runListCmd, "Output workflow runs as JSON")
|
|
||||||
addRepoFlags(runViewCmd)
|
|
||||||
runViewCmd.Flags().BoolP("verbose", "v", false, "Show job steps")
|
|
||||||
runViewCmd.Flags().BoolP("log", "", false, "View full log for either a run or specific job")
|
|
||||||
runViewCmd.Flags().StringP("job", "j", "", "View a specific job ID from a run")
|
|
||||||
runViewCmd.Flags().BoolP("log-failed", "", false, "View the log for any failed steps in a run or specific job")
|
|
||||||
addJSONFlags(runViewCmd, "Output workflow run as JSON")
|
|
||||||
addRepoFlags(runWatchCmd)
|
|
||||||
runWatchCmd.Flags().DurationP("interval", "i", 5*time.Second, "Polling interval")
|
|
||||||
addRepoFlags(runRerunCmd)
|
|
||||||
addRepoFlags(runCancelCmd)
|
|
||||||
|
|
||||||
// Add flags for workflow commands
|
|
||||||
addRepoFlags(workflowListCmd)
|
|
||||||
workflowListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list")
|
|
||||||
addJSONFlags(workflowListCmd, "Output workflows as JSON")
|
|
||||||
addRepoFlags(workflowViewCmd)
|
|
||||||
addJSONFlags(workflowViewCmd, "Output workflow as JSON")
|
|
||||||
addRepoFlags(workflowRunCmd)
|
|
||||||
addRepoFlags(workflowEnableCmd)
|
|
||||||
addRepoFlags(workflowDisableCmd)
|
|
||||||
workflowRunCmd.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)")
|
|
||||||
workflowRunCmd.Flags().StringSliceP("field", "f", nil, "Add a string parameter in key=value format (can be used multiple times)")
|
|
||||||
workflowRunCmd.Flags().StringSliceP("raw-field", "F", nil, "Add a string parameter in key=value format, reading from file if value starts with @ (can be used multiple times)")
|
|
||||||
|
|
||||||
// Add flags for secret commands
|
// Add flags for secret commands
|
||||||
addRepoFlags(actionsSecretListCmd)
|
addRepoFlags(actionsSecretListCmd)
|
||||||
addRepoFlags(actionsSecretCreateCmd)
|
addRepoFlags(actionsSecretCreateCmd)
|
||||||
|
|
|
||||||
148
cmd/aliases.go
148
cmd/aliases.go
|
|
@ -1,142 +1,16 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
// Top-level aliases for "actions run" and "actions workflow" — matches gh
|
||||||
"time"
|
// CLI's ergonomics so users can type `fj run list` and `fj workflow list`
|
||||||
|
// instead of `fj actions run list`.
|
||||||
"github.com/spf13/cobra"
|
//
|
||||||
)
|
// Both trees are built from the same factory functions defined in
|
||||||
|
// `cmd/actions.go` (newRunCmd / newWorkflowCmd), which means flags and
|
||||||
// Top-level aliases for "actions run" and "actions workflow" commands,
|
// help text are guaranteed identical between the two paths. Previously
|
||||||
// matching gh CLI's command structure (e.g., "fj run list" instead of "fj actions run list").
|
// this file rebuilt parallel trees by hand and silently drifted (the
|
||||||
|
// `--json` Bool/string mismatch was the symptom that surfaced).
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
// --- run alias ---
|
rootCmd.AddCommand(newRunCmd(" (alias for 'actions run')"))
|
||||||
runAliasCmd := &cobra.Command{
|
rootCmd.AddCommand(newWorkflowCmd(" (alias for 'actions workflow')"))
|
||||||
Use: "run",
|
|
||||||
Short: "View and manage workflow runs (alias for 'actions run')",
|
|
||||||
Long: "List, view, and manage workflow runs.\n\nThis is a top-level alias for 'actions run'.",
|
|
||||||
}
|
|
||||||
|
|
||||||
runAliasListCmd := &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List recent workflow runs",
|
|
||||||
Long: "List recent workflow runs for a repository.",
|
|
||||||
RunE: runRunList,
|
|
||||||
}
|
|
||||||
addRepoFlags(runAliasListCmd)
|
|
||||||
runAliasListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list")
|
|
||||||
runAliasListCmd.Flags().Bool("json", false, "Output workflow runs as JSON")
|
|
||||||
|
|
||||||
runAliasViewCmd := &cobra.Command{
|
|
||||||
Use: "view <run-id>",
|
|
||||||
Short: "View a workflow run",
|
|
||||||
Long: "View details about a specific workflow run.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runRunView,
|
|
||||||
}
|
|
||||||
addRepoFlags(runAliasViewCmd)
|
|
||||||
runAliasViewCmd.Flags().BoolP("verbose", "v", false, "Show job steps")
|
|
||||||
runAliasViewCmd.Flags().BoolP("log", "", false, "View full log for either a run or specific job")
|
|
||||||
runAliasViewCmd.Flags().StringP("job", "j", "", "View a specific job ID from a run")
|
|
||||||
runAliasViewCmd.Flags().BoolP("log-failed", "", false, "View the log for any failed steps in a run or specific job")
|
|
||||||
runAliasViewCmd.Flags().Bool("json", false, "Output workflow run as JSON")
|
|
||||||
|
|
||||||
runAliasWatchCmd := &cobra.Command{
|
|
||||||
Use: "watch <run-id>",
|
|
||||||
Short: "Watch a workflow run",
|
|
||||||
Long: "Poll a workflow run until it completes.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runRunWatch,
|
|
||||||
}
|
|
||||||
addRepoFlags(runAliasWatchCmd)
|
|
||||||
runAliasWatchCmd.Flags().DurationP("interval", "i", 5*time.Second, "Polling interval")
|
|
||||||
|
|
||||||
runAliasRerunCmd := &cobra.Command{
|
|
||||||
Use: "rerun <run-id>",
|
|
||||||
Short: "Rerun a workflow run",
|
|
||||||
Long: "Trigger a rerun for a specific workflow run.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runRunRerun,
|
|
||||||
}
|
|
||||||
addRepoFlags(runAliasRerunCmd)
|
|
||||||
|
|
||||||
runAliasCancelCmd := &cobra.Command{
|
|
||||||
Use: "cancel <run-id>",
|
|
||||||
Short: "Cancel a workflow run",
|
|
||||||
Long: "Cancel a running workflow run.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runRunCancel,
|
|
||||||
}
|
|
||||||
addRepoFlags(runAliasCancelCmd)
|
|
||||||
|
|
||||||
runAliasCmd.AddCommand(runAliasListCmd)
|
|
||||||
runAliasCmd.AddCommand(runAliasViewCmd)
|
|
||||||
runAliasCmd.AddCommand(runAliasWatchCmd)
|
|
||||||
runAliasCmd.AddCommand(runAliasRerunCmd)
|
|
||||||
runAliasCmd.AddCommand(runAliasCancelCmd)
|
|
||||||
rootCmd.AddCommand(runAliasCmd)
|
|
||||||
|
|
||||||
// --- workflow alias ---
|
|
||||||
workflowAliasCmd := &cobra.Command{
|
|
||||||
Use: "workflow",
|
|
||||||
Short: "Manage workflows (alias for 'actions workflow')",
|
|
||||||
Long: "List, view, and run workflows.\n\nThis is a top-level alias for 'actions workflow'.",
|
|
||||||
}
|
|
||||||
|
|
||||||
workflowAliasListCmd := &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List workflows",
|
|
||||||
Long: "List all workflows in a repository.",
|
|
||||||
RunE: runWorkflowList,
|
|
||||||
}
|
|
||||||
addRepoFlags(workflowAliasListCmd)
|
|
||||||
workflowAliasListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list")
|
|
||||||
workflowAliasListCmd.Flags().Bool("json", false, "Output workflows as JSON")
|
|
||||||
|
|
||||||
workflowAliasViewCmd := &cobra.Command{
|
|
||||||
Use: "view <workflow>",
|
|
||||||
Short: "View a workflow",
|
|
||||||
Long: "View details about a specific workflow. You can specify the workflow by name or filename.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runWorkflowView,
|
|
||||||
}
|
|
||||||
addRepoFlags(workflowAliasViewCmd)
|
|
||||||
workflowAliasViewCmd.Flags().Bool("json", false, "Output workflow as JSON")
|
|
||||||
|
|
||||||
workflowAliasRunCmd := &cobra.Command{
|
|
||||||
Use: "run <workflow>",
|
|
||||||
Short: "Run a workflow",
|
|
||||||
Long: "Trigger a workflow_dispatch event for a workflow. The workflow must support the workflow_dispatch trigger.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runWorkflowRun,
|
|
||||||
}
|
|
||||||
addRepoFlags(workflowAliasRunCmd)
|
|
||||||
workflowAliasRunCmd.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)")
|
|
||||||
workflowAliasRunCmd.Flags().StringSliceP("field", "f", nil, "Add a string parameter in key=value format (can be used multiple times)")
|
|
||||||
workflowAliasRunCmd.Flags().StringSliceP("raw-field", "F", nil, "Add a string parameter in key=value format, reading from file if value starts with @ (can be used multiple times)")
|
|
||||||
|
|
||||||
workflowAliasEnableCmd := &cobra.Command{
|
|
||||||
Use: "enable <workflow>",
|
|
||||||
Short: "Enable a workflow",
|
|
||||||
Long: "Enable a workflow so it can be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to enable workflows.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runWorkflowEnable,
|
|
||||||
}
|
|
||||||
addRepoFlags(workflowAliasEnableCmd)
|
|
||||||
|
|
||||||
workflowAliasDisableCmd := &cobra.Command{
|
|
||||||
Use: "disable <workflow>",
|
|
||||||
Short: "Disable a workflow",
|
|
||||||
Long: "Disable a workflow so it cannot be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to disable workflows.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runWorkflowDisable,
|
|
||||||
}
|
|
||||||
addRepoFlags(workflowAliasDisableCmd)
|
|
||||||
|
|
||||||
workflowAliasCmd.AddCommand(workflowAliasListCmd)
|
|
||||||
workflowAliasCmd.AddCommand(workflowAliasViewCmd)
|
|
||||||
workflowAliasCmd.AddCommand(workflowAliasRunCmd)
|
|
||||||
workflowAliasCmd.AddCommand(workflowAliasEnableCmd)
|
|
||||||
workflowAliasCmd.AddCommand(workflowAliasDisableCmd)
|
|
||||||
rootCmd.AddCommand(workflowAliasCmd)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
203
cmd/api.go
203
cmd/api.go
|
|
@ -6,15 +6,23 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/public/fj/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
"forgejo.zerova.net/public/fj/internal/git"
|
"forgejo.zerova.net/public/fj/internal/git"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxAPIResponseBytes caps response bodies for `fj api`. Forgejo responses
|
||||||
|
// are normally <1 MB; 64 MB is enough for any sane payload while preventing
|
||||||
|
// a runaway body from OOMing the CLI when combined with the 30 s client
|
||||||
|
// timeout.
|
||||||
|
const maxAPIResponseBytes = 64 << 20
|
||||||
|
|
||||||
var apiCmd = &cobra.Command{
|
var apiCmd = &cobra.Command{
|
||||||
Use: "api <endpoint> [flags]",
|
Use: "api <endpoint> [flags]",
|
||||||
Short: "Make an authenticated API request",
|
Short: "Make an authenticated API request",
|
||||||
|
|
@ -35,7 +43,13 @@ If --field is used and no --method is specified, the method defaults to POST.`,
|
||||||
fj api /users/johndoe
|
fj api /users/johndoe
|
||||||
|
|
||||||
# Use raw body from stdin
|
# Use raw body from stdin
|
||||||
echo '{"title":"test"}' | fj api /repos/{owner}/{repo}/issues --input -`,
|
echo '{"title":"test"}' | fj api /repos/{owner}/{repo}/issues --input -
|
||||||
|
|
||||||
|
# Filter the response with a jq expression
|
||||||
|
fj api /repos/{owner}/{repo}/issues --jq '.[].title'
|
||||||
|
|
||||||
|
# Project the response down to specific fields
|
||||||
|
fj api /repos/{owner}/{repo} --json-fields full_name,description,private`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runAPI,
|
RunE: runAPI,
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +64,40 @@ func init() {
|
||||||
apiCmd.Flags().StringArrayP("header", "H", nil, "Add an HTTP request header (key:value)")
|
apiCmd.Flags().StringArrayP("header", "H", nil, "Add an HTTP request header (key:value)")
|
||||||
apiCmd.Flags().Bool("silent", false, "Do not print the response body")
|
apiCmd.Flags().Bool("silent", false, "Do not print the response body")
|
||||||
apiCmd.Flags().BoolP("include", "i", false, "Include HTTP response headers in the output")
|
apiCmd.Flags().BoolP("include", "i", false, "Include HTTP response headers in the output")
|
||||||
|
apiCmd.Flags().Bool("paginate", false, "Follow rel=\"next\" Link headers and concatenate JSON array pages (gh-compatible)")
|
||||||
|
addJSONFlags(apiCmd, "Output the response as JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLinkHeaderNext extracts the URL with rel="next" from an RFC 5988
|
||||||
|
// Link header. Returns "" if not present.
|
||||||
|
func parseLinkHeaderNext(link string) string {
|
||||||
|
for _, segment := range strings.Split(link, ",") {
|
||||||
|
segment = strings.TrimSpace(segment)
|
||||||
|
if !strings.Contains(segment, `rel="next"`) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start := strings.Index(segment, "<")
|
||||||
|
end := strings.Index(segment, ">")
|
||||||
|
if start >= 0 && end > start {
|
||||||
|
return segment[start+1 : end]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// concatPaginatedJSON parses each body as a JSON array and merges them.
|
||||||
|
// Errors if any body isn't an array (e.g. an object response means the
|
||||||
|
// endpoint isn't paginated and --paginate doesn't apply).
|
||||||
|
func concatPaginatedJSON(bodies [][]byte) ([]byte, error) {
|
||||||
|
merged := make([]json.RawMessage, 0)
|
||||||
|
for i, b := range bodies {
|
||||||
|
var page []json.RawMessage
|
||||||
|
if err := json.Unmarshal(b, &page); err != nil {
|
||||||
|
return nil, fmt.Errorf("--paginate requires JSON array responses; page %d wasn't an array: %w", i+1, err)
|
||||||
|
}
|
||||||
|
merged = append(merged, page...)
|
||||||
|
}
|
||||||
|
return json.Marshal(merged)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runAPI(cmd *cobra.Command, args []string) error {
|
func runAPI(cmd *cobra.Command, args []string) error {
|
||||||
|
|
@ -139,15 +187,28 @@ func runAPI(cmd *cobra.Command, args []string) error {
|
||||||
body = bytes.NewReader(bodyBytes)
|
body = bytes.NewReader(bodyBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build URL
|
// Build the request URL safely. Naive concatenation lets endpoints like
|
||||||
baseURL := "https://" + host.Hostname + "/api/v1"
|
// "/../admin/users" escape the /api/v1 base via Go's URL normalization
|
||||||
if !strings.HasPrefix(endpoint, "/") {
|
// of `..` segments — silently sending authenticated traffic to non-API
|
||||||
endpoint = "/" + endpoint
|
// paths. Parse the endpoint, reject `..`, then JoinPath onto the base.
|
||||||
|
endpointURL, err := url.Parse(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid endpoint %q: %w", endpoint, err)
|
||||||
}
|
}
|
||||||
url := baseURL + endpoint
|
if endpointURL.Scheme != "" || endpointURL.Host != "" {
|
||||||
|
return fmt.Errorf("endpoint must be a path, not a full URL: %s", endpoint)
|
||||||
|
}
|
||||||
|
for _, seg := range strings.Split(strings.Trim(endpointURL.Path, "/"), "/") {
|
||||||
|
if seg == ".." {
|
||||||
|
return fmt.Errorf("endpoint contains forbidden '..' segment: %s", endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base := &url.URL{Scheme: "https", Host: host.Hostname, Path: "/api/v1"}
|
||||||
|
final := base.JoinPath(endpointURL.Path)
|
||||||
|
final.RawQuery = endpointURL.RawQuery
|
||||||
|
|
||||||
// Create HTTP request
|
// Create HTTP request
|
||||||
req, err := http.NewRequest(method, url, body)
|
req, err := http.NewRequest(method, final.String(), body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -170,20 +231,42 @@ func runAPI(cmd *cobra.Command, args []string) error {
|
||||||
req.Header.Set(strings.TrimSpace(key), strings.TrimSpace(value))
|
req.Header.Set(strings.TrimSpace(key), strings.TrimSpace(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute request
|
paginate, _ := cmd.Flags().GetBool("paginate")
|
||||||
|
if paginate && method != http.MethodGet {
|
||||||
|
return fmt.Errorf("--paginate only supports GET requests")
|
||||||
|
}
|
||||||
|
|
||||||
|
// doOnce executes a single request via the shared client (30 s timeout,
|
||||||
|
// pooled connections), reads the body bounded by maxAPIResponseBytes,
|
||||||
|
// and closes the body before returning. Previous zero-value http.Client{}
|
||||||
|
// had no timeout, pinning the CLI on a hung Forgejo indefinitely.
|
||||||
|
doOnce := func(r *http.Request) (body []byte, header http.Header, status int, proto string, statusText string, retErr error) {
|
||||||
ios.StartSpinner("Requesting...")
|
ios.StartSpinner("Requesting...")
|
||||||
httpClient := &http.Client{}
|
resp, err := api.SharedHTTPClient.Do(r)
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
ios.StopSpinner()
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to perform request: %w", err)
|
return nil, nil, 0, "", "", fmt.Errorf("failed to perform request: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
// Print response headers if requested
|
body, err = io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, 0, "", "", fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
if int64(len(body)) > maxAPIResponseBytes {
|
||||||
|
return nil, nil, 0, "", "", fmt.Errorf("response body exceeded %d bytes (use a different tool for bulk transfers)", maxAPIResponseBytes)
|
||||||
|
}
|
||||||
|
return body, resp.Header, resp.StatusCode, resp.Proto, resp.Status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody, respHeader, statusCode, proto, status, err := doOnce(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if include {
|
if include {
|
||||||
fmt.Fprintf(ios.Out, "%s %s\n", resp.Proto, resp.Status)
|
fmt.Fprintf(ios.Out, "%s %s\n", proto, status)
|
||||||
for key, values := range resp.Header {
|
for key, values := range respHeader {
|
||||||
for _, v := range values {
|
for _, v := range values {
|
||||||
fmt.Fprintf(ios.Out, "%s: %s\n", key, v)
|
fmt.Fprintf(ios.Out, "%s: %s\n", key, v)
|
||||||
}
|
}
|
||||||
|
|
@ -191,39 +274,99 @@ func runAPI(cmd *cobra.Command, args []string) error {
|
||||||
fmt.Fprintln(ios.Out)
|
fmt.Fprintln(ios.Out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read response body
|
if statusCode < 200 || statusCode >= 300 {
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read response body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle non-2xx status codes
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
if !silent {
|
if !silent {
|
||||||
fmt.Fprint(ios.ErrOut, string(respBody))
|
fmt.Fprint(ios.ErrOut, string(respBody))
|
||||||
if len(respBody) > 0 && respBody[len(respBody)-1] != '\n' {
|
if len(respBody) > 0 && respBody[len(respBody)-1] != '\n' {
|
||||||
fmt.Fprintln(ios.ErrOut)
|
fmt.Fprintln(ios.ErrOut)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fmt.Errorf("API request failed with status %d", resp.StatusCode)
|
return fmt.Errorf("API request failed with status %d", statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow `Link: rel="next"` headers when --paginate is set, accumulating
|
||||||
|
// each page's body. After the loop, concatPaginatedJSON merges them into
|
||||||
|
// a single JSON array. Endpoint must be paginatable (returns an array).
|
||||||
|
if paginate {
|
||||||
|
bodies := [][]byte{respBody}
|
||||||
|
nextURL := parseLinkHeaderNext(respHeader.Get("Link"))
|
||||||
|
for nextURL != "" {
|
||||||
|
// Forgejo emits same-origin next-links in practice, but a buggy
|
||||||
|
// or hostile upstream could redirect us to a foreign host — at
|
||||||
|
// which point we'd leak the bearer token. Validate origin (and
|
||||||
|
// resolve relative URLs against `base`) before forwarding auth.
|
||||||
|
parsedNext, err := url.Parse(nextURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid Link rel=\"next\" URL %q: %w", nextURL, err)
|
||||||
|
}
|
||||||
|
if !parsedNext.IsAbs() {
|
||||||
|
parsedNext = base.ResolveReference(parsedNext)
|
||||||
|
}
|
||||||
|
if parsedNext.Scheme != "https" || parsedNext.Host != host.Hostname {
|
||||||
|
return fmt.Errorf("paginated next URL %s is not same-origin as https://%s; refusing to forward credentials", parsedNext.String(), host.Hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextReq, err := http.NewRequest(http.MethodGet, parsedNext.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to build paginated request: %w", err)
|
||||||
|
}
|
||||||
|
if host.Token != "" {
|
||||||
|
nextReq.Header.Set("Authorization", "token "+host.Token)
|
||||||
|
}
|
||||||
|
nextReq.Header.Set("Accept", "application/json")
|
||||||
|
for _, h := range headers {
|
||||||
|
key, value, found := strings.Cut(h, ":")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nextReq.Header.Set(strings.TrimSpace(key), strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
pageBody, pageHeader, pageStatus, _, _, err := doOnce(nextReq)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if pageStatus < 200 || pageStatus >= 300 {
|
||||||
|
return fmt.Errorf("paginated request to %s failed with status %d", parsedNext.String(), pageStatus)
|
||||||
|
}
|
||||||
|
bodies = append(bodies, pageBody)
|
||||||
|
nextURL = parseLinkHeaderNext(pageHeader.Get("Link"))
|
||||||
|
}
|
||||||
|
merged, err := concatPaginatedJSON(bodies)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
respBody = merged
|
||||||
}
|
}
|
||||||
|
|
||||||
if silent || len(respBody) == 0 {
|
if silent || len(respBody) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pretty-print JSON, or output raw if not JSON
|
contentType := respHeader.Get("Content-Type")
|
||||||
contentType := resp.Header.Get("Content-Type")
|
isJSON := strings.Contains(contentType, "json") || json.Valid(respBody)
|
||||||
if strings.Contains(contentType, "json") || json.Valid(respBody) {
|
|
||||||
|
// If the user asked for JSON projection or jq filtering, route through
|
||||||
|
// the shared JSON output helpers so the API command is consistent with
|
||||||
|
// `fj repo list`, `fj pr list`, etc.
|
||||||
|
if wantJSON(cmd) {
|
||||||
|
if !isJSON {
|
||||||
|
return fmt.Errorf("--json/--json-fields/--jq requires a JSON response, but the server returned %s", contentType)
|
||||||
|
}
|
||||||
|
var parsed any
|
||||||
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||||
|
return fmt.Errorf("response is not valid JSON: %w", err)
|
||||||
|
}
|
||||||
|
return outputJSON(cmd, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pretty-print JSON by default, otherwise emit raw bytes.
|
||||||
|
if isJSON {
|
||||||
var parsed any
|
var parsed any
|
||||||
if err := json.Unmarshal(respBody, &parsed); err == nil {
|
if err := json.Unmarshal(respBody, &parsed); err == nil {
|
||||||
enc := json.NewEncoder(ios.Out)
|
return writeJSON(parsed)
|
||||||
enc.SetIndent("", " ")
|
|
||||||
return enc.Encode(parsed)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw output for non-JSON responses
|
|
||||||
_, err = ios.Out.Write(respBody)
|
_, err = ios.Out.Write(respBody)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
cmd/auth.go
17
cmd/auth.go
|
|
@ -55,16 +55,25 @@ func init() {
|
||||||
authCmd.AddCommand(authLogoutCmd)
|
authCmd.AddCommand(authLogoutCmd)
|
||||||
authCmd.AddCommand(authTokenCmd)
|
authCmd.AddCommand(authTokenCmd)
|
||||||
|
|
||||||
authLoginCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
|
// --hostname is a persistent flag on rootCmd (cmd/root.go). Don't
|
||||||
authLoginCmd.Flags().StringP("token", "t", "", "Personal access token")
|
// re-declare it on auth subcommands — local flags shadow the persistent
|
||||||
authLogoutCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
|
// one, so `fj --hostname=X auth login` and `fj auth login --hostname=X`
|
||||||
authTokenCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
|
// went through different code paths (viper vs. local).
|
||||||
|
authLoginCmd.Flags().StringP("token", "t", "", "Personal access token (DEPRECATED: visible in `ps auxe`; pipe via stdin instead)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func runAuthLogin(cmd *cobra.Command, args []string) error {
|
func runAuthLogin(cmd *cobra.Command, args []string) error {
|
||||||
hostname, _ := cmd.Flags().GetString("hostname")
|
hostname, _ := cmd.Flags().GetString("hostname")
|
||||||
token, _ := cmd.Flags().GetString("token")
|
token, _ := cmd.Flags().GetString("token")
|
||||||
|
|
||||||
|
// Tokens passed via --token end up on the process command line and
|
||||||
|
// therefore in `ps auxe` and shell history. Warn loudly so users notice.
|
||||||
|
// (Don't refuse the flag — too disruptive for scripts that already use it.)
|
||||||
|
if cmd.Flags().Changed("token") {
|
||||||
|
fmt.Fprintln(ios.ErrOut, "warning: --token puts the token on the command line (visible in `ps auxe` and shell history)")
|
||||||
|
fmt.Fprintln(ios.ErrOut, " prefer omitting --token and pasting at the prompt, or piping via stdin.")
|
||||||
|
}
|
||||||
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package cmd
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fj/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
|
|
@ -25,9 +24,15 @@ type CLIError struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Detail string `json:"detail,omitempty"`
|
Detail string `json:"detail,omitempty"`
|
||||||
Status int `json:"status,omitempty"`
|
Status int `json:"status,omitempty"`
|
||||||
|
// Hint is a separate field so JSON consumers get clean structure and
|
||||||
|
// the human renderer can append "Hint: ..." without polluting Message.
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *CLIError) Error() string {
|
func (e *CLIError) Error() string {
|
||||||
|
if e.Hint != "" {
|
||||||
|
return e.Message + "\nHint: " + e.Hint
|
||||||
|
}
|
||||||
return e.Message
|
return e.Message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,46 +47,59 @@ func NewAPIError(status int, message string) *CLIError {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContextualError wraps common errors with helpful hints.
|
// ContextualError wraps common errors with helpful hints.
|
||||||
|
//
|
||||||
|
// Auth/404 hints come exclusively from a typed *api.APIError now — we used
|
||||||
|
// to substring-match "401"/"403" against the rendered error string, which
|
||||||
|
// would trigger an "auth login" hint for any error mentioning issue #403.
|
||||||
|
// If the API client doesn't surface APIError, no hint is added; that's a
|
||||||
|
// signal to fix the client wrapper, not to layer regex on top.
|
||||||
func ContextualError(err error) error {
|
func ContextualError(err error) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := err.Error()
|
// If the error chain already holds a CLIError, leave it — it owns its
|
||||||
|
// Code/Hint already.
|
||||||
// Check for API errors with status codes
|
var cErr *CLIError
|
||||||
var apiErr *api.APIError
|
if errors.As(err, &cErr) {
|
||||||
if errors.As(err, &apiErr) {
|
|
||||||
switch {
|
|
||||||
case apiErr.StatusCode == 401 || apiErr.StatusCode == 403:
|
|
||||||
return fmt.Errorf("%w\nHint: Try authenticating with: fj auth login", err)
|
|
||||||
case apiErr.StatusCode == 404:
|
|
||||||
return fmt.Errorf("%w\nHint: Resource not found. Check the repository and number are correct.", err)
|
|
||||||
}
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for network/connection errors
|
var apiErr *api.APIError
|
||||||
switch {
|
if errors.As(err, &apiErr) {
|
||||||
case strings.Contains(msg, "no such host"):
|
c := &CLIError{
|
||||||
return fmt.Errorf("%w\nHint: Check your internet connection and that the host is correct.", err)
|
Code: ErrAPIError,
|
||||||
case strings.Contains(msg, "connection refused"):
|
Message: err.Error(),
|
||||||
return fmt.Errorf("%w\nHint: Check your internet connection and that the host is correct.", err)
|
Status: apiErr.StatusCode,
|
||||||
|
Detail: apiErr.Body,
|
||||||
|
}
|
||||||
|
switch apiErr.StatusCode {
|
||||||
|
case 401, 403:
|
||||||
|
c.Code = ErrAuthRequired
|
||||||
|
c.Hint = "Try authenticating with: fj auth login"
|
||||||
|
case 404:
|
||||||
|
c.Code = ErrNotFound
|
||||||
|
c.Hint = "Resource not found. Check the repository and number are correct."
|
||||||
|
}
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for string-based status code patterns (from wrapped errors)
|
// Plain network errors come back as fmt.Errorf strings from net/http.
|
||||||
|
msg := err.Error()
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(msg, "401") || strings.Contains(msg, "403"):
|
case strings.Contains(msg, "no such host"),
|
||||||
if strings.Contains(msg, "authentication") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "forbidden") {
|
strings.Contains(msg, "connection refused"),
|
||||||
return fmt.Errorf("%w\nHint: Try authenticating with: fj auth login", err)
|
strings.Contains(msg, "i/o timeout"):
|
||||||
|
return &CLIError{
|
||||||
|
Code: ErrNetworkError,
|
||||||
|
Message: msg,
|
||||||
|
Hint: "Check your internet connection and that the host is correct.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeJSONError writes a structured JSON error to stderr.
|
|
||||||
// It attempts to extract structured info from known error types.
|
|
||||||
// WriteJSONError writes a structured JSON error to stderr.
|
// WriteJSONError writes a structured JSON error to stderr.
|
||||||
// It is exported for use from main.go.
|
// It is exported for use from main.go.
|
||||||
func WriteJSONError(err error) {
|
func WriteJSONError(err error) {
|
||||||
|
|
@ -90,7 +108,9 @@ func WriteJSONError(err error) {
|
||||||
Message: err.Error(),
|
Message: err.Error(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract structured info from the error chain.
|
// Try to extract structured info from the error chain. Prefer CLIError
|
||||||
|
// (which carries Hint cleanly) over APIError so a wrapped CLIError
|
||||||
|
// keeps its structured fields.
|
||||||
var apiErr *api.APIError
|
var apiErr *api.APIError
|
||||||
var cErr *CLIError
|
var cErr *CLIError
|
||||||
|
|
||||||
|
|
@ -105,8 +125,6 @@ func WriteJSONError(err error) {
|
||||||
cliErr.Code = ErrAuthRequired
|
cliErr.Code = ErrAuthRequired
|
||||||
case apiErr.StatusCode == 404:
|
case apiErr.StatusCode == 404:
|
||||||
cliErr.Code = ErrNotFound
|
cliErr.Code = ErrNotFound
|
||||||
default:
|
|
||||||
cliErr.Code = ErrAPIError
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,3 +132,6 @@ func WriteJSONError(err error) {
|
||||||
enc.SetIndent("", " ")
|
enc.SetIndent("", " ")
|
||||||
_ = enc.Encode(cliErr)
|
_ = enc.Encode(cliErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compile-time check that CLIError satisfies the standard error interface.
|
||||||
|
var _ error = (*CLIError)(nil)
|
||||||
|
|
|
||||||
18
cmd/issue.go
18
cmd/issue.go
|
|
@ -221,13 +221,24 @@ func runIssueList(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
ios.StartSpinner("Fetching issues...")
|
ios.StartSpinner("Fetching issues...")
|
||||||
issues, _, err := client.ListRepoIssues(owner, name, gitea.ListIssueOption{
|
// ListRepoIssues returns both issues AND PRs (we filter PRs out below).
|
||||||
|
// Pull more than `limit` so post-filter we still have `limit` real issues
|
||||||
|
// — overshoot 2x as a heuristic. paginateGitea(0, ...) would be safer
|
||||||
|
// but spends extra round-trips; keep it bounded.
|
||||||
|
fetchLimit := limit * 2
|
||||||
|
if fetchLimit < 50 {
|
||||||
|
fetchLimit = 50
|
||||||
|
}
|
||||||
|
issues, err := paginateGitea(fetchLimit, func(page, pageSize int) ([]*gitea.Issue, error) {
|
||||||
|
batch, _, err := client.ListRepoIssues(owner, name, gitea.ListIssueOption{
|
||||||
State: stateType,
|
State: stateType,
|
||||||
Labels: labels,
|
Labels: labels,
|
||||||
KeyWord: search,
|
KeyWord: search,
|
||||||
CreatedBy: author,
|
CreatedBy: author,
|
||||||
AssignedBy: assignee,
|
AssignedBy: assignee,
|
||||||
ListOptions: gitea.ListOptions{PageSize: limit},
|
ListOptions: gitea.ListOptions{Page: page, PageSize: pageSize},
|
||||||
|
})
|
||||||
|
return batch, err
|
||||||
})
|
})
|
||||||
ios.StopSpinner()
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -240,6 +251,9 @@ func runIssueList(cmd *cobra.Command, args []string) error {
|
||||||
nonPRIssues = append(nonPRIssues, issue)
|
nonPRIssues = append(nonPRIssues, issue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if limit > 0 && len(nonPRIssues) > limit {
|
||||||
|
nonPRIssues = nonPRIssues[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
if wantJSON(cmd) {
|
||||||
return outputJSON(cmd, nonPRIssues)
|
return outputJSON(cmd, nonPRIssues)
|
||||||
|
|
|
||||||
49
cmd/json.go
49
cmd/json.go
|
|
@ -10,47 +10,48 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// addJSONFlags adds --json, --json-fields, and --jq flags to a command.
|
// addJSONFlags adds --json, --json-fields, and --jq flags to a command.
|
||||||
// --json is an optional-value string flag:
|
|
||||||
// - --json (no value) → output all fields as JSON
|
|
||||||
// - --json title,state → output only those fields (gh-compatible)
|
|
||||||
//
|
//
|
||||||
// --json-fields is kept as a backwards-compatible alias.
|
// Flag design (BREAKING CHANGE — the previous --json was a string with
|
||||||
|
// NoOptDefVal=" " so `--json=fields` projected and `--json` alone meant
|
||||||
|
// "everything". That sentinel produced a `--json string[=" "]` in --help
|
||||||
|
// and left users guessing about the equals sign). Now:
|
||||||
|
//
|
||||||
|
// - --json : Bool. "Output the response as JSON." (all fields)
|
||||||
|
// - --json-fields … : String. Comma-separated projection.
|
||||||
|
// - --jq … : String. jq expression filter.
|
||||||
|
//
|
||||||
|
// --json and --json-fields are mutually exclusive — pick one. --jq composes
|
||||||
|
// with either (or neither, in which case it implies "as JSON").
|
||||||
func addJSONFlags(cmd *cobra.Command, jsonDesc string) {
|
func addJSONFlags(cmd *cobra.Command, jsonDesc string) {
|
||||||
f := cmd.Flags()
|
f := cmd.Flags()
|
||||||
f.String("json", "", jsonDesc)
|
f.Bool("json", false, jsonDesc)
|
||||||
f.Lookup("json").NoOptDefVal = " " // space sentinel: flag present with no value
|
f.String("json-fields", "", "Output as JSON, projecting only these comma-separated fields")
|
||||||
f.String("json-fields", "", "Comma-separated list of JSON fields to include")
|
|
||||||
f.String("jq", "", "Filter JSON output using a jq expression")
|
f.String("jq", "", "Filter JSON output using a jq expression")
|
||||||
|
cmd.MarkFlagsMutuallyExclusive("json", "json-fields")
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantJSON returns true if the user requested JSON output via --json, --json-fields, or --jq.
|
// wantJSON returns true if the user requested JSON output via --json,
|
||||||
|
// --json-fields, or --jq.
|
||||||
func wantJSON(cmd *cobra.Command) bool {
|
func wantJSON(cmd *cobra.Command) bool {
|
||||||
if j, _ := cmd.Flags().GetString("json"); j != "" {
|
if b, _ := cmd.Flags().GetBool("json"); b {
|
||||||
return true
|
|
||||||
}
|
|
||||||
if jq, _ := cmd.Flags().GetString("jq"); jq != "" {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if f, _ := cmd.Flags().GetString("json-fields"); f != "" {
|
if f, _ := cmd.Flags().GetString("json-fields"); f != "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if jq, _ := cmd.Flags().GetString("jq"); jq != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// outputJSON writes a value as JSON, respecting --json, --json-fields, and --jq flags.
|
// outputJSON writes a value as JSON, respecting --json-fields and --jq.
|
||||||
|
// --json (the bool) is the "no projection, no filter" signal handled
|
||||||
|
// implicitly: when neither --json-fields nor --jq is set, the whole value
|
||||||
|
// is emitted.
|
||||||
func outputJSON(cmd *cobra.Command, value any) error {
|
func outputJSON(cmd *cobra.Command, value any) error {
|
||||||
jsonVal, _ := cmd.Flags().GetString("json")
|
fields, _ := cmd.Flags().GetString("json-fields")
|
||||||
jsonFields, _ := cmd.Flags().GetString("json-fields")
|
|
||||||
jqExpr, _ := cmd.Flags().GetString("jq")
|
jqExpr, _ := cmd.Flags().GetString("jq")
|
||||||
|
|
||||||
fields := ""
|
|
||||||
jsonVal = strings.TrimSpace(jsonVal)
|
|
||||||
if jsonVal != "" {
|
|
||||||
fields = jsonVal
|
|
||||||
} else if jsonFields != "" {
|
|
||||||
fields = jsonFields
|
|
||||||
}
|
|
||||||
|
|
||||||
return writeJSONFiltered(value, fields, jqExpr)
|
return writeJSONFiltered(value, fields, jqExpr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
43
cmd/paginate.go
Normal file
43
cmd/paginate.go
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
// paginateGitea walks pages of a gitea SDK list method until the response
|
||||||
|
// is short (last page) or we hit limit. limit=0 means unlimited.
|
||||||
|
//
|
||||||
|
// Forgejo/Gitea caps PageSize at 50, so naive `PageSize: limit` for limit > 50
|
||||||
|
// silently truncated results across most `fj * list` commands. This helper
|
||||||
|
// centralizes the loop so every list command paginates consistently.
|
||||||
|
//
|
||||||
|
// fetch is called with (page, pageSize) and returns the items for that page.
|
||||||
|
// The 1-based `page` matches the gitea SDK convention.
|
||||||
|
func paginateGitea[T any](limit int, fetch func(page, pageSize int) ([]T, error)) ([]T, error) {
|
||||||
|
const maxPageSize = 50
|
||||||
|
pageSize := maxPageSize
|
||||||
|
if limit > 0 && limit < pageSize {
|
||||||
|
pageSize = limit
|
||||||
|
}
|
||||||
|
|
||||||
|
var all []T
|
||||||
|
for page := 1; ; page++ {
|
||||||
|
if limit > 0 && len(all) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
batch, err := fetch(page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return all, err
|
||||||
|
}
|
||||||
|
if len(batch) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
all = append(all, batch...)
|
||||||
|
// A short page (less than the requested size) is the conventional
|
||||||
|
// "you've reached the end" signal — saves one extra round-trip.
|
||||||
|
if len(batch) < pageSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit > 0 && len(all) > limit {
|
||||||
|
all = all[:limit]
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
37
cmd/pr.go
37
cmd/pr.go
|
|
@ -252,39 +252,32 @@ func runPRList(cmd *cobra.Command, args []string) error {
|
||||||
needsClientFilter := assignee != "" || author != "" || len(labels) > 0 || search != "" || draft || head != "" || base != ""
|
needsClientFilter := assignee != "" || author != "" || len(labels) > 0 || search != "" || draft || head != "" || base != ""
|
||||||
|
|
||||||
ios.StartSpinner("Fetching pull requests...")
|
ios.StartSpinner("Fetching pull requests...")
|
||||||
var prs []*gitea.PullRequest
|
// When client-side filtering is needed, pull pages until exhausted (no
|
||||||
if needsClientFilter {
|
// limit) so we can apply filters; otherwise paginate up to the user's
|
||||||
page := 1
|
// limit. Either way, paginate — `PageSize: limit` capped at 50 silently.
|
||||||
for {
|
fetchPage := func(page, pageSize int) ([]*gitea.PullRequest, error) {
|
||||||
batch, _, err := client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{
|
batch, _, err := client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{
|
||||||
State: stateType,
|
State: stateType,
|
||||||
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
ListOptions: gitea.ListOptions{Page: page, PageSize: pageSize},
|
||||||
})
|
})
|
||||||
if err != nil {
|
return batch, err
|
||||||
ios.StopSpinner()
|
|
||||||
return fmt.Errorf("failed to list pull requests: %w", err)
|
|
||||||
}
|
|
||||||
prs = append(prs, batch...)
|
|
||||||
if len(batch) < 50 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
page++
|
|
||||||
}
|
}
|
||||||
|
var prs []*gitea.PullRequest
|
||||||
|
if needsClientFilter {
|
||||||
|
prs, err = paginateGitea(0, fetchPage) // pull all, then filter + limit
|
||||||
|
if err == nil {
|
||||||
prs = filterPRs(prs, author, assignee, labels, search, draft, head, base)
|
prs = filterPRs(prs, author, assignee, labels, search, draft, head, base)
|
||||||
if len(prs) > limit {
|
if limit > 0 && len(prs) > limit {
|
||||||
prs = prs[:limit]
|
prs = prs[:limit]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
prs, _, err = client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{
|
prs, err = paginateGitea(limit, fetchPage)
|
||||||
State: stateType,
|
}
|
||||||
ListOptions: gitea.ListOptions{PageSize: limit},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
ios.StopSpinner()
|
ios.StopSpinner()
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to list pull requests: %w", err)
|
return fmt.Errorf("failed to list pull requests: %w", err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
ios.StopSpinner()
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
if wantJSON(cmd) {
|
||||||
return outputJSON(cmd, prs)
|
return outputJSON(cmd, prs)
|
||||||
|
|
|
||||||
13
cmd/repo.go
13
cmd/repo.go
|
|
@ -216,17 +216,18 @@ func runRepoList(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("failed to get user info: %w", err)
|
return fmt.Errorf("failed to get user info: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
repos, _, err := client.ListUserRepos(user.UserName, gitea.ListReposOptions{})
|
limit, _ := cmd.Flags().GetInt("limit")
|
||||||
|
repos, err := paginateGitea(limit, func(page, pageSize int) ([]*gitea.Repository, error) {
|
||||||
|
batch, _, err := client.ListUserRepos(user.UserName, gitea.ListReposOptions{
|
||||||
|
ListOptions: gitea.ListOptions{Page: page, PageSize: pageSize},
|
||||||
|
})
|
||||||
|
return batch, err
|
||||||
|
})
|
||||||
ios.StopSpinner()
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to list repositories: %w", err)
|
return fmt.Errorf("failed to list repositories: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
limit, _ := cmd.Flags().GetInt("limit")
|
|
||||||
if limit > 0 && len(repos) > limit {
|
|
||||||
repos = repos[:limit]
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
if wantJSON(cmd) {
|
||||||
return outputJSON(cmd, repos)
|
return outputJSON(cmd, repos)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
62
cmd/root.go
62
cmd/root.go
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
"forgejo.zerova.net/public/fj/internal/git"
|
"forgejo.zerova.net/public/fj/internal/git"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
|
@ -21,7 +22,7 @@ var rootCmd = &cobra.Command{
|
||||||
Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line",
|
Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line",
|
||||||
Long: `fj is a command line tool for Forgejo instances (including Codeberg).
|
Long: `fj is a command line tool for Forgejo instances (including Codeberg).
|
||||||
It brings pull requests, issues, and other Forgejo concepts to the terminal.`,
|
It brings pull requests, issues, and other Forgejo concepts to the terminal.`,
|
||||||
Version: "0.3.2",
|
Version: "0.4.0",
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,7 +46,12 @@ func init() {
|
||||||
|
|
||||||
func initConfig() {
|
func initConfig() {
|
||||||
if cfgFile != "" {
|
if cfgFile != "" {
|
||||||
|
// Tell viper to load this file for env-style overrides AND make
|
||||||
|
// internal/config.Load()/.Save() use it (this is the load-bearing
|
||||||
|
// half — without SetExplicitConfigPath, --config was silently
|
||||||
|
// ignored by every auth-touching command).
|
||||||
viper.SetConfigFile(cfgFile)
|
viper.SetConfigFile(cfgFile)
|
||||||
|
config.SetExplicitConfigPath(cfgFile)
|
||||||
} else {
|
} else {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -66,7 +72,7 @@ func initConfig() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = os.MkdirAll(configDir, 0755)
|
_ = os.MkdirAll(configDir, 0700)
|
||||||
|
|
||||||
viper.AddConfigPath(configDir)
|
viper.AddConfigPath(configDir)
|
||||||
viper.SetConfigType("yaml")
|
viper.SetConfigType("yaml")
|
||||||
|
|
@ -77,6 +83,14 @@ func initConfig() {
|
||||||
viper.SetEnvPrefix("FJ")
|
viper.SetEnvPrefix("FJ")
|
||||||
|
|
||||||
_ = viper.ReadInConfig()
|
_ = viper.ReadInConfig()
|
||||||
|
|
||||||
|
// If the resolved config exists with overly permissive mode, warn — the
|
||||||
|
// file holds API tokens. Don't fail-close; just nudge the user.
|
||||||
|
if path, err := config.GetConfigPath(); err == nil {
|
||||||
|
if info, statErr := os.Stat(path); statErr == nil && info.Mode()&0o077 != 0 {
|
||||||
|
fmt.Fprintf(ios.ErrOut, "warning: %s mode %o is world/group readable; tokens may leak. chmod 600 it.\n", path, info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseRepo parses the repository string in the format "owner/name".
|
// parseRepo parses the repository string in the format "owner/name".
|
||||||
|
|
@ -143,8 +157,11 @@ func parseIssueArg(arg string) (int64, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrateConfigDir copies all files from src to dst (one level, no subdirs).
|
// migrateConfigDir copies all files from src to dst (one level, no subdirs).
|
||||||
|
// Uses O_TRUNC so a partially-pre-existing dst file is fully replaced rather
|
||||||
|
// than having the legacy contents overwrite a prefix and leaving stale tail
|
||||||
|
// bytes — which for a YAML token store would silently corrupt config.
|
||||||
func migrateConfigDir(src, dst string) error {
|
func migrateConfigDir(src, dst string) error {
|
||||||
if err := os.MkdirAll(dst, 0755); err != nil {
|
if err := os.MkdirAll(dst, 0700); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
entries, err := os.ReadDir(src)
|
entries, err := os.ReadDir(src)
|
||||||
|
|
@ -155,21 +172,34 @@ func migrateConfigDir(src, dst string) error {
|
||||||
if e.IsDir() {
|
if e.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
in, err := os.Open(filepath.Join(src, e.Name()))
|
if err := copyOneConfigFile(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
out, err := os.OpenFile(filepath.Join(dst, e.Name()), os.O_CREATE|os.O_WRONLY, 0600)
|
|
||||||
if err != nil {
|
|
||||||
in.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = io.Copy(out, in)
|
|
||||||
in.Close()
|
|
||||||
out.Close()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copyOneConfigFile(srcPath, dstPath string) (retErr error) {
|
||||||
|
in, err := os.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if cerr := in.Close(); retErr == nil {
|
||||||
|
retErr = cerr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
out, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if cerr := out.Close(); retErr == nil {
|
||||||
|
retErr = cerr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, err = io.Copy(out, in)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -266,10 +266,9 @@ func runWikiView(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("wiki page has no HTML URL")
|
return fmt.Errorf("wiki page has no HTML URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonFlag, _ := cmd.Flags().GetBool("json")
|
if wantJSON(cmd) {
|
||||||
if jsonFlag {
|
|
||||||
page.Content = string(content)
|
page.Content = string(content)
|
||||||
return writeJSON(page)
|
return outputJSON(cmd, page)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ios.StartPager(); err != nil {
|
if err := ios.StartPager(); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,16 @@ import (
|
||||||
"forgejo.zerova.net/public/fj/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
var sharedHTTPClient = &http.Client{
|
// SharedHTTPClient is the package-wide HTTP client. Exported so other
|
||||||
|
// packages (notably cmd/api.go) can reuse the same timeout and connection
|
||||||
|
// pooling instead of constructing zero-value clients with no timeout.
|
||||||
|
var SharedHTTPClient = &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Internal alias kept so existing call sites compile unchanged.
|
||||||
|
var sharedHTTPClient = SharedHTTPClient
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
*gitea.Client
|
*gitea.Client
|
||||||
hostname string
|
hostname string
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,17 @@ type Config struct {
|
||||||
Hosts map[string]HostConfig `yaml:"hosts"`
|
Hosts map[string]HostConfig `yaml:"hosts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// explicitConfigPath, when non-empty, overrides the default config file
|
||||||
|
// location for both Load() and Save(). It's set by cmd/root.initConfig when
|
||||||
|
// the user passes --config <path>. Stored at package scope so existing
|
||||||
|
// call sites of config.Load()/c.Save() continue to work without each one
|
||||||
|
// having to know about the flag.
|
||||||
|
var explicitConfigPath string
|
||||||
|
|
||||||
|
// SetExplicitConfigPath wires a user-supplied --config path through to
|
||||||
|
// Load/Save. Pass "" to clear.
|
||||||
|
func SetExplicitConfigPath(p string) { explicitConfigPath = p }
|
||||||
|
|
||||||
type HostConfig struct {
|
type HostConfig struct {
|
||||||
Hostname string `yaml:"hostname"`
|
Hostname string `yaml:"hostname"`
|
||||||
Token string `yaml:"token"`
|
Token string `yaml:"token"`
|
||||||
|
|
@ -35,6 +46,9 @@ func GetConfigDir() (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetConfigPath() (string, error) {
|
func GetConfigPath() (string, error) {
|
||||||
|
if explicitConfigPath != "" {
|
||||||
|
return explicitConfigPath, nil
|
||||||
|
}
|
||||||
dir, err := GetConfigDir()
|
dir, err := GetConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue