Files
sherlock/internal/installer/helpers.go
T
amacocian 59eaf9e47d
Release / release (push) Failing after 6s
SearXNG support
2026-06-14 21:33:22 +02:00

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))
}