280 lines
7.2 KiB
Go
280 lines
7.2 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)
|
|
}
|
|
|
|
// appendMissingConfigSections compares the operator config with the
|
|
// shipped example and appends any missing [providers.*] or [services.*]
|
|
// sections as clearly marked templates. It never rewrites existing
|
|
// sections, so operator edits are preserved.
|
|
func appendMissingConfigSections(opts *Options, example, cfgPath string) ([]string, error) {
|
|
cfg, err := os.ReadFile(cfgPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
opts.warn("no config found at %s; run setup with --config-only to create one.", cfgPath)
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
exampleBody, err := os.ReadFile(example)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
existing := sectionSet(string(cfg))
|
|
blocks := configSectionBlocks(string(exampleBody))
|
|
var missing []configSectionBlock
|
|
for _, b := range blocks {
|
|
if !existing[b.Name] {
|
|
missing = append(missing, b)
|
|
}
|
|
}
|
|
if len(missing) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
var out strings.Builder
|
|
out.WriteString(string(cfg))
|
|
if len(cfg) > 0 && cfg[len(cfg)-1] != '\n' {
|
|
out.WriteByte('\n')
|
|
}
|
|
for _, b := range missing {
|
|
out.WriteString("\n# Added by sherlock because this section was missing from config.toml.\n")
|
|
out.WriteString("# Template copied from config.example.toml; fill placeholders before use.\n")
|
|
out.WriteString(strings.TrimSpace(b.Body))
|
|
out.WriteByte('\n')
|
|
}
|
|
if err := os.WriteFile(cfgPath, []byte(out.String()), 0o600); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
names := make([]string, 0, len(missing))
|
|
for _, b := range missing {
|
|
names = append(names, b.Name)
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
type configSectionBlock struct {
|
|
Name string
|
|
Body string
|
|
}
|
|
|
|
func sectionSet(body string) map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, line := range strings.Split(body, "\n") {
|
|
if name, ok := configSectionName(line); ok {
|
|
out[name] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func configSectionBlocks(body string) []configSectionBlock {
|
|
lines := strings.Split(body, "\n")
|
|
var blocks []configSectionBlock
|
|
for i := 0; i < len(lines); i++ {
|
|
name, ok := configSectionName(lines[i])
|
|
if !ok {
|
|
continue
|
|
}
|
|
start := i
|
|
end := len(lines)
|
|
for j := i + 1; j < len(lines); j++ {
|
|
if _, ok := configSectionName(lines[j]); ok {
|
|
end = j
|
|
break
|
|
}
|
|
}
|
|
blocks = append(blocks, configSectionBlock{
|
|
Name: name,
|
|
Body: strings.Join(lines[start:end], "\n"),
|
|
})
|
|
i = end - 1
|
|
}
|
|
return blocks
|
|
}
|
|
|
|
func configSectionName(line string) (string, bool) {
|
|
s := strings.TrimSpace(line)
|
|
if !strings.HasPrefix(s, "[") {
|
|
return "", false
|
|
}
|
|
end := strings.IndexByte(s, ']')
|
|
if end < 0 {
|
|
return "", false
|
|
}
|
|
name := s[1:end]
|
|
if strings.HasPrefix(name, "providers.") || strings.HasPrefix(name, "services.") {
|
|
return name, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// 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))
|
|
}
|