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

240 lines
7.2 KiB
Go

// Package installer holds the single implementation of "install /
// update sherlock": sync the source, optionally seed the config, build
// and install every binary with the release version baked in, and
// install shell completions.
//
// There is exactly one copy of this logic. Two entry points call it:
//
// - `go run ./setup` (the bootstrap) → Install with interactive config
// seeding + editor.
// - `sherlock update` → Install non-interactively against the latest
// release tag.
package installer
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/config"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/selfupdate"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/xdg"
)
// devVersion is what a build reports when no explicit version is baked.
const devVersion = "0.0.0-dev"
// binaries are the cmd/<name> packages installed. `go install ./cmd/...`
// covers exactly these (the setup entry lives outside cmd/, so it is
// never installed).
var binaries = []string{"sherlock", "gitea-mcp", "grafana-mcp", "gssh-mcp", "searxng-mcp"}
// Options controls one Install run.
type Options struct {
// Local builds from LocalDir instead of cloning/updating the cache
// checkout. Used by `go run ./setup --local` for development and by
// `sherlock update` (which has already synced the cache itself).
Local bool
LocalDir string
// Version, if set, is baked verbatim into every binary's
// main.Version. If empty, it is derived (the checked-out tag, else
// the VERSION file, else the dev sentinel).
Version string
// SeedConfig copies config.example.toml to the operator's config
// path when none exists yet (never clobbers a filled-in file).
SeedConfig bool
// OpenEditor opens $VISUAL/$EDITOR on the config after seeding and
// appending any missing section templates.
OpenEditor bool
// ConfigOnly stops after the config step (no build/install).
ConfigOnly bool
// Out/Err receive progress and warnings. Default to os.Stdout/Stderr.
Out io.Writer
Err io.Writer
}
func (o *Options) out() io.Writer {
if o.Out != nil {
return o.Out
}
return os.Stdout
}
func (o *Options) errw() io.Writer {
if o.Err != nil {
return o.Err
}
return os.Stderr
}
func (o *Options) info(format string, a ...any) {
_, _ = fmt.Fprintf(o.out(), "\033[1;34m==>\033[0m "+format+"\n", a...)
}
func (o *Options) warn(format string, a ...any) {
_, _ = fmt.Fprintf(o.errw(), "\033[1;33mwarning:\033[0m "+format+"\n", a...)
}
// Install runs the full install/update for opts. It is safe to re-run.
func Install(ctx context.Context, opts Options) error {
if _, err := exec.LookPath("go"); err != nil {
return fmt.Errorf("the Go toolchain is required but 'go' was not found on PATH; install Go (https://go.dev/dl/) and retry")
}
source, version, err := resolveSource(ctx, &opts)
if err != nil {
return err
}
example := filepath.Join(source, "config.example.toml")
if _, err := os.Stat(example); err != nil {
return fmt.Errorf("config.example.toml not found in the source (%s): %w", example, err)
}
cfgPath, err := config.DefaultPath()
if err != nil {
return err
}
if opts.SeedConfig {
if err := seedConfig(&opts, example, cfgPath); err != nil {
return err
}
}
addedSections, err := appendMissingConfigSections(&opts, example, cfgPath)
if err != nil {
return err
}
if len(addedSections) > 0 {
opts.warn("%s was missing section(s): %s. Appended marked templates from config.example.toml; fill placeholders before use.",
cfgPath, strings.Join(addedSections, ", "))
}
if opts.SeedConfig {
if opts.OpenEditor {
if err := openEditor(&opts, cfgPath); err != nil {
return err
}
}
if containsPlaceholders(cfgPath) {
opts.warn("%s still contains REPLACE_… placeholders — fill them in before using sherlock.", cfgPath)
}
if opts.ConfigOnly {
opts.info("Config ready at %s (skipping install: --config-only).", cfgPath)
return nil
}
}
opts.info("Installing binaries (version %s): %s", version, strings.Join(binaries, " "))
if err := goInstall(&opts, source, version); err != nil {
return fmt.Errorf("go install: %w", err)
}
if err := installCompletions(&opts, source); err != nil {
// Non-fatal: completions are a convenience.
opts.warn("could not install shell completion: %v", err)
}
reportInstallDir(&opts)
opts.info("Done. Try: sherlock copilot")
return nil
}
// resolveSource determines the directory to build from and the version
// to bake. In local mode that's LocalDir; otherwise it clones/updates
// the cache checkout and checks out the latest release tag.
func resolveSource(ctx context.Context, opts *Options) (source, version string, err error) {
version = devVersion
if opts.Local {
source = opts.LocalDir
if source == "" {
source, _ = os.Getwd()
}
if v := versionFile(source); v != "" {
version = v
}
opts.info("Building from local checkout: %s (version %s)", source, version)
} else {
if _, err := exec.LookPath("git"); err != nil {
return "", "", fmt.Errorf("git is required to clone the sherlock source but was not found on PATH")
}
source, err = syncCache(ctx, opts)
if err != nil {
return "", "", err
}
if tag := latestLocalTag(source); tag != "" {
version = tag
} else if v := versionFile(source); v != "" {
version = v
opts.warn("no release tags found yet — building the default branch as %s.", version)
}
}
if opts.Version != "" {
version = opts.Version
}
return source, version, nil
}
// syncCache clones (first time) or fetches the cache checkout and checks
// out the latest release tag, returning its path.
func syncCache(ctx context.Context, opts *Options) (string, error) {
cache, err := xdg.CacheHome()
if err != nil {
return "", err
}
src := filepath.Join(cache, "sherlock", "src")
if _, err := os.Stat(filepath.Join(src, ".git")); err != nil {
opts.info("Cloning %s into %s", selfupdate.RepoURL(), src)
if err := os.MkdirAll(filepath.Dir(src), 0o700); err != nil {
return "", fmt.Errorf("mkdir cache: %w", err)
}
_ = os.RemoveAll(src)
if err := git(ctx, opts, "", "clone", "--quiet", selfupdate.RepoURL(), src); err != nil {
return "", fmt.Errorf("clone: %w", err)
}
} else {
opts.info("Updating cached source at %s", src)
if err := git(ctx, opts, src, "fetch", "--quiet", "--tags", "origin"); err != nil {
return "", fmt.Errorf("git fetch: %w", err)
}
}
if tag := latestLocalTag(src); tag != "" {
if err := git(ctx, opts, src, "checkout", "--quiet", "--force", tag); err != nil {
return "", fmt.Errorf("git checkout %s: %w", tag, err)
}
opts.info("Checked out release %s", tag)
}
return src, nil
}
func goInstall(opts *Options, source, version string) error {
pkgs := make([]string, 0, len(binaries))
for _, b := range binaries {
pkgs = append(pkgs, "./cmd/"+b)
}
args := append([]string{"install", "-ldflags", "-X main.Version=" + version}, pkgs...)
cmd := exec.Command("go", args...)
cmd.Dir = source
cmd.Stdout = opts.errw()
cmd.Stderr = opts.errw()
return cmd.Run()
}
func git(ctx context.Context, opts *Options, dir string, args ...string) error {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = dir
cmd.Stdout = opts.errw()
cmd.Stderr = opts.errw()
return cmd.Run()
}