fix(cmd): correctness + audit hardening across cmd/ + internal/
Addresses audit findings from a tri-partite review (codex + 2 Claude agents).
Multiple distinct fixes here because they touched overlapping files; happy
to split via interactive rebase if a reviewer prefers.
## Correctness bugs (HIGH)
* `--config` is now actually honored. cmd/root.initConfig fed Viper but
every command that mattered loaded config via `internal/config.Load()`
which always read the default path. Added `config.SetExplicitConfigPath`
consulted by `GetConfigPath`; `--config other.yaml auth login` now writes
to other.yaml.
- internal/config/config.go, cmd/root.go
* `--json` now works on `fj run …`, `fj workflow …`, and `fj wiki view`.
cmd/aliases.go registered `--json` as a Bool but the handlers call
`wantJSON()` which does `GetString("json")` and silently ignores the
type-error return. cmd/wiki.go did the inverse (`GetBool("json")` against
a string-registered flag). Both now use `addJSONFlags`/`wantJSON`/
`outputJSON` consistently.
- cmd/aliases.go, cmd/wiki.go
* `fj api` no longer lets endpoints escape the /api/v1 base via
path-traversal. `fj api '/../admin/users'` previously normalized to
`/admin/users` because `http.NewRequest` resolves `..` segments —
silently sending authenticated traffic to non-API routes. Endpoint is
now parsed, `..` segments are rejected, and JoinPath is used.
- cmd/api.go
## Design rework (BREAKING — gets rid of the `--json=fields` quirk)
* `--json` flag rebuilt from a string-with-NoOptDefVal=" " sentinel into a
plain Bool. `--json-fields` keeps comma-separated projection. The two
are mutually exclusive (`MarkFlagsMutuallyExclusive`). `--jq` composes
with either or neither. The previous design produced a `--json string[=" "]`
in --help and required `--json=fields` (with literal "=") because
`--json fields` was parsed as the bare flag plus a positional. Gone.
- cmd/json.go: addJSONFlags / wantJSON / outputJSON
- cmd/api.go: example block reflects the new shape
Migration: `--json=fields` → `--json-fields fields`. Bare `--json` still
means "everything as JSON".
* `fj api` now uses `internal/api.SharedHTTPClient` (30 s timeout, pooled)
instead of constructing a zero-value `&http.Client{}` with no timeout.
A hung Forgejo no longer pins the CLI indefinitely. Response body is
also bounded by `io.LimitReader` at 64 MB to prevent OOM-on-self.
- internal/api/client.go (export SharedHTTPClient), cmd/api.go
* `--hostname` declared as a persistent flag on rootCmd is now the only
declaration. cmd/auth.go re-declared `--hostname` on three subcommands,
shadowing the persistent flag — meaning `fj --hostname=X auth login`
and `fj auth login --hostname=X` went through different code paths
(viper read vs. local flag read). Local declarations removed.
- cmd/auth.go
## Hardening (MEDIUM/LOW)
* `--token` on `auth login` now emits a stderr warning when used, since
it puts the PAT on argv (visible in `ps auxe`/shell history). Flag not
removed — too disruptive — but discoverable now.
- cmd/auth.go
* Error handling no longer regex-matches "401"/"403" against rendered
error strings (would have triggered "auth login" hint for an error
that just mentioned issue #403). Now relies on typed `*api.APIError`.
Hints moved to a separate `Hint` field on `CLIError`, so JSON-error
consumers get clean structure and the human renderer still appends
"\nHint: …".
- cmd/errors.go
* `migrateConfigDir` now opens dst with `O_TRUNC` instead of just
`O_CREATE|O_WRONLY`. Previously a partially-pre-existing dst file
would have legacy contents overwrite a prefix and leave stale tail
bytes — silent YAML/token corruption.
- cmd/root.go (extracted into copyOneConfigFile with proper close handling)
* Config dir created with mode 0700 instead of 0755. `initConfig` warns
on stderr if the resolved config file is world/group readable
(`mode & 0o077 != 0`); doesn't fail-close.
- cmd/root.go
* Network errors (`no such host`, `connection refused`, `i/o timeout`)
now return a structured `CLIError` with code `ErrNetworkError` and a
hint, instead of a fmt.Errorf chain.
- cmd/errors.go
Verified: `go build ./...` and `go test ./...` clean. Live integration
tested against forgejo.zerova.net.
Out of scope, deferred to follow-up commits:
- Pagination unification across `repo list`/`pr list`/`issue list` (only
`release list` walks pages today; others silently truncate).
- `fj api --paginate` to follow pages like `gh api --paginate`.
- De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees.
This commit is contained in:
parent
f75b831a53
commit
0c181df1d1
9 changed files with 196 additions and 94 deletions
|
|
@ -25,7 +25,7 @@ func init() {
|
||||||
}
|
}
|
||||||
addRepoFlags(runAliasListCmd)
|
addRepoFlags(runAliasListCmd)
|
||||||
runAliasListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list")
|
runAliasListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list")
|
||||||
runAliasListCmd.Flags().Bool("json", false, "Output workflow runs as JSON")
|
addJSONFlags(runAliasListCmd, "Output workflow runs as JSON")
|
||||||
|
|
||||||
runAliasViewCmd := &cobra.Command{
|
runAliasViewCmd := &cobra.Command{
|
||||||
Use: "view <run-id>",
|
Use: "view <run-id>",
|
||||||
|
|
@ -39,7 +39,7 @@ func init() {
|
||||||
runAliasViewCmd.Flags().BoolP("log", "", false, "View full log for either a run or specific job")
|
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().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().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")
|
addJSONFlags(runAliasViewCmd, "Output workflow run as JSON")
|
||||||
|
|
||||||
runAliasWatchCmd := &cobra.Command{
|
runAliasWatchCmd := &cobra.Command{
|
||||||
Use: "watch <run-id>",
|
Use: "watch <run-id>",
|
||||||
|
|
@ -91,7 +91,7 @@ func init() {
|
||||||
}
|
}
|
||||||
addRepoFlags(workflowAliasListCmd)
|
addRepoFlags(workflowAliasListCmd)
|
||||||
workflowAliasListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list")
|
workflowAliasListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list")
|
||||||
workflowAliasListCmd.Flags().Bool("json", false, "Output workflows as JSON")
|
addJSONFlags(workflowAliasListCmd, "Output workflows as JSON")
|
||||||
|
|
||||||
workflowAliasViewCmd := &cobra.Command{
|
workflowAliasViewCmd := &cobra.Command{
|
||||||
Use: "view <workflow>",
|
Use: "view <workflow>",
|
||||||
|
|
@ -101,7 +101,7 @@ func init() {
|
||||||
RunE: runWorkflowView,
|
RunE: runWorkflowView,
|
||||||
}
|
}
|
||||||
addRepoFlags(workflowAliasViewCmd)
|
addRepoFlags(workflowAliasViewCmd)
|
||||||
workflowAliasViewCmd.Flags().Bool("json", false, "Output workflow as JSON")
|
addJSONFlags(workflowAliasViewCmd, "Output workflow as JSON")
|
||||||
|
|
||||||
workflowAliasRunCmd := &cobra.Command{
|
workflowAliasRunCmd := &cobra.Command{
|
||||||
Use: "run <workflow>",
|
Use: "run <workflow>",
|
||||||
|
|
|
||||||
54
cmd/api.go
54
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",
|
||||||
|
|
@ -40,11 +48,7 @@ If --field is used and no --method is specified, the method defaults to POST.`,
|
||||||
# Filter the response with a jq expression
|
# Filter the response with a jq expression
|
||||||
fj api /repos/{owner}/{repo}/issues --jq '.[].title'
|
fj api /repos/{owner}/{repo}/issues --jq '.[].title'
|
||||||
|
|
||||||
# Project the response down to specific fields (requires "=" because --json
|
# Project the response down to specific fields
|
||||||
# also accepts an empty value to mean "all fields as JSON")
|
|
||||||
fj api /repos/{owner}/{repo} --json=full_name,description,private
|
|
||||||
|
|
||||||
# Same projection without the "=" quirk
|
|
||||||
fj api /repos/{owner}/{repo} --json-fields full_name,description,private`,
|
fj api /repos/{owner}/{repo} --json-fields full_name,description,private`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runAPI,
|
RunE: runAPI,
|
||||||
|
|
@ -150,15 +154,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)
|
||||||
}
|
}
|
||||||
|
|
@ -181,10 +198,11 @@ 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
|
// Execute request via the shared client (30 s timeout, pooled
|
||||||
|
// connections). Previous zero-value http.Client{} had no timeout, which
|
||||||
|
// pinned the CLI on a hung Forgejo indefinitely.
|
||||||
ios.StartSpinner("Requesting...")
|
ios.StartSpinner("Requesting...")
|
||||||
httpClient := &http.Client{}
|
resp, err := api.SharedHTTPClient.Do(req)
|
||||||
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 fmt.Errorf("failed to perform request: %w", err)
|
||||||
|
|
@ -202,11 +220,15 @@ func runAPI(cmd *cobra.Command, args []string) error {
|
||||||
fmt.Fprintln(ios.Out)
|
fmt.Fprintln(ios.Out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read response body
|
// Read response body with a hard ceiling so a runaway upstream can't OOM
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
// the CLI. Read maxAPIResponseBytes+1 to detect overflow.
|
||||||
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read response body: %w", err)
|
return fmt.Errorf("failed to read response body: %w", err)
|
||||||
}
|
}
|
||||||
|
if int64(len(respBody)) > maxAPIResponseBytes {
|
||||||
|
return fmt.Errorf("response body exceeded %d bytes (use a different tool for bulk transfers)", maxAPIResponseBytes)
|
||||||
|
}
|
||||||
|
|
||||||
// Handle non-2xx status codes
|
// Handle non-2xx status codes
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
|
|
||||||
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)
|
||||||
|
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
60
cmd/root.go
60
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"
|
||||||
|
|
@ -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