189 lines
5.4 KiB
Go
189 lines
5.4 KiB
Go
|
|
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
|
||
|
|
}
|