172 lines
4.6 KiB
Go
172 lines
4.6 KiB
Go
package installer
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// versionFile reads and trims dir/VERSION, returning "" if absent/empty.
|
|
func versionFile(dir string) string {
|
|
b, err := os.ReadFile(filepath.Join(dir, "VERSION"))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(b))
|
|
}
|
|
|
|
// latestLocalTag returns the highest vX.Y.Z tag in the git checkout at
|
|
// dir, or "" if there are none / git fails.
|
|
func latestLocalTag(dir string) string {
|
|
cmd := exec.CommandContext(context.Background(), "git", "-C", dir,
|
|
"tag", "-l", "v*", "--sort=-v:refname")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
if t := strings.TrimSpace(line); t != "" {
|
|
return t
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// seedConfig copies example → cfgPath when cfgPath does not exist,
|
|
// creating parent dirs (0700) and the file (0600). An existing config is
|
|
// left untouched.
|
|
func seedConfig(opts *Options, example, cfgPath string) error {
|
|
if _, err := os.Stat(cfgPath); err == nil {
|
|
opts.info("Existing config found at %s — editing it in place (not overwriting).", cfgPath)
|
|
return nil
|
|
}
|
|
opts.info("Creating %s from config.example.toml", cfgPath)
|
|
if err := os.MkdirAll(filepath.Dir(cfgPath), 0o700); err != nil {
|
|
return err
|
|
}
|
|
data, err := os.ReadFile(example)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(cfgPath, data, 0o600)
|
|
}
|
|
|
|
// pickEditor resolves the editor command: $VISUAL, then $EDITOR, then
|
|
// the first of sensible-editor/nano/vi found on PATH. Returns "" if none.
|
|
func pickEditor() string {
|
|
if v := os.Getenv("VISUAL"); v != "" {
|
|
return v
|
|
}
|
|
if e := os.Getenv("EDITOR"); e != "" {
|
|
return e
|
|
}
|
|
for _, e := range []string{"sensible-editor", "nano", "vi"} {
|
|
if _, err := exec.LookPath(e); err == nil {
|
|
return e
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// openEditor opens cfgPath in the resolved editor and waits. A missing
|
|
// editor is a warning, not an error (the operator can edit by hand).
|
|
func openEditor(opts *Options, cfgPath string) error {
|
|
ed := pickEditor()
|
|
if ed == "" {
|
|
opts.warn("no editor found (set $EDITOR). Edit %s by hand, then re-run with -y.", cfgPath)
|
|
return nil
|
|
}
|
|
opts.info("Opening %s in: %s", cfgPath, ed)
|
|
// The editor command may include args (e.g. "code --wait"); split on
|
|
// spaces like a shell would for the simple cases.
|
|
fields := strings.Fields(ed)
|
|
cmd := exec.Command(fields[0], append(fields[1:], cfgPath)...)
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
func containsPlaceholders(cfgPath string) bool {
|
|
b, err := os.ReadFile(cfgPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return strings.Contains(string(b), "REPLACE_")
|
|
}
|
|
|
|
// fishCompletionsDir returns the fish completions directory if the
|
|
// operator has a fish config dir, else "". We never create a fish config
|
|
// for non-fish users.
|
|
func fishCompletionsDir() string {
|
|
var base string
|
|
if d := os.Getenv("__fish_config_dir"); d != "" {
|
|
base = d
|
|
} else if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
|
|
base = filepath.Join(x, "fish")
|
|
} else if home, err := os.UserHomeDir(); err == nil {
|
|
base = filepath.Join(home, ".config", "fish")
|
|
}
|
|
if base == "" {
|
|
return ""
|
|
}
|
|
if fi, err := os.Stat(base); err != nil || !fi.IsDir() {
|
|
return ""
|
|
}
|
|
return filepath.Join(base, "completions")
|
|
}
|
|
|
|
// installCompletions copies the shipped shell completions into the
|
|
// operator's shell config when present. Currently fish only.
|
|
func installCompletions(opts *Options, source string) error {
|
|
dir := fishCompletionsDir()
|
|
if dir == "" {
|
|
return nil // not a fish user
|
|
}
|
|
src := filepath.Join(source, "completions", "sherlock.fish")
|
|
data, err := os.ReadFile(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
dst := filepath.Join(dir, "sherlock.fish")
|
|
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
|
return err
|
|
}
|
|
opts.info("Installed fish completion to %s", dst)
|
|
return nil
|
|
}
|
|
|
|
// reportInstallDir prints where binaries landed and a note if that dir
|
|
// is not on PATH (standard Go setup, not something we work around).
|
|
func reportInstallDir(opts *Options) {
|
|
bindir := goEnv("GOBIN")
|
|
if bindir == "" {
|
|
if gp := goEnv("GOPATH"); gp != "" {
|
|
bindir = filepath.Join(gp, "bin")
|
|
}
|
|
}
|
|
if bindir == "" {
|
|
return
|
|
}
|
|
opts.info("Installed to %s", bindir)
|
|
for _, p := range filepath.SplitList(os.Getenv("PATH")) {
|
|
if p == bindir {
|
|
return
|
|
}
|
|
}
|
|
opts.info("Note: %s is the Go install dir — make sure it's on your PATH.", bindir)
|
|
}
|
|
|
|
func goEnv(key string) string {
|
|
out, err := exec.Command("go", "env", key).Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|