diff --git a/internal/cmd/init.go b/internal/cmd/init.go index ff4e220..b3618db 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -258,12 +258,17 @@ func detectCompletionsState() initStepState { return state } shellType := common.DetectShellType() - rc := common.DetectShellRC() if shellType == "" { state.status = "unsupported shell — skipping" state.configured = true return state } + if pkgPath := common.PackageInstalledCompletionPath(shellType); pkgPath != "" { + state.configured = true + state.status = "already installed at " + util.DisplayPath(pkgPath) + return state + } + rc := common.DetectShellRC() mentioned, err := common.ShellRCMentionsGhostCompletion(rc) if err != nil { state.status = fmt.Sprintf("could not read %s", rc) @@ -352,6 +357,10 @@ func runInitCompletions(cmd *cobra.Command) (bool, error) { cmd.PrintErrln("Could not detect your shell from $SHELL; skipping completions.") return false, nil } + if pkgPath := common.PackageInstalledCompletionPath(shellType); pkgPath != "" { + cmd.PrintErrf("Completions already installed at %s.\n", pkgPath) + return false, nil + } rc := common.DetectShellRC() mentioned, err := common.ShellRCMentionsGhostCompletion(rc) if err != nil { diff --git a/internal/cmd/init_test.go b/internal/cmd/init_test.go index ce14f82..534f7ce 100644 --- a/internal/cmd/init_test.go +++ b/internal/cmd/init_test.go @@ -65,6 +65,10 @@ func TestRunSelectedInitSteps_ConfiguresPathBeforeCompletions(t *testing.T) { t.Setenv("PATH", filepath.Join(home, "not-in-path")) t.Setenv("ZDOTDIR", "") t.Setenv("XDG_CONFIG_HOME", "") + // Point HOMEBREW_PREFIX at an empty dir so PackageInstalledCompletionPath + // doesn't pick up a real brew-installed completion on the dev machine + // and skip writing the snippet. + t.Setenv("HOMEBREW_PREFIX", t.TempDir()) executablePath, err := getGhostExecutablePath() if err != nil { diff --git a/internal/common/shell_setup.go b/internal/common/shell_setup.go index fa97083..821df37 100644 --- a/internal/common/shell_setup.go +++ b/internal/common/shell_setup.go @@ -65,6 +65,60 @@ func DetectShellRC() string { return filepath.Join(home, ".bashrc") } +// PackageInstalledCompletionPath returns the path of a Ghost shell +// completion file installed by a package manager (Homebrew on macOS, +// deb/rpm on Linux) for the given shell. Returns "" if shell is empty, +// unsupported, or no such file exists. When found, the caller should treat +// completions as already configured and skip writing to the user's rc file. +// +// Homebrew's prefix is taken from $HOMEBREW_PREFIX when set; otherwise the +// well-known prefixes (/opt/homebrew, /usr/local, /home/linuxbrew/.linuxbrew) +// are probed. The deb/rpm system paths under /usr/share are checked +// unconditionally. +func PackageInstalledCompletionPath(shell string) string { + for _, path := range packageCompletionCandidates(shell) { + if _, err := os.Stat(path); err == nil { + return path + } + } + return "" +} + +func packageCompletionCandidates(shell string) []string { + prefixes := homebrewPrefixCandidates() + switch shell { + case "bash": + paths := make([]string, 0, len(prefixes)+1) + for _, p := range prefixes { + paths = append(paths, filepath.Join(p, "etc", "bash_completion.d", "ghost")) + } + return append(paths, "/usr/share/bash-completion/completions/ghost") + case "zsh": + paths := make([]string, 0, len(prefixes)+2) + for _, p := range prefixes { + paths = append(paths, filepath.Join(p, "share", "zsh", "site-functions", "_ghost")) + } + return append(paths, + "/usr/share/zsh/vendor-completions/_ghost", + "/usr/share/zsh/site-functions/_ghost", + ) + case "fish": + paths := make([]string, 0, len(prefixes)+1) + for _, p := range prefixes { + paths = append(paths, filepath.Join(p, "share", "fish", "vendor_completions.d", "ghost.fish")) + } + return append(paths, "/usr/share/fish/vendor_completions.d/ghost.fish") + } + return nil +} + +func homebrewPrefixCandidates() []string { + if prefix := os.Getenv("HOMEBREW_PREFIX"); prefix != "" { + return []string{prefix} + } + return []string{"/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"} +} + // IsInPath reports whether dir is an element of $PATH. func IsInPath(dir string) bool { if dir == "" { diff --git a/internal/common/shell_setup_test.go b/internal/common/shell_setup_test.go index e3c1cf9..cd41422 100644 --- a/internal/common/shell_setup_test.go +++ b/internal/common/shell_setup_test.go @@ -69,6 +69,56 @@ func TestDetectShellRC(t *testing.T) { } } +func TestPackageInstalledCompletionPath(t *testing.T) { + tests := []struct { + name string + shell string + // relPath is created (with an empty file) under a fresh + // HOMEBREW_PREFIX temp dir before invoking the function. Empty + // means "no file created". + relPath string + wantFile bool + }{ + {name: "bash with no file", shell: "bash"}, + {name: "bash homebrew", shell: "bash", relPath: "etc/bash_completion.d/ghost", wantFile: true}, + {name: "zsh homebrew", shell: "zsh", relPath: "share/zsh/site-functions/_ghost", wantFile: true}, + {name: "fish homebrew", shell: "fish", relPath: "share/fish/vendor_completions.d/ghost.fish", wantFile: true}, + {name: "unsupported shell", shell: "ksh"}, + {name: "empty shell", shell: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prefix := t.TempDir() + t.Setenv("HOMEBREW_PREFIX", prefix) + + var want string + if tt.relPath != "" { + want = filepath.Join(prefix, tt.relPath) + if err := os.MkdirAll(filepath.Dir(want), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(want, nil, 0o644); err != nil { + t.Fatal(err) + } + } + + got := PackageInstalledCompletionPath(tt.shell) + if tt.wantFile { + if got != want { + t.Errorf("got %q, want %q", got, want) + } + return + } + // Without a fake file we expect "" — but a real package install + // on the dev machine (e.g. /usr/share/...) could match. Treat + // that as a skip rather than a failure. + if got != "" { + t.Skipf("real package completion present at %q; can't validate empty case", got) + } + }) + } +} + func TestIsInPath(t *testing.T) { t.Setenv("PATH", "/foo:/bar:/baz") if !IsInPath("/bar") {