diff --git a/CHANGELOG.md b/CHANGELOG.md index b3592a8..f0ef142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 deltas (`7d`, `24h`, `2w`, `1m`). Server-side filter for issues, client-side for PRs (SDK lacks a PR-side filter). - `fgj label update` added as an alias for `fgj label edit`. +- `fgj repo {archive,unarchive}` — toggle a repository's archived state + via `EditRepo`. Archiving prompts for confirmation (requires `--yes` + in non-TTY environments); unarchiving is reversible and skips the + prompt. +- `fgj completion install [shell]` — idempotently writes the + completion script to the shell-standard location (XDG for bash, + `~/.zsh/completions/_fgj` for zsh, `~/.config/fish/completions/fgj.fish` + for fish; brew prefix on macOS when present). Supports `--dry-run` + and `--system` (bash only, prints the required sudo command). ### Changed diff --git a/cmd/completion_install.go b/cmd/completion_install.go new file mode 100644 index 0000000..c45c870 --- /dev/null +++ b/cmd/completion_install.go @@ -0,0 +1,188 @@ +package cmd + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/spf13/cobra" +) + +var completionInstallCmd = &cobra.Command{ + Use: "install [shell]", + Short: "Install shell completions to a standard location", + Long: "Install shell completions for fgj to a shell-appropriate location. If [shell] is omitted, it is detected from $SHELL. Supported: bash, zsh, fish.", + Args: cobra.MaximumNArgs(1), + ValidArgs: []string{"bash", "zsh", "fish"}, + RunE: runCompletionInstall, +} + +func init() { + completionCmd.AddCommand(completionInstallCmd) + completionInstallCmd.Flags().Bool("system", false, "Install system-wide (bash only; prints required sudo command)") + completionInstallCmd.Flags().Bool("dry-run", false, "Print the target path and exit without writing") +} + +func runCompletionInstall(cmd *cobra.Command, args []string) error { + shell := "" + if len(args) == 1 { + shell = args[0] + } else { + detected, err := detectShell() + if err != nil { + return err + } + shell = detected + } + + switch shell { + case "bash", "zsh", "fish": + // supported + case "powershell": + return fmt.Errorf("powershell auto-install is not supported; run `fgj completion powershell` and save the output manually") + default: + return fmt.Errorf("unsupported shell: %s (supported: bash, zsh, fish)", shell) + } + + system, _ := cmd.Flags().GetBool("system") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + // Generate completion script into a buffer. + var buf bytes.Buffer + switch shell { + case "bash": + if err := rootCmd.GenBashCompletion(&buf); err != nil { + return fmt.Errorf("failed to generate bash completions: %w", err) + } + case "zsh": + if err := rootCmd.GenZshCompletion(&buf); err != nil { + return fmt.Errorf("failed to generate zsh completions: %w", err) + } + case "fish": + if err := rootCmd.GenFishCompletion(&buf, true); err != nil { + return fmt.Errorf("failed to generate fish completions: %w", err) + } + } + + // Resolve the target path. + targetPath, sudoHint, err := completionTargetPath(shell, system) + if err != nil { + return err + } + + if sudoHint != "" { + // System-wide bash path requires root. Print the command and exit. + fmt.Fprintf(ios.Out, "System-wide install requires root. Run:\n %s\n", sudoHint) + return nil + } + + if dryRun { + fmt.Fprintln(ios.Out, targetPath) + return nil + } + + // Create parent directory. + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return fmt.Errorf("failed to create %s: %w", filepath.Dir(targetPath), err) + } + + // Idempotent write: if the file already exists and matches, skip. + if existing, err := os.ReadFile(targetPath); err == nil { + if bytes.Equal(existing, buf.Bytes()) { + fmt.Fprintf(ios.Out, "Completion already installed at %s (up to date)\n", targetPath) + return nil + } + } + + if err := os.WriteFile(targetPath, buf.Bytes(), 0o644); err != nil { + return fmt.Errorf("failed to write %s: %w", targetPath, err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Installed %s completions to %s\n", cs.SuccessIcon(), shell, targetPath) + + if shell == "zsh" { + fmt.Fprintln(ios.Out, "") + fmt.Fprintln(ios.Out, "If you haven't already, add the following to your ~/.zshrc:") + fmt.Fprintln(ios.Out, " fpath=(~/.zsh/completions $fpath)") + fmt.Fprintln(ios.Out, " autoload -U compinit && compinit") + } + + return nil +} + +// completionTargetPath resolves the target install path for the given shell. +// For bash system-wide installs, it returns an empty path and a sudo hint command +// that the caller should print instead of writing. +func completionTargetPath(shell string, system bool) (string, string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", "", fmt.Errorf("failed to resolve home directory: %w", err) + } + + switch shell { + case "bash": + if system { + if _, err := os.Stat("/etc/bash_completion.d"); err == nil { + exe, exeErr := os.Executable() + if exeErr != nil || exe == "" { + exe = "fgj" + } + sudo := fmt.Sprintf("sudo sh -c '%s completion bash > /etc/bash_completion.d/fgj'", exe) + return "", sudo, nil + } + return "", "", fmt.Errorf("--system requested but /etc/bash_completion.d does not exist") + } + if runtime.GOOS == "darwin" { + if prefix, ok := brewPrefix(); ok { + return filepath.Join(prefix, "etc", "bash_completion.d", "fgj"), "", nil + } + } + xdg := os.Getenv("XDG_DATA_HOME") + if xdg == "" { + xdg = filepath.Join(home, ".local", "share") + } + return filepath.Join(xdg, "bash-completion", "completions", "fgj"), "", nil + + case "zsh": + zdot := os.Getenv("ZDOTDIR") + if zdot == "" { + zdot = home + } + return filepath.Join(zdot, ".zsh", "completions", "_fgj"), "", nil + + case "fish": + return filepath.Join(home, ".config", "fish", "completions", "fgj.fish"), "", nil + } + + return "", "", fmt.Errorf("unsupported shell: %s", shell) +} + +// detectShell reads $SHELL and returns its basename. +func detectShell() (string, error) { + s := os.Getenv("SHELL") + if s == "" { + return "", fmt.Errorf("cannot detect shell: $SHELL is empty (pass shell name explicitly)") + } + return filepath.Base(s), nil +} + +// brewPrefix returns the Homebrew prefix if brew is available on PATH. +func brewPrefix() (string, bool) { + if _, err := exec.LookPath("brew"); err != nil { + return "", false + } + out, err := exec.Command("brew", "--prefix").Output() + if err != nil { + return "", false + } + prefix := strings.TrimSpace(string(out)) + if prefix == "" { + return "", false + } + return prefix, true +} diff --git a/cmd/repo_archive.go b/cmd/repo_archive.go new file mode 100644 index 0000000..0c23f6a --- /dev/null +++ b/cmd/repo_archive.go @@ -0,0 +1,163 @@ +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 repoArchiveCmd = &cobra.Command{ + Use: "archive [owner/name]", + Short: "Archive a repository", + Long: `Mark a repository as archived. Archived repositories remain visible but +become read-only: pushes, issues, pull requests, and releases are disabled. + +The target repo may be passed as a positional argument, via -R/--repo, or +auto-detected from the current git context. If both positional and -R are +given, the -R flag wins.`, + Example: ` # Archive a specific repo (prompted confirmation) + fgj repo archive owner/name + + # Archive the current repo without prompting + fgj repo archive --yes + + # Archive using the -R flag + fgj repo archive -R owner/name -y`, + Args: cobra.MaximumNArgs(1), + RunE: runRepoArchive, +} + +var repoUnarchiveCmd = &cobra.Command{ + Use: "unarchive [owner/name]", + Short: "Unarchive a repository", + Long: `Clear the archived flag on a repository, restoring normal read-write +behaviour. + +The target repo may be passed as a positional argument, via -R/--repo, or +auto-detected from the current git context. If both positional and -R are +given, the -R flag wins.`, + Example: ` # Unarchive a specific repo + fgj repo unarchive owner/name + + # Unarchive the current repo + fgj repo unarchive`, + Args: cobra.MaximumNArgs(1), + RunE: runRepoUnarchive, +} + +func init() { + repoCmd.AddCommand(repoArchiveCmd) + repoCmd.AddCommand(repoUnarchiveCmd) + + addRepoFlags(repoArchiveCmd) + repoArchiveCmd.Flags().BoolP("yes", "y", false, "Skip the confirmation prompt") + addJSONFlags(repoArchiveCmd, "Output updated repository as JSON") + + addRepoFlags(repoUnarchiveCmd) + repoUnarchiveCmd.Flags().BoolP("yes", "y", false, "Skip the confirmation prompt (unused; kept for symmetry with archive)") + addJSONFlags(repoUnarchiveCmd, "Output updated repository as JSON") +} + +// resolveRepoTarget returns owner/name honouring the "optional positional OR +// -R flag" pattern used elsewhere in the CLI: -R wins when both are supplied. +func resolveRepoTarget(cmd *cobra.Command, args []string) (string, string, error) { + var repo string + if len(args) > 0 { + repo = args[0] + } + if r, _ := cmd.Flags().GetString("repo"); r != "" { + repo = r + } + return parseRepo(repo) +} + +func runRepoArchive(cmd *cobra.Command, args []string) error { + owner, name, err := resolveRepoTarget(cmd, args) + if err != nil { + return err + } + + slug := fmt.Sprintf("%s/%s", owner, name) + skipConfirm, _ := cmd.Flags().GetBool("yes") + + if !skipConfirm { + if !ios.IsStdinTTY() { + return fmt.Errorf("refusing to archive %s without a TTY; pass -y/--yes to confirm non-interactively", slug) + } + prompt := fmt.Sprintf("Archive %s? This disables issues/PRs/pushes. [y/N]: ", slug) + answer, err := promptLine(prompt) + if err != nil { + return err + } + if answer != "y" && answer != "Y" && answer != "yes" && answer != "Yes" && answer != "YES" { + fmt.Fprintln(ios.ErrOut, "Aborted.") + return nil + } + } + + cfg, err := config.Load() + if err != nil { + return err + } + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) + if err != nil { + return err + } + + archived := true + opt := gitea.EditRepoOption{Archived: &archived} + + ios.StartSpinner("Archiving repository...") + repository, _, err := client.EditRepo(owner, name, opt) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to archive %s: %w", slug, err) + } + + if wantJSON(cmd) { + return outputJSON(cmd, repository) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Archived %s\n", cs.SuccessIcon(), slug) + return nil +} + +func runRepoUnarchive(cmd *cobra.Command, args []string) error { + owner, name, err := resolveRepoTarget(cmd, args) + if err != nil { + return err + } + + slug := fmt.Sprintf("%s/%s", owner, name) + + cfg, err := config.Load() + if err != nil { + return err + } + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) + if err != nil { + return err + } + + archived := false + opt := gitea.EditRepoOption{Archived: &archived} + + ios.StartSpinner("Unarchiving repository...") + repository, _, err := client.EditRepo(owner, name, opt) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to unarchive %s: %w", slug, err) + } + + if wantJSON(cmd) { + return outputJSON(cmd, repository) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Unarchived %s\n", cs.SuccessIcon(), slug) + return nil +}