From 4eeef2ceca7a5f20f0840b8cbcef608abf103b58 Mon Sep 17 00:00:00 2001 From: sid Date: Sun, 19 Apr 2026 22:14:43 -0600 Subject: [PATCH] feat: pr approve/reject, repo migrate/template, secret stdin fix, docs - fgj pr approve / pr reject: thin shortcuts over 'pr review --approve' and '--request-changes'. Reject requires a body. - fgj repo migrate: wrap SDK MigrateRepo. Supports git, github, gitlab, gitea, gogs services; mirror mode with --mirror-interval; selective import (wiki/labels/milestones/issues/PRs/releases/LFS); auth via --auth-token or --auth-username/--auth-password. Defaults owner to the authenticated user. - fgj repo create-from-template: wrap SDK CreateRepoFromTemplate with fine-grained --with-{content,topics,labels,webhooks,git-hooks,avatar} flags. Template is owner/name; new repo defaults to the current user. - Rework 'fgj actions secret create' input. New cmd/secret_input.go resolves values from --body, --body-file (path or '-'), hidden TTY prompt via term.ReadPassword, or piped stdin. Trims trailing whitespace, rejects empty values. Replaces fmt.Scanln which broke on spaces/newlines and echoed input. - CHANGELOG: v0.4.0 Unreleased section documenting all additions, changes, and development items. - README: updated feature list with new commands. --- CHANGELOG.md | 59 +++++++++++++- README.md | 25 +++--- cmd/actions.go | 37 ++++++--- cmd/pr_approve_reject.go | 102 +++++++++++++++++++++++ cmd/repo_migrate.go | 170 +++++++++++++++++++++++++++++++++++++++ cmd/repo_template.go | 113 ++++++++++++++++++++++++++ cmd/secret_input.go | 68 ++++++++++++++++ 7 files changed, 551 insertions(+), 23 deletions(-) create mode 100644 cmd/pr_approve_reject.go create mode 100644 cmd/repo_migrate.go create mode 100644 cmd/repo_template.go create mode 100644 cmd/secret_input.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 39e583a..19cc43a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,64 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased] — 0.4.0 + +### Added — Repository Management + +- `fgj branch {list,rename,delete}` — list branches with protection + status, rename branches, delete (protected branches are refused). +- `fgj repo delete` — type-to-confirm deletion; `--yes` for scripts. +- `fgj repo search` — search repositories on the current host by + query, topic, or description; filter by `--type`, `--owner`, + `--private`, `--archived`. +- `fgj repo migrate` — import from GitHub, GitLab, Gitea, Gogs, or + a plain Git remote; supports mirror mode and selective import of + wiki/labels/milestones/issues/PRs/releases/LFS. +- `fgj repo create-from-template` — scaffold a repo from a template, + with fine-grained control over what to copy (content, topics, + labels, webhooks, git hooks, avatar). + +### Added — Pull Requests + +- `fgj pr clean ` — delete the local branch created by + `fgj pr checkout`. Refuses if the PR is open (use `--force`) or + if the branch is currently checked out. +- `fgj pr approve ` / `fgj pr reject ` — shortcuts over + `pr review`; `reject` requires a body. +- `fgj pr review-comments ` — list inline review comments across + every review on a PR. +- `fgj pr resolve ` / `fgj pr unresolve ` — + mark review threads (un)resolved. Requires Forgejo 8.x+ / + Gitea 1.22+ server-side. + +### Added — Notifications & Organizations + +- `fgj notification list [--all]` / `fgj notification read ` — + list unread (default) or all notifications; mark individual + threads read. +- `fgj org {list,create,delete}` — list your orgs, create with + visibility/description, delete with confirmation. +- `fgj webhook {list,create,update,delete}` — full CRUD on repo + webhooks: gitea/slack/discord/etc. hook types, event selection, + content type, secret, branch filter, auth header. + +### Added — Misc + +- `fgj open [number] [--url]` — launch the repo / issue / PR page in + the default browser; auto-detects issue-vs-PR; prints URL on + non-TTY stdout or with `--url`. +- `fgj whoami` — show the authenticated user and host. +- `fgj admin user list` — admin-gated user enumeration. + +### Changed + +- `fgj actions secret create` stdin handling reworked. Adds `--body` + (inline) and `--body-file` (path or `-` for stdin) flags; interactive + prompts now use hidden input via `term.ReadPassword`; piped stdin is + read whole (prior `fmt.Scanln` broke on spaces/newlines and echoed + the typed value). Empty values are rejected. +- Gitea SDK bumped `v0.22.1` → `v0.23.2` (last release compatible with + Go 1.24; `v0.24+` requires Go 1.26). ### Development diff --git a/README.md b/README.md index 770b787..7391168 100644 --- a/README.md +++ b/README.md @@ -10,20 +10,25 @@ ## Features - Multi-instance support (works with any Forgejo or Gitea instance) -- Pull request management (create, list, view, merge, diff, comment, review) -- Issue tracking (create, list, view, comment, close, labels) -- Repository operations (view, list, create, edit, clone, fork) -- Label management (list, create, edit, delete) -- Milestone management (list, view, create, edit, delete) -- Wiki page management (list, view, create, edit, delete) -- Issue dependencies (`--add-dependency`, `--remove-dependency`) -- Forgejo Actions (workflow runs, watch/rerun/cancel, enable/disable, secrets, variables) -- Releases (create, upload, delete) +- Pull requests — list, view, create, merge, close, reopen, edit, checkout, clean, diff, comment, review, approve, reject, checks, review-comments, resolve/unresolve +- Issues — create, list, view, comment, close, reopen, edit, labels, dependencies +- Repositories — view, list, create, edit, clone, fork, rename, delete, search, migrate, create-from-template +- Branches — list, rename, delete +- Labels / milestones / wiki — full CRUD +- Organizations — list, create, delete +- Webhooks — list, create, update, delete +- Notifications — list (unread or all), mark read +- Forgejo Actions — workflow runs, watch/rerun/cancel, workflows enable/disable, secrets, variables +- Releases — create, upload, delete +- Admin — `admin user list` (admin-token only) +- `fgj open` — launch a repo / issue / PR in the browser +- `fgj whoami` — show the authenticated user on the current host - Raw API access (`fgj api`) for arbitrary REST calls - Shell completions (bash, zsh, fish, PowerShell) and man pages -- JSON output (`--json`) for all list/view commands +- JSON output (`--json`, `--json-fields`, `--jq`) for all list/view commands - Structured JSON error output (`--json-errors`) for machine consumption - Automatic repository and hostname detection from git context +- Directory-scoped host defaults (`match_dirs`) - Secure authentication with personal access tokens - XDG Base Directory compliant config location - AI coding agent friendly diff --git a/cmd/actions.go b/cmd/actions.go index c1418ad..9f65af4 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -251,12 +251,26 @@ var actionsSecretListCmd = &cobra.Command{ var actionsSecretCreateCmd = &cobra.Command{ Use: "create ", Short: "Create or update a repository secret", - Long: "Create or update a secret for Forgejo Actions. The secret value will be read from stdin.", - Example: ` # Create a secret (will prompt for value) + Long: `Create or update a secret for Forgejo Actions. + +The secret value is read from the first available source: + 1. --body + 2. --body-file (use "-" for stdin) + 3. interactive prompt (hidden input) if stdin is a TTY + 4. stdin (when piped) + +Trailing newlines are trimmed. Empty values are rejected.`, + Example: ` # Interactive (hidden prompt) fgj actions secret create DEPLOY_TOKEN - # Create a secret for a specific repo - fgj actions secret create API_KEY -R owner/repo`, + # Pipe a value + op read op://vault/github/token | fgj actions secret create GH_TOKEN --body-file - + + # Read from a file + fgj actions secret create TLS_KEY --body-file ./server.key + + # Inline (visible in shell history — use sparingly) + fgj actions secret create DEBUG_FLAG --body "on"`, Args: cobra.ExactArgs(1), RunE: runActionsSecretCreate, } @@ -397,6 +411,8 @@ func init() { // Add flags for secret commands addRepoFlags(actionsSecretListCmd) addRepoFlags(actionsSecretCreateCmd) + actionsSecretCreateCmd.Flags().String("body", "", "Secret value (visible in shell history)") + actionsSecretCreateCmd.Flags().String("body-file", "", "Read secret value from file, or '-' for stdin") addRepoFlags(actionsSecretDeleteCmd) // Add flags for variable commands @@ -1254,12 +1270,9 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error { secretName := args[0] - // Read secret value from stdin - fmt.Fprint(ios.ErrOut, "Enter secret value: ") - var secretValue string - _, err = fmt.Scanln(&secretValue) + secretValue, err := readSecretValue(cmd, secretName) if err != nil { - return fmt.Errorf("failed to read secret value: %w", err) + return err } opt := gitea.CreateSecretOption{ @@ -1267,12 +1280,12 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error { Data: secretValue, } - _, err = client.CreateRepoActionSecret(owner, name, opt) - if err != nil { + if _, err := client.CreateRepoActionSecret(owner, name, opt); err != nil { return fmt.Errorf("failed to create secret: %w", err) } - fmt.Fprintf(ios.Out, "Secret '%s' created successfully\n", secretName) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Secret %q created\n", cs.SuccessIcon(), secretName) return nil } diff --git a/cmd/pr_approve_reject.go b/cmd/pr_approve_reject.go new file mode 100644 index 0000000..b394c16 --- /dev/null +++ b/cmd/pr_approve_reject.go @@ -0,0 +1,102 @@ +package cmd + +import ( + "fmt" + + "code.gitea.io/sdk/gitea" + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" + "github.com/spf13/cobra" +) + +var prApproveCmd = &cobra.Command{ + Use: "approve ", + Aliases: []string{"lgtm"}, + Short: "Approve a pull request", + Long: "Shortcut for 'fgj pr review --approve'. Body is optional.", + Example: ` # Approve with no body + fgj pr approve 42 + + # Approve with a message + fgj pr approve 42 -b "Thanks, shipping."`, + Args: cobra.ExactArgs(1), + RunE: runPRApproveReject(gitea.ReviewStateApproved, "approved", false), +} + +var prRejectCmd = &cobra.Command{ + Use: "reject ", + Short: "Request changes on a pull request", + Long: "Shortcut for 'fgj pr review --request-changes'. Body is required.", + Example: ` # Reject with explanation + fgj pr reject 42 -b "See the inline comments on auth.go" + + # Reject with a longer message from a file + fgj pr reject 42 --body-file feedback.md`, + Args: cobra.ExactArgs(1), + RunE: runPRApproveReject(gitea.ReviewStateRequestChanges, "reviewed with requested changes", true), +} + +func init() { + prCmd.AddCommand(prApproveCmd) + prCmd.AddCommand(prRejectCmd) + + for _, c := range []*cobra.Command{prApproveCmd, prRejectCmd} { + c.Flags().StringP("repo", "R", "", "Repository in owner/name format") + c.Flags().StringP("body", "b", "", "Review body/message") + c.Flags().String("body-file", "", "Read body from file (use \"-\" for stdin)") + addJSONFlags(c, "Output created review as JSON") + } +} + +func runPRApproveReject(state gitea.ReviewStateType, verb string, requireBody bool) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + prNumber, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid pull request number: %w", err) + } + + body, err := readBody(cmd) + if err != nil { + return err + } + if requireBody && body == "" { + return fmt.Errorf("a body is required (use --body or --body-file)") + } + + repo, _ := cmd.Flags().GetString("repo") + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) + if err != nil { + return err + } + + ios.StartSpinner("Submitting review...") + review, _, err := client.CreatePullReview(owner, name, prNumber, gitea.CreatePullReviewOptions{ + State: state, + Body: body, + }) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to submit review: %w", err) + } + + if wantJSON(cmd) { + return outputJSON(cmd, review) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s PR #%d %s\n", cs.SuccessIcon(), prNumber, verb) + if review.HTMLURL != "" { + fmt.Fprintf(ios.Out, "View at: %s\n", review.HTMLURL) + } + return nil + } +} diff --git a/cmd/repo_migrate.go b/cmd/repo_migrate.go new file mode 100644 index 0000000..60e548c --- /dev/null +++ b/cmd/repo_migrate.go @@ -0,0 +1,170 @@ +package cmd + +import ( + "fmt" + "strings" + + "code.gitea.io/sdk/gitea" + "github.com/spf13/cobra" +) + +var repoMigrateCmd = &cobra.Command{ + Use: "migrate ", + Aliases: []string{"m"}, + Short: "Migrate a repository from an external service", + Long: `Import a repository from GitHub, GitLab, Gogs, Gitea, or a plain Git +remote. By default the migration is a one-shot import; pass --mirror +to keep syncing on an interval. + +Authentication for the source repo is passed via --auth-token or +--auth-username + --auth-password. Neither is stored after the +migration completes on the server side.`, + Example: ` # Migrate a GitHub repo to this user's account + fgj repo migrate https://github.com/cli/cli \ + --name gh-mirror --service github --auth-token "$GH_TOKEN" + + # Mirror a plain Git remote into an org + fgj repo migrate https://example.com/project.git \ + --name project --owner infrastructure --mirror --mirror-interval 8h + + # Migrate with all content kinds + fgj repo migrate https://gitea.com/user/repo \ + --name repo --service gitea --auth-token "$TOKEN" \ + --wiki --labels --milestones --issues --pulls --releases --lfs`, + Args: cobra.ExactArgs(1), + RunE: runRepoMigrate, +} + +func init() { + repoCmd.AddCommand(repoMigrateCmd) + + repoMigrateCmd.Flags().String("name", "", "Name for the new repository (required)") + repoMigrateCmd.Flags().String("owner", "", "Owner (user or org) for the new repository (defaults to you)") + repoMigrateCmd.Flags().String("service", "git", "Source service: git, github, gitlab, gitea, gogs") + repoMigrateCmd.Flags().StringP("description", "d", "", "Description of the new repository") + repoMigrateCmd.Flags().String("auth-token", "", "Auth token for the source repo (preferred over username/password)") + repoMigrateCmd.Flags().String("auth-username", "", "Auth username for the source repo") + repoMigrateCmd.Flags().String("auth-password", "", "Auth password for the source repo") + repoMigrateCmd.Flags().Bool("private", false, "Make the new repository private") + repoMigrateCmd.Flags().Bool("mirror", false, "Mirror the source (keep syncing) instead of one-shot import") + repoMigrateCmd.Flags().String("mirror-interval", "", "Mirror sync interval (e.g. 8h, 24h); only with --mirror") + repoMigrateCmd.Flags().Bool("wiki", false, "Include wiki in the migration") + repoMigrateCmd.Flags().Bool("labels", false, "Include labels") + repoMigrateCmd.Flags().Bool("milestones", false, "Include milestones") + repoMigrateCmd.Flags().Bool("issues", false, "Include issues") + repoMigrateCmd.Flags().Bool("pulls", false, "Include pull requests") + repoMigrateCmd.Flags().Bool("releases", false, "Include releases") + repoMigrateCmd.Flags().Bool("lfs", false, "Include Git LFS content") + repoMigrateCmd.Flags().String("lfs-endpoint", "", "Explicit Git LFS server URL") + + _ = repoMigrateCmd.MarkFlagRequired("name") + addJSONFlags(repoMigrateCmd, "Output created repository as JSON") +} + +func runRepoMigrate(cmd *cobra.Command, args []string) error { + cloneURL := args[0] + + repoName, _ := cmd.Flags().GetString("name") + if strings.TrimSpace(repoName) == "" { + return fmt.Errorf("--name is required") + } + + owner, _ := cmd.Flags().GetString("owner") + serviceStr, _ := cmd.Flags().GetString("service") + + service, err := parseGitService(serviceStr) + if err != nil { + return err + } + + client, err := loadClient() + if err != nil { + return err + } + + // Default owner = authenticated user. + if owner == "" { + user, _, err := client.GetMyUserInfo() + if err != nil { + return fmt.Errorf("failed to resolve current user (pass --owner to override): %w", err) + } + owner = user.UserName + } + + description, _ := cmd.Flags().GetString("description") + authToken, _ := cmd.Flags().GetString("auth-token") + authUser, _ := cmd.Flags().GetString("auth-username") + authPass, _ := cmd.Flags().GetString("auth-password") + private, _ := cmd.Flags().GetBool("private") + mirror, _ := cmd.Flags().GetBool("mirror") + mirrorInterval, _ := cmd.Flags().GetString("mirror-interval") + if mirrorInterval != "" && !mirror { + return fmt.Errorf("--mirror-interval requires --mirror") + } + + wiki, _ := cmd.Flags().GetBool("wiki") + labels, _ := cmd.Flags().GetBool("labels") + milestones, _ := cmd.Flags().GetBool("milestones") + issues, _ := cmd.Flags().GetBool("issues") + pulls, _ := cmd.Flags().GetBool("pulls") + releases, _ := cmd.Flags().GetBool("releases") + lfs, _ := cmd.Flags().GetBool("lfs") + lfsEndpoint, _ := cmd.Flags().GetString("lfs-endpoint") + + opt := gitea.MigrateRepoOption{ + RepoName: repoName, + RepoOwner: owner, + CloneAddr: cloneURL, + Service: service, + AuthUsername: authUser, + AuthPassword: authPass, + AuthToken: authToken, + Private: private, + Description: description, + Mirror: mirror, + MirrorInterval: mirrorInterval, + Wiki: wiki, + Labels: labels, + Milestones: milestones, + Issues: issues, + PullRequests: pulls, + Releases: releases, + LFS: lfs, + LFSEndpoint: lfsEndpoint, + } + + ios.StartSpinner("Starting migration...") + repo, _, err := client.MigrateRepo(opt) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("migration failed: %w", err) + } + + if wantJSON(cmd) { + return outputJSON(cmd, repo) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Migrated to %s\n", cs.SuccessIcon(), repo.FullName) + if repo.HTMLURL != "" { + fmt.Fprintf(ios.Out, " %s\n", repo.HTMLURL) + } + return nil +} + +func parseGitService(s string) (gitea.GitServiceType, error) { + switch strings.ToLower(s) { + case "", "git", "plain": + return gitea.GitServicePlain, nil + case "github": + return gitea.GitServiceGithub, nil + case "gitlab": + return gitea.GitServiceGitlab, nil + case "gitea", "forgejo": + return gitea.GitServiceGitea, nil + case "gogs": + return gitea.GitServiceGogs, nil + default: + return "", fmt.Errorf("unknown --service %q (expected: git, github, gitlab, gitea, gogs)", s) + } +} diff --git a/cmd/repo_template.go b/cmd/repo_template.go new file mode 100644 index 0000000..0a82faf --- /dev/null +++ b/cmd/repo_template.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "fmt" + "strings" + + "code.gitea.io/sdk/gitea" + "github.com/spf13/cobra" +) + +var repoCreateFromTemplateCmd = &cobra.Command{ + Use: "create-from-template ", + Aliases: []string{"ct"}, + Short: "Create a repository from a template", + Long: `Scaffold a new repository based on an existing template repository. + +By default this copies only the default branch content. Pass --with- +flags to include topics, labels, webhooks, git hooks, and other template +metadata.`, + Example: ` # Create a new repo under your account from a template + fgj repo create-from-template org/template-name my-new-repo + + # Target a specific owner and make it private + fgj repo create-from-template org/template-name new-repo --owner myorg --private + + # Copy everything: content, topics, labels, webhooks, hooks, avatar + fgj repo create-from-template org/template-name new-repo \ + --with-content --with-topics --with-labels \ + --with-webhooks --with-git-hooks --with-avatar`, + Args: cobra.ExactArgs(2), + RunE: runRepoCreateFromTemplate, +} + +func init() { + repoCmd.AddCommand(repoCreateFromTemplateCmd) + + repoCreateFromTemplateCmd.Flags().String("owner", "", "Owner (user or org) for the new repository (defaults to you)") + repoCreateFromTemplateCmd.Flags().StringP("description", "d", "", "Description for the new repository") + repoCreateFromTemplateCmd.Flags().Bool("private", false, "Make the new repository private") + repoCreateFromTemplateCmd.Flags().Bool("with-content", true, "Include default branch content from the template") + repoCreateFromTemplateCmd.Flags().Bool("with-topics", false, "Include topics from the template") + repoCreateFromTemplateCmd.Flags().Bool("with-labels", false, "Include labels from the template") + repoCreateFromTemplateCmd.Flags().Bool("with-webhooks", false, "Include webhooks from the template") + repoCreateFromTemplateCmd.Flags().Bool("with-git-hooks", false, "Include git hooks from the template") + repoCreateFromTemplateCmd.Flags().Bool("with-avatar", false, "Include the template repo's avatar") + + addJSONFlags(repoCreateFromTemplateCmd, "Output created repository as JSON") +} + +func runRepoCreateFromTemplate(cmd *cobra.Command, args []string) error { + templateSlug := args[0] + newName := args[1] + + parts := strings.SplitN(templateSlug, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf("template must be in owner/name format (got %q)", templateSlug) + } + templateOwner, templateName := parts[0], parts[1] + + client, err := loadClient() + if err != nil { + return err + } + + owner, _ := cmd.Flags().GetString("owner") + if owner == "" { + user, _, err := client.GetMyUserInfo() + if err != nil { + return fmt.Errorf("failed to resolve current user (pass --owner to override): %w", err) + } + owner = user.UserName + } + + description, _ := cmd.Flags().GetString("description") + private, _ := cmd.Flags().GetBool("private") + withContent, _ := cmd.Flags().GetBool("with-content") + withTopics, _ := cmd.Flags().GetBool("with-topics") + withLabels, _ := cmd.Flags().GetBool("with-labels") + withWebhooks, _ := cmd.Flags().GetBool("with-webhooks") + withGitHooks, _ := cmd.Flags().GetBool("with-git-hooks") + withAvatar, _ := cmd.Flags().GetBool("with-avatar") + + opt := gitea.CreateRepoFromTemplateOption{ + Owner: owner, + Name: newName, + Description: description, + Private: private, + GitContent: withContent, + Topics: withTopics, + Labels: withLabels, + Webhooks: withWebhooks, + GitHooks: withGitHooks, + Avatar: withAvatar, + } + + ios.StartSpinner("Creating from template...") + repo, _, err := client.CreateRepoFromTemplate(templateOwner, templateName, opt) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("template instantiation failed: %w", err) + } + + if wantJSON(cmd) { + return outputJSON(cmd, repo) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Created %s from template %s\n", cs.SuccessIcon(), repo.FullName, templateSlug) + if repo.HTMLURL != "" { + fmt.Fprintf(ios.Out, " %s\n", repo.HTMLURL) + } + return nil +} diff --git a/cmd/secret_input.go b/cmd/secret_input.go new file mode 100644 index 0000000..98bbca9 --- /dev/null +++ b/cmd/secret_input.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// readSecretValue resolves the value for a secret/token flag from, in order: +// 1. --body (inline; visible in shell history) +// 2. --body-file (file path, or "-" for stdin) +// 3. interactive TTY prompt (hidden) +// 4. piped stdin +// +// Trailing whitespace (including the final newline common in heredocs and +// `echo ... | fgj ...`) is trimmed. An empty resolved value is rejected so we +// never silently write an empty secret. +func readSecretValue(cmd *cobra.Command, label string) (string, error) { + if v, _ := cmd.Flags().GetString("body"); v != "" { + return strings.TrimRight(v, "\r\n"), nil + } + + if path, _ := cmd.Flags().GetString("body-file"); path != "" { + var raw []byte + var err error + if path == "-" { + raw, err = io.ReadAll(ios.In) + } else { + raw, err = os.ReadFile(path) + } + if err != nil { + return "", fmt.Errorf("failed to read secret from %q: %w", path, err) + } + value := strings.TrimRight(string(raw), "\r\n") + if value == "" { + return "", fmt.Errorf("secret value from %q is empty", path) + } + return value, nil + } + + if ios.IsStdinTTY() { + fmt.Fprintf(ios.ErrOut, "Value for %s: ", label) + pw, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(ios.ErrOut) + if err != nil { + return "", fmt.Errorf("failed to read secret: %w", err) + } + value := strings.TrimRight(string(pw), "\r\n") + if value == "" { + return "", fmt.Errorf("secret value is empty") + } + return value, nil + } + + raw, err := io.ReadAll(ios.In) + if err != nil { + return "", fmt.Errorf("failed to read secret from stdin: %w", err) + } + value := strings.TrimRight(string(raw), "\r\n") + if value == "" { + return "", fmt.Errorf("secret value from stdin is empty") + } + return value, nil +}