From 0c181df1d106826422b449e6c0b3a8f1372e798f Mon Sep 17 00:00:00 2001 From: sid Date: Sat, 2 May 2026 15:41:48 -0600 Subject: [PATCH] fix(cmd): correctness + audit hardening across cmd/ + internal/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/aliases.go | 8 ++--- cmd/api.go | 54 +++++++++++++++++++--------- cmd/auth.go | 17 ++++++--- cmd/errors.go | 75 +++++++++++++++++++++++++-------------- cmd/json.go | 49 ++++++++++++------------- cmd/root.go | 60 +++++++++++++++++++++++-------- cmd/wiki.go | 5 ++- internal/api/client.go | 8 ++++- internal/config/config.go | 14 ++++++++ 9 files changed, 196 insertions(+), 94 deletions(-) diff --git a/cmd/aliases.go b/cmd/aliases.go index 522c540..401c040 100644 --- a/cmd/aliases.go +++ b/cmd/aliases.go @@ -25,7 +25,7 @@ func init() { } addRepoFlags(runAliasListCmd) 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{ Use: "view ", @@ -39,7 +39,7 @@ func init() { 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") + addJSONFlags(runAliasViewCmd, "Output workflow run as JSON") runAliasWatchCmd := &cobra.Command{ Use: "watch ", @@ -91,7 +91,7 @@ func init() { } addRepoFlags(workflowAliasListCmd) 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{ Use: "view ", @@ -101,7 +101,7 @@ func init() { RunE: runWorkflowView, } addRepoFlags(workflowAliasViewCmd) - workflowAliasViewCmd.Flags().Bool("json", false, "Output workflow as JSON") + addJSONFlags(workflowAliasViewCmd, "Output workflow as JSON") workflowAliasRunCmd := &cobra.Command{ Use: "run ", diff --git a/cmd/api.go b/cmd/api.go index e336c8f..ad0d136 100644 --- a/cmd/api.go +++ b/cmd/api.go @@ -6,15 +6,23 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "strconv" "strings" + "forgejo.zerova.net/public/fj/internal/api" "forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fj/internal/git" "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{ Use: "api [flags]", 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 fj api /repos/{owner}/{repo}/issues --jq '.[].title' - # Project the response down to specific fields (requires "=" because --json - # 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 + # Project the response down to specific fields fj api /repos/{owner}/{repo} --json-fields full_name,description,private`, Args: cobra.ExactArgs(1), RunE: runAPI, @@ -150,15 +154,28 @@ func runAPI(cmd *cobra.Command, args []string) error { body = bytes.NewReader(bodyBytes) } - // Build URL - baseURL := "https://" + host.Hostname + "/api/v1" - if !strings.HasPrefix(endpoint, "/") { - endpoint = "/" + endpoint + // Build the request URL safely. Naive concatenation lets endpoints like + // "/../admin/users" escape the /api/v1 base via Go's URL normalization + // of `..` segments — silently sending authenticated traffic to non-API + // 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 - req, err := http.NewRequest(method, url, body) + req, err := http.NewRequest(method, final.String(), body) if err != nil { 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)) } - // 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...") - httpClient := &http.Client{} - resp, err := httpClient.Do(req) + resp, err := api.SharedHTTPClient.Do(req) ios.StopSpinner() if err != nil { 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) } - // Read response body - respBody, err := io.ReadAll(resp.Body) + // Read response body with a hard ceiling so a runaway upstream can't OOM + // the CLI. Read maxAPIResponseBytes+1 to detect overflow. + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes+1)) if err != nil { 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 if resp.StatusCode < 200 || resp.StatusCode >= 300 { diff --git a/cmd/auth.go b/cmd/auth.go index 16034f0..53ce982 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -55,16 +55,25 @@ func init() { authCmd.AddCommand(authLogoutCmd) authCmd.AddCommand(authTokenCmd) - authLoginCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)") - authLoginCmd.Flags().StringP("token", "t", "", "Personal access token") - authLogoutCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)") - authTokenCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)") + // --hostname is a persistent flag on rootCmd (cmd/root.go). Don't + // re-declare it on auth subcommands — local flags shadow the persistent + // one, so `fj --hostname=X auth login` and `fj auth login --hostname=X` + // 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 { hostname, _ := cmd.Flags().GetString("hostname") 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) if hostname == "" { diff --git a/cmd/errors.go b/cmd/errors.go index bec3dd9..7617430 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -3,7 +3,6 @@ package cmd import ( "encoding/json" "errors" - "fmt" "strings" "forgejo.zerova.net/public/fj/internal/api" @@ -25,9 +24,15 @@ type CLIError struct { Message string `json:"message"` Detail string `json:"detail,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 { + if e.Hint != "" { + return e.Message + "\nHint: " + e.Hint + } return e.Message } @@ -42,46 +47,59 @@ func NewAPIError(status int, message string) *CLIError { } // 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 { if err == nil { return nil } - msg := err.Error() - - // Check for API errors with status codes - var apiErr *api.APIError - 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) - } + // If the error chain already holds a CLIError, leave it — it owns its + // Code/Hint already. + var cErr *CLIError + if errors.As(err, &cErr) { return err } - // Check for network/connection errors - switch { - case strings.Contains(msg, "no such host"): - return fmt.Errorf("%w\nHint: Check your internet connection and that the host is correct.", err) - case strings.Contains(msg, "connection refused"): - return fmt.Errorf("%w\nHint: Check your internet connection and that the host is correct.", err) + var apiErr *api.APIError + if errors.As(err, &apiErr) { + c := &CLIError{ + Code: ErrAPIError, + Message: err.Error(), + 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 { - case strings.Contains(msg, "401") || strings.Contains(msg, "403"): - if strings.Contains(msg, "authentication") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "forbidden") { - return fmt.Errorf("%w\nHint: Try authenticating with: fj auth login", err) + case strings.Contains(msg, "no such host"), + strings.Contains(msg, "connection refused"), + 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 } -// 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. // It is exported for use from main.go. func WriteJSONError(err error) { @@ -90,7 +108,9 @@ func WriteJSONError(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 cErr *CLIError @@ -105,8 +125,6 @@ func WriteJSONError(err error) { cliErr.Code = ErrAuthRequired case apiErr.StatusCode == 404: cliErr.Code = ErrNotFound - default: - cliErr.Code = ErrAPIError } } @@ -114,3 +132,6 @@ func WriteJSONError(err error) { enc.SetIndent("", " ") _ = enc.Encode(cliErr) } + +// Compile-time check that CLIError satisfies the standard error interface. +var _ error = (*CLIError)(nil) diff --git a/cmd/json.go b/cmd/json.go index 2472449..70ac0be 100644 --- a/cmd/json.go +++ b/cmd/json.go @@ -10,47 +10,48 @@ import ( ) // 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) { f := cmd.Flags() - f.String("json", "", jsonDesc) - f.Lookup("json").NoOptDefVal = " " // space sentinel: flag present with no value - f.String("json-fields", "", "Comma-separated list of JSON fields to include") + f.Bool("json", false, jsonDesc) + f.String("json-fields", "", "Output as JSON, projecting only these comma-separated fields") 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 { - if j, _ := cmd.Flags().GetString("json"); j != "" { - return true - } - if jq, _ := cmd.Flags().GetString("jq"); jq != "" { + if b, _ := cmd.Flags().GetBool("json"); b { return true } if f, _ := cmd.Flags().GetString("json-fields"); f != "" { return true } + if jq, _ := cmd.Flags().GetString("jq"); jq != "" { + return true + } 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 { - jsonVal, _ := cmd.Flags().GetString("json") - jsonFields, _ := cmd.Flags().GetString("json-fields") + fields, _ := cmd.Flags().GetString("json-fields") jqExpr, _ := cmd.Flags().GetString("jq") - - fields := "" - jsonVal = strings.TrimSpace(jsonVal) - if jsonVal != "" { - fields = jsonVal - } else if jsonFields != "" { - fields = jsonFields - } - return writeJSONFiltered(value, fields, jqExpr) } diff --git a/cmd/root.go b/cmd/root.go index 0ee2cf0..d0b06bb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fj/internal/git" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -45,7 +46,12 @@ func init() { func initConfig() { 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) + config.SetExplicitConfigPath(cfgFile) } else { home, err := os.UserHomeDir() if err != nil { @@ -66,7 +72,7 @@ func initConfig() { } } - _ = os.MkdirAll(configDir, 0755) + _ = os.MkdirAll(configDir, 0700) viper.AddConfigPath(configDir) viper.SetConfigType("yaml") @@ -77,6 +83,14 @@ func initConfig() { viper.SetEnvPrefix("FJ") _ = 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". @@ -143,8 +157,11 @@ func parseIssueArg(arg string) (int64, error) { } // 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 { - if err := os.MkdirAll(dst, 0755); err != nil { + if err := os.MkdirAll(dst, 0700); err != nil { return err } entries, err := os.ReadDir(src) @@ -155,21 +172,34 @@ func migrateConfigDir(src, dst string) error { if e.IsDir() { continue } - in, err := os.Open(filepath.Join(src, e.Name())) - 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 { + if err := copyOneConfigFile(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil { return err } } 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 +} diff --git a/cmd/wiki.go b/cmd/wiki.go index 97d2b13..63cb504 100644 --- a/cmd/wiki.go +++ b/cmd/wiki.go @@ -266,10 +266,9 @@ func runWikiView(cmd *cobra.Command, args []string) error { return fmt.Errorf("wiki page has no HTML URL") } - jsonFlag, _ := cmd.Flags().GetBool("json") - if jsonFlag { + if wantJSON(cmd) { page.Content = string(content) - return writeJSON(page) + return outputJSON(cmd, page) } if err := ios.StartPager(); err != nil { diff --git a/internal/api/client.go b/internal/api/client.go index 112d0c3..0666357 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -12,10 +12,16 @@ import ( "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, } +// Internal alias kept so existing call sites compile unchanged. +var sharedHTTPClient = SharedHTTPClient + type Client struct { *gitea.Client hostname string diff --git a/internal/config/config.go b/internal/config/config.go index e0abfca..79c5c0b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,17 @@ type Config struct { 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 . 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 { Hostname string `yaml:"hostname"` Token string `yaml:"token"` @@ -35,6 +46,9 @@ func GetConfigDir() (string, error) { } func GetConfigPath() (string, error) { + if explicitConfigPath != "" { + return explicitConfigPath, nil + } dir, err := GetConfigDir() if err != nil { return "", err