From 53b0d025f59243e897204833fd19da3e1614ec0c Mon Sep 17 00:00:00 2001 From: Alexandru Macocian Date: Sat, 13 Jun 2026 14:25:40 +0200 Subject: [PATCH] Shared infra for installer --- .gitea/workflows/ci.yaml | 136 ---------------- .gitea/workflows/release.yaml | 134 +++++++++++++--- VERSION | 2 +- cmd/sherlock/update.go | 88 ++--------- docs/conventions.md | 8 +- docs/installation.md | 32 ++-- docs/versioning.md | 33 ++-- install.sh | 193 ++--------------------- internal/installer/helpers.go | 171 ++++++++++++++++++++ internal/installer/installer.go | 226 +++++++++++++++++++++++++++ internal/installer/installer_test.go | 95 +++++++++++ setup/main.go | 56 +++++++ 12 files changed, 733 insertions(+), 441 deletions(-) delete mode 100644 .gitea/workflows/ci.yaml create mode 100644 internal/installer/helpers.go create mode 100644 internal/installer/installer.go create mode 100644 internal/installer/installer_test.go create mode 100644 setup/main.go diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml deleted file mode 100644 index 0b63d79..0000000 --- a/.gitea/workflows/ci.yaml +++ /dev/null @@ -1,136 +0,0 @@ -name: CI - -# Go CI for sherlock. Runs gofmt, go vet, errcheck, staticcheck, -# go test -race, and go build on every push and PR. No deploy pipeline -# — sherlock is operator-installed via `go install`, not host-deployed. -# -# Why no actions/checkout + actions/setup-go: -# act_runner re-clones every third-party JS action from github.com per -# job. setup-go is large and that clone routinely hangs / times out on -# the homelab runner. We avoid the failure mode entirely by doing the -# checkout with plain git and downloading Go directly from go.dev. -# /usr/local/go persists across jobs in the host-executor runner -# container, so subsequent runs are no-ops. -# -# Runner notes: -# - Charlie runner image: gitea/act_runner + git/sops/docker-cli/bash/sudo -# (alpine-based). No Go preinstalled — installed lazily below. -# - runs-on: self-hosted picks any Charlie runner. No SSH-into-host -# because there's no docker build / no host state to mutate. - -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - -env: - # Bumped from 1.23.4 → 1.26.3 in Phase 1: go-oidc/v3, x/oauth2 and - # x/sync now declare `go 1.25.0`, which transitively forces our module - # to `go 1.25+`. 1.26.3 is the current upstream stable. - GO_VERSION: "1.26.3" - # Pinned linter versions. Bump deliberately; the runner caches the - # built binaries under ~/go/bin so re-runs are no-ops. - ERRCHECK_VERSION: "v1.20.0" - STATICCHECK_VERSION: "v0.7.0" - -jobs: - build: - runs-on: self-hosted - defaults: - run: - shell: sh - env: - # Gitea auto-generates a per-job `secrets.GITHUB_TOKEN` (read-scoped - # to this repo) for compat with GitHub Actions, but act_runner does - # NOT auto-export it as an env var — referencing it explicitly here - # makes it available to the Checkout step's `git fetch`. Same role - # as `secrets.RUNNER_REPO_PAT` / `CALLER_REPO_PAT` in the Charlie - # deploy/build workflows, except we don't need a long-lived PAT — - # the per-job token is enough for a one-shot fetch. - GITHUB_TOKEN: ${{ github.token }} - steps: - - name: Install Go and toolchain - run: | - set -eu - # curl: fetch the Go tarball. - # build-base (gcc + musl-dev + make): `go test -race` requires cgo. - missing="" - command -v curl >/dev/null 2>&1 || missing="$missing curl" - command -v gcc >/dev/null 2>&1 || missing="$missing build-base" - if [ -n "$missing" ]; then - apk add --no-cache $missing - fi - want="go${GO_VERSION}" - have="$(/usr/local/go/bin/go env GOVERSION 2>/dev/null || echo none)" - if [ "$have" != "$want" ]; then - echo "installing Go ${GO_VERSION} (had: ${have})" - curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" -o /tmp/go.tgz - rm -rf /usr/local/go - tar -C /usr/local -xzf /tmp/go.tgz - rm -f /tmp/go.tgz - else - echo "Go ${GO_VERSION} already installed" - fi - echo "/usr/local/go/bin" >> "$GITHUB_PATH" - echo "$HOME/go/bin" >> "$GITHUB_PATH" - # cgo on for the rest of the job (race detector needs it). - echo "CGO_ENABLED=1" >> "$GITHUB_ENV" - - - name: Checkout - run: | - set -eu - cd "$GITHUB_WORKSPACE" - # Wipe any leftovers from a previous job using the same workspace. - rm -rf ./* ./.[!.]* 2>/dev/null || true - git init -q - git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" - # Gitea injects GITHUB_TOKEN per job; use it as a one-shot - # Authorization header (not persisted to .git/config). - auth=$(printf '%s' "x-access-token:${GITHUB_TOKEN}" | base64 -w0) - git -c "http.extraheader=Authorization: Basic ${auth}" \ - fetch --depth=1 origin "${GITHUB_SHA}" - git checkout -q FETCH_HEAD - - - name: Install linters (errcheck, staticcheck) - run: | - set -eu - # Both tools are cached under $HOME/go/bin across jobs since - # the host-executor runner persists the home directory. - if ! command -v errcheck >/dev/null 2>&1; then - echo "installing errcheck ${ERRCHECK_VERSION}" - go install "github.com/kisielk/errcheck@${ERRCHECK_VERSION}" - else - echo "errcheck already installed: $(errcheck -h 2>&1 | head -1 || true)" - fi - if ! command -v staticcheck >/dev/null 2>&1; then - echo "installing staticcheck ${STATICCHECK_VERSION}" - go install "honnef.co/go/tools/cmd/staticcheck@${STATICCHECK_VERSION}" - else - echo "staticcheck already installed: $(staticcheck -version)" - fi - - - name: gofmt - run: | - set -eu - diff=$(gofmt -l $(find . -name '*.go' -not -path './vendor/*')) - if [ -n "$diff" ]; then - echo "gofmt would reformat:" - echo "$diff" - exit 1 - fi - - - name: go vet - run: go vet ./... - - - name: errcheck - run: errcheck ./... - - - name: staticcheck - run: staticcheck ./... - - - name: go test -race - run: go test ./... -race - - - name: go build - run: go build ./... diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index fed0b61..755f53e 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -1,56 +1,141 @@ name: Release -# Auto-tags releases from the VERSION file. On every push to main it -# reads VERSION (e.g. "0.1.0") and, if the tag "v0.1.0" does not already -# exist, creates and pushes it at the pushed commit. Bumping the release -# is therefore a one-line edit to VERSION — no manual tagging. +# Single pipeline: lint + test + build, then (only on push to main and +# only if every gate passed) tag the release from the VERSION file. +# A tag is therefore never created unless the build and tests are green. # -# Token: pushing a tag needs write access. Gitea injects a per-job -# `github.token`; this workflow uses it, so the repository's -# Settings → Actions → General → "Workflow permissions" must allow -# read/write. If your instance restricts the auto token, set a PAT -# secret named RELEASE_TAG_PAT (repo write) and it will be used instead. +# Why no actions/checkout + actions/setup-go: +# act_runner re-clones every third-party JS action from github.com per +# job. setup-go is large and that clone routinely hangs / times out on +# the homelab runner. We avoid the failure mode entirely by doing the +# checkout with plain git and downloading Go directly from go.dev. +# /usr/local/go persists across jobs in the host-executor runner +# container, so subsequent runs are no-ops. # -# Runner notes mirror ci.yaml: no actions/checkout + setup-go (the -# homelab act_runner re-clones JS actions per job and stalls); we use -# plain git instead. +# Tagging token: +# Pushing a tag needs write access. Gitea injects a per-job +# `github.token`; this workflow uses it, so the repository's +# Settings → Actions → General → "Workflow permissions" must allow +# read/write. If your instance restricts the auto token, set a PAT +# secret named RELEASE_TAG_PAT (repo write) and it is used instead. on: push: branches: [main] + pull_request: workflow_dispatch: +env: + GO_VERSION: "1.26.3" + ERRCHECK_VERSION: "v1.20.0" + STATICCHECK_VERSION: "v0.7.0" + jobs: - tag: + release: runs-on: self-hosted defaults: run: shell: sh env: + # Gitea auto-generates a per-job read-scoped token for git fetch; + # RELEASE_TAG_PAT (if set) is preferred for the tag push. GITHUB_TOKEN: ${{ github.token }} RELEASE_TAG_PAT: ${{ secrets.RELEASE_TAG_PAT }} steps: - - name: Tag release from VERSION + - name: Install Go and toolchain run: | set -eu + missing="" + command -v curl >/dev/null 2>&1 || missing="$missing curl" + command -v gcc >/dev/null 2>&1 || missing="$missing build-base" + command -v git >/dev/null 2>&1 || missing="$missing git" + if [ -n "$missing" ]; then + apk add --no-cache $missing + fi + want="go${GO_VERSION}" + have="$(/usr/local/go/bin/go env GOVERSION 2>/dev/null || echo none)" + if [ "$have" != "$want" ]; then + echo "installing Go ${GO_VERSION} (had: ${have})" + curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" -o /tmp/go.tgz + rm -rf /usr/local/go + tar -C /usr/local -xzf /tmp/go.tgz + rm -f /tmp/go.tgz + else + echo "Go ${GO_VERSION} already installed" + fi + echo "/usr/local/go/bin" >> "$GITHUB_PATH" + echo "$HOME/go/bin" >> "$GITHUB_PATH" + echo "CGO_ENABLED=1" >> "$GITHUB_ENV" + + - name: Checkout + run: | + set -eu + cd "$GITHUB_WORKSPACE" + rm -rf ./* ./.[!.]* 2>/dev/null || true + git init -q + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + auth=$(printf '%s' "x-access-token:${GITHUB_TOKEN}" | base64 -w0) + git -c "http.extraheader=Authorization: Basic ${auth}" \ + fetch --depth=1 origin "${GITHUB_SHA}" + git checkout -q FETCH_HEAD + + - name: Install linters (errcheck, staticcheck) + run: | + set -eu + if ! command -v errcheck >/dev/null 2>&1; then + echo "installing errcheck ${ERRCHECK_VERSION}" + go install "github.com/kisielk/errcheck@${ERRCHECK_VERSION}" + else + echo "errcheck already installed" + fi + if ! command -v staticcheck >/dev/null 2>&1; then + echo "installing staticcheck ${STATICCHECK_VERSION}" + go install "honnef.co/go/tools/cmd/staticcheck@${STATICCHECK_VERSION}" + else + echo "staticcheck already installed: $(staticcheck -version)" + fi + + - name: gofmt + run: | + set -eu + diff=$(gofmt -l $(find . -name '*.go' -not -path './vendor/*')) + if [ -n "$diff" ]; then + echo "gofmt would reformat:" + echo "$diff" + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: errcheck + run: errcheck ./... + + - name: staticcheck + run: staticcheck ./... + + - name: go test -race + run: go test ./... -race + + - name: go build + run: go build ./... + + # ── Release: only on push to main, and only after every gate above + # passed (steps run sequentially and fail-fast). ────────────── + - name: Tag release from VERSION + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + run: | + set -eu + cd "$GITHUB_WORKSPACE" - # Prefer an explicit PAT if provided, else the per-job token. token="${RELEASE_TAG_PAT:-$GITHUB_TOKEN}" if [ -z "$token" ]; then echo "no token available to push tags" >&2 exit 1 fi auth=$(printf '%s' "x-access-token:${token}" | base64 -w0) - remote="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" - work="$(mktemp -d)" - cd "$work" - git init -q - git remote add origin "$remote" - git -c "http.extraheader=Authorization: Basic ${auth}" \ - fetch --depth=1 origin "${GITHUB_SHA}" - git checkout -q FETCH_HEAD - # Fetch existing tags so we can check for the target tag. + # Fetch existing tags so we can check the target tag. git -c "http.extraheader=Authorization: Basic ${auth}" \ fetch --quiet --tags origin || true @@ -65,7 +150,6 @@ jobs: echo "Tag ${tag} already exists — nothing to release." exit 0 fi - # Also guard against a remote tag the shallow fetch missed. if git -c "http.extraheader=Authorization: Basic ${auth}" \ ls-remote --tags origin "refs/tags/${tag}" | grep -q "${tag}"; then echo "Tag ${tag} already exists on the remote — nothing to release." diff --git a/VERSION b/VERSION index 17e51c3..d917d3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.1 +0.1.2 diff --git a/cmd/sherlock/update.go b/cmd/sherlock/update.go index 11350db..3537321 100644 --- a/cmd/sherlock/update.go +++ b/cmd/sherlock/update.go @@ -4,12 +4,10 @@ import ( "context" "fmt" "os" - "os/exec" - "path/filepath" "time" + "gitea.alexandru.macocian.me/amacocian/sherlock/internal/installer" "gitea.alexandru.macocian.me/amacocian/sherlock/internal/selfupdate" - "gitea.alexandru.macocian.me/amacocian/sherlock/internal/xdg" ) // updateCheckTimeout bounds the best-effort passive update check so it @@ -40,9 +38,11 @@ func notifyUpdateAvailable() { res.Current, res.Latest) } -// runUpdate checks for the latest release and, if newer than the -// running binary (or if forced), updates the source checkout under -// $XDG_CACHE_HOME/sherlock/src and reinstalls all binaries from it. +// runUpdate checks for the latest release and, if newer than the running +// binary (or if forced), reinstalls via the shared installer. The +// installer is the single source of truth for *what* an install does +// (sync the source to the latest tag, build every binary, install +// completions); update only decides *whether* to run it. func runUpdate(args []string) { force := false for _, a := range args { @@ -55,18 +55,9 @@ func runUpdate(args []string) { } } - if _, err := exec.LookPath("git"); err != nil { - fmt.Fprintln(os.Stderr, "sherlock update: git is required but was not found on PATH.") - os.Exit(exitGeneric) - } - if _, err := exec.LookPath("go"); err != nil { - fmt.Fprintln(os.Stderr, "sherlock update: the Go toolchain is required but 'go' was not found on PATH.") - os.Exit(exitGeneric) - } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() latest, err := selfupdate.LatestTag(ctx, nil) + cancel() if err != nil { fmt.Fprintln(os.Stderr, "sherlock update: checking for latest release:", err) os.Exit(exitGeneric) @@ -86,63 +77,16 @@ func runUpdate(args []string) { return } - src, err := updateSourceCheckout(latest) - if err != nil { + // Non-interactive install against the cache checkout (the installer + // syncs it to the latest tag and bakes that version). Config is left + // untouched — SeedConfig is off, so an update never opens an editor + // or clobbers config. + if err := installer.Install(context.Background(), installer.Options{ + Local: false, + SeedConfig: false, + OpenEditor: false, + }); err != nil { fmt.Fprintln(os.Stderr, "sherlock update:", err) os.Exit(exitGeneric) } - - fmt.Printf("Installing %s …\n", latest) - if err := goInstallAll(src, latest); err != nil { - fmt.Fprintln(os.Stderr, "sherlock update:", err) - os.Exit(exitGeneric) - } - fmt.Printf("Updated to %s.\n", latest) -} - -// updateSourceCheckout ensures $XDG_CACHE_HOME/sherlock/src is a clone -// of the sherlock repo checked out at tag, cloning on first use and -// fetching otherwise. Returns the checkout path. -func updateSourceCheckout(tag string) (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 { - if err := os.MkdirAll(filepath.Dir(src), 0o700); err != nil { - return "", fmt.Errorf("mkdir cache: %w", err) - } - _ = os.RemoveAll(src) // clear any partial/non-git leftovers - if err := run("", "git", "clone", "--quiet", selfupdate.RepoURL(), src); err != nil { - return "", fmt.Errorf("clone %s: %w", selfupdate.RepoURL(), err) - } - } else { - if err := run(src, "git", "fetch", "--quiet", "--tags", "origin"); err != nil { - return "", fmt.Errorf("git fetch: %w", err) - } - } - if err := run(src, "git", "checkout", "--quiet", "--force", tag); err != nil { - return "", fmt.Errorf("git checkout %s: %w", tag, err) - } - return src, nil -} - -// goInstallAll runs `go install ./cmd/...` from src, baking version into -// every binary's main.Version. -func goInstallAll(src, version string) error { - return run(src, "go", "install", - "-ldflags", "-X main.Version="+version, - "./cmd/...") -} - -// run executes name with args in dir (cwd if empty), streaming output -// to the user's stderr/stdout. -func run(dir, name string, args ...string) error { - cmd := exec.Command(name, args...) - cmd.Dir = dir - cmd.Stdout = os.Stderr // build chatter goes to stderr, not stdout - cmd.Stderr = os.Stderr - return cmd.Run() } diff --git a/docs/conventions.md b/docs/conventions.md index aece01d..22458ca 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -6,9 +6,11 @@ Rules that govern this repo. Anything that becomes load-bearing belongs here, in ``` cmd/ one subdir per shippable binary; package main only +setup/ bootstrap installer entry (go run ./setup); not shipped internal/ every other package; never imported outside this module -internal/agent/profiles built-in TOML profiles, embedded with //go:embed +internal/installer/ the one install/update implementation (shared by setup + `sherlock update`) docs/ one topic per file (see "Docs" below) +VERSION release version source of truth (see versioning.md) README.md TOC only ``` @@ -56,8 +58,8 @@ No top-level `pkg/` until we have an external consumer. ## CI -- `.gitea/workflows/ci.yaml` runs on every push + PR. It runs `gofmt`, `go vet`, `errcheck`, `staticcheck`, `go test -race`, and `go build`. No other gates. -- `.gitea/workflows/release.yaml` runs on push to `main`. It reads the root `VERSION` file and pushes the tag `vVERSION` if it doesn't already exist. Cutting a release is a one-line edit to `VERSION`. See [versioning.md](versioning.md). +- `.gitea/workflows/release.yaml` is the single pipeline. On every push + PR it runs `gofmt`, `go vet`, `errcheck`, `staticcheck`, `go test -race`, and `go build`. +- On **push to `main` only**, and **only after every gate above passes**, a final step reads the root `VERSION` file and pushes the tag `vVERSION` if it doesn't already exist. A tag is never created on a red build. Cutting a release is a one-line edit to `VERSION`. See [versioning.md](versioning.md). - Sherlock is operator-installed via `install.sh` (which clones + `go install`s) and self-updates via `sherlock update`. No host-deploy pipeline. ## Security diff --git a/docs/installation.md b/docs/installation.md index 2b19d9d..6a106bd 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,22 +1,25 @@ # Installation -Sherlock installs from a clone of this repo with a single script. +Sherlock installs from a clone of this repo with a single Go command. ## Quick start ```bash git clone https://gitea.alexandru.macocian.me/amacocian/sherlock.git cd sherlock -./install.sh +go run ./setup ``` -The script: +(`./install.sh` is kept as a thin convenience wrapper that just runs +`go run ./setup` with the same flags.) + +The installer: 1. **Syncs the source.** Clones (or updates) the sherlock repo under `${XDG_CACHE_HOME:-~/.cache}/sherlock/src` and checks out the latest release tag, so the install is reproducible and shares one checkout with `sherlock update`. (Use `--local` to build from the checkout - you ran the script from instead — handy for development.) + you ran it from instead — handy for development.) 2. **Seeds the config.** Copies [`config.example.toml`](../config.example.toml) to your config path (`~/.config/sherlock/config.toml` by default) — but only if you don't already have one, so re-runs never clobber filled-in @@ -33,6 +36,10 @@ The script: if you already have a fish config directory (it won't create one for non-fish users). +The exact same code runs on `sherlock update` — there is one installer, +in `internal/installer`, with two entry points (`go run ./setup` for the +bootstrap and `sherlock update` for self-update). + Then: ```bash @@ -41,17 +48,20 @@ sherlock copilot ## Requirements -- The **Go toolchain** on `PATH` (the script builds from source). +- The **Go toolchain** on `PATH` (the installer builds from source). +- **git** on `PATH` (to clone the source; not needed with `--local`). - An **OS keyring** / Secret Service provider (the wallet lives there; see [storage.md](storage.md)). - The install dir (`go env GOBIN`, else `$(go env GOPATH)/bin`) on your - `PATH` — this is standard Go setup. The script notes the dir if it + `PATH` — this is standard Go setup. The installer notes the dir if it isn't already on `PATH`, but adding it is the usual one-time Go step (e.g. `fish_add_path -U ~/go/bin` on fish, or an `export PATH` line in `~/.bashrc`/`~/.zshrc`). ## Flags +Pass to `go run ./setup` (or `./install.sh`): + | Flag | Effect | |---|---| | `-y`, `--yes` | Skip the editor step (config is already filled in). | @@ -69,7 +79,7 @@ sherlock copilot ## Updating -Sherlock updates itself in place: +Sherlock updates itself in place (running the same installer): ```bash sherlock update # if a newer release exists @@ -81,10 +91,10 @@ available. See [versioning.md](versioning.md). ## Re-running -`./install.sh` is safe to re-run: it edits your existing config in place -(never overwriting it) and re-installs the binaries. Use `-y` to skip -the editor when you only want to rebuild, or `--config-only` to tweak -the config without reinstalling. +`go run ./setup` (or `./install.sh`) is safe to re-run: it edits your +existing config in place (never overwriting it) and re-installs the +binaries. Use `-y` to skip the editor when you only want to rebuild, or +`--config-only` to tweak the config without reinstalling. To point sherlock at a different deployment, edit the values in your `config.toml` — there are no compiled-in defaults, so nothing is baked diff --git a/docs/versioning.md b/docs/versioning.md index 5a46f30..6c9ce51 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -8,9 +8,11 @@ itself in place. - The source of truth is the **`VERSION`** file at the repo root, holding a full semver string (e.g. `0.1.0`). - On every push to `main`, the [release workflow](../.gitea/workflows/release.yaml) - reads `VERSION` and, **if the matching tag `vVERSION` does not already - exist**, creates and pushes it at that commit. Cutting a release is a - one-line edit to `VERSION` — no manual tagging. + runs the full CI (gofmt, vet, errcheck, staticcheck, `test -race`, + build) and then — **only if every gate passed** — reads `VERSION` and, + **if the matching tag `vVERSION` does not already exist**, creates and + pushes it at that commit. A tag is never created on a red build. + Cutting a release is a one-line edit to `VERSION` — no manual tagging. - Binaries bake their version in at build time via `-ldflags "-X main.Version="`. A build without that flag reports the sentinel `0.0.0-dev` and is treated as "not a release". @@ -19,8 +21,8 @@ itself in place. | Build path | Version baked | |---|---| -| `install.sh` (default) | the latest `vX.Y.Z` tag in the cache checkout, or the `VERSION` file if there are no tags yet | -| `install.sh --local` | the `VERSION` file in your working tree | +| `go run ./setup` (default) | the latest `vX.Y.Z` tag in the cache checkout, or the `VERSION` file if there are no tags yet | +| `go run ./setup --local` | the `VERSION` file in your working tree | | `sherlock update` | the latest published tag | | plain `go install ./cmd/...` | `0.0.0-dev` (no `-ldflags`) | @@ -48,17 +50,16 @@ sherlock update # update to the latest release if newer sherlock update --force # reinstall the latest even if not newer ``` -`update`: +`update` resolves the latest tag, then calls the shared installer +(`internal/installer`) — the same code `go run ./setup` runs. The +installer clones (first time) or fetches the source under +`${XDG_CACHE_HOME:-~/.cache}/sherlock/src`, checks out the latest tag, +runs `go install -ldflags "-X main.Version=" ./cmd/...`, and +installs shell completions. An update never touches your config. -1. Resolves the latest tag from the tags API. -2. Clones (first time) or fetches the source under - `${XDG_CACHE_HOME:-~/.cache}/sherlock/src` and checks out that tag. -3. Runs `go install -ldflags "-X main.Version=" ./cmd/...`, which - reinstalls `sherlock` and every MCP binary with the version baked in. - -It requires `git` and the Go toolchain on `PATH`. The same cache -checkout is what `install.sh` builds from, so the two share one source -of truth. +There is no duplicate install logic: `go run ./setup` and +`sherlock update` differ only in whether they seed/edit the config. Both +require `git` and the Go toolchain on `PATH`. ## Environment overrides @@ -66,4 +67,4 @@ of truth. |---|---| | `SHERLOCK_NO_UPDATE_CHECK` | Disable the passive update hint. | | `SHERLOCK_UPDATE_TAGS_URL` | Override the tags API URL (forks, mirrors, tests). | -| `SHERLOCK_UPDATE_REPO_URL` | Override the clone URL used by `update` and `install.sh`. | +| `SHERLOCK_UPDATE_REPO_URL` | Override the clone URL used by the installer. | diff --git a/install.sh b/install.sh index d25b051..2f3e65b 100755 --- a/install.sh +++ b/install.sh @@ -1,185 +1,24 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh # -# sherlock installer. +# Thin convenience wrapper. The real installer is Go: # -# ./install.sh +# go run ./setup # -# It: -# 1. clones (or updates) the sherlock source under -# ${XDG_CACHE_HOME:-~/.cache}/sherlock/src and checks out the latest -# release tag, so installs are reproducible and share the same -# checkout as `sherlock update`, -# 2. copies config.example.toml to your sherlock config path -# (default ~/.config/sherlock/config.toml) if you don't have one yet, -# 3. opens it in your editor so you can fill in your deployment values, -# 4. on save, `go install`s sherlock and every MCP binary (with the -# release version baked in), plus the fish completion if you use fish. +# This script just forwards to it so `./install.sh` keeps working. All +# flags pass through (see `go run ./setup --help`). # -# Flags: -# -y, --yes skip the editor step (config is already filled in) -# --config-only set up/edit the config but do NOT build/install -# --local build from THIS checkout instead of cloning to the -# cache (for development) -# -h, --help show this help -# -# Env overrides: -# SHERLOCK_CONFIG exact config path (else XDG / ~/.config) -# SHERLOCK_UPDATE_REPO_URL clone URL (else the public sherlock repo) -# VISUAL / EDITOR editor to open (falls back to nano, then vi) +# ./install.sh # interactive: seed config, edit, install +# ./install.sh -y # skip the editor +# ./install.sh --local # build from this checkout (development) +# ./install.sh --config-only -set -euo pipefail +set -eu -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_URL="${SHERLOCK_UPDATE_REPO_URL:-https://gitea.alexandru.macocian.me/amacocian/sherlock}" -CACHE_SRC="${XDG_CACHE_HOME:-$HOME/.cache}/sherlock/src" - -# Every cmd/ that ships a binary. Keep in sync with cmd/. -BINARIES=(sherlock gitea-mcp grafana-mcp gssh-mcp) - -SKIP_EDITOR=0 -CONFIG_ONLY=0 -LOCAL=0 - -info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } -warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; } -die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } - -usage() { - sed -n '3,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' - exit "${1:-0}" +command -v go >/dev/null 2>&1 || { + echo "error: the Go toolchain is required but 'go' was not found on PATH." >&2 + echo "Install Go (https://go.dev/dl/), then re-run." >&2 + exit 1 } -while [ $# -gt 0 ]; do - case "$1" in - -y|--yes) SKIP_EDITOR=1 ;; - --config-only) CONFIG_ONLY=1 ;; - --local) LOCAL=1 ;; - -h|--help) usage 0 ;; - *) warn "unknown argument: $1"; usage 1 ;; - esac - shift -done - -# ── Toolchain checks ───────────────────────────────────────────────── -command -v go >/dev/null 2>&1 || die "the Go toolchain is required but 'go' was not found on PATH. Install Go (https://go.dev/dl/), then re-run." -if [ "$LOCAL" -eq 0 ]; then - command -v git >/dev/null 2>&1 || die "git is required to clone the sherlock source but was not found on PATH." -fi - -# ── 1. Resolve the build source (cache clone at latest tag, or local) ─ -VERSION="0.0.0-dev" -if [ "$LOCAL" -eq 1 ]; then - SOURCE="$SCRIPT_DIR" - [ -f "$SOURCE/VERSION" ] && VERSION="$(tr -d ' \t\r\n' < "$SOURCE/VERSION")" - info "Building from local checkout: $SOURCE (version $VERSION)" -else - SOURCE="$CACHE_SRC" - if [ -d "$SOURCE/.git" ]; then - info "Updating cached source at $SOURCE" - git -C "$SOURCE" fetch --quiet --tags origin - else - info "Cloning $REPO_URL into $SOURCE" - mkdir -p "$(dirname "$SOURCE")" - rm -rf "$SOURCE" - git clone --quiet "$REPO_URL" "$SOURCE" - fi - # Latest vX.Y.Z tag, if any; otherwise stay on the default branch. - tag="$(git -C "$SOURCE" tag -l 'v*' --sort=-v:refname | head -n1)" - if [ -n "$tag" ]; then - git -C "$SOURCE" checkout --quiet --force "$tag" - VERSION="$tag" - info "Checked out release $tag" - else - [ -f "$SOURCE/VERSION" ] && VERSION="$(tr -d ' \t\r\n' < "$SOURCE/VERSION")" - warn "No release tags found yet — building the default branch as $VERSION." - fi -fi - -EXAMPLE="$SOURCE/config.example.toml" -[ -f "$EXAMPLE" ] || die "config.example.toml not found in the source ($EXAMPLE)." - -# ── 2. Resolve the config path (mirrors internal/config.DefaultPath) ── -config_path() { - if [ -n "${SHERLOCK_CONFIG:-}" ]; then - printf '%s\n' "$SHERLOCK_CONFIG" - elif [ -n "${XDG_CONFIG_HOME:-}" ]; then - printf '%s/sherlock/config.toml\n' "$XDG_CONFIG_HOME" - else - printf '%s/.config/sherlock/config.toml\n' "$HOME" - fi -} -CONFIG="$(config_path)" - -# Seed the config from the example (never clobber a filled one). -if [ -f "$CONFIG" ]; then - info "Existing config found at $CONFIG — editing it in place (not overwriting)." -else - info "Creating $CONFIG from config.example.toml" - mkdir -p "$(dirname "$CONFIG")" - cp "$EXAMPLE" "$CONFIG" - chmod 600 "$CONFIG" -fi - -# ── 3. Open it in the editor and wait for the user to finish ───────── -pick_editor() { - if [ -n "${VISUAL:-}" ]; then printf '%s\n' "$VISUAL"; return; fi - if [ -n "${EDITOR:-}" ]; then printf '%s\n' "$EDITOR"; return; fi - for e in sensible-editor nano vi; do - if command -v "$e" >/dev/null 2>&1; then printf '%s\n' "$e"; return; fi - done - return 1 -} - -if [ "$SKIP_EDITOR" -eq 0 ]; then - if EDITOR_CMD="$(pick_editor)"; then - info "Opening $CONFIG in: $EDITOR_CMD" - # shellcheck disable=SC2086 - $EDITOR_CMD "$CONFIG" - else - warn "No editor found (set \$EDITOR). Edit $CONFIG by hand, then re-run with -y." - fi -fi - -# Light sanity check: warn (don't block) on leftover placeholders. -if grep -q 'REPLACE_' "$CONFIG" 2>/dev/null; then - warn "$CONFIG still contains REPLACE_… placeholders — fill them in before using sherlock." -fi - -if [ "$CONFIG_ONLY" -eq 1 ]; then - info "Config ready at $CONFIG (skipping install: --config-only)." - exit 0 -fi - -# ── 4. Build + install every binary from the source ────────────────── -info "Installing binaries (version $VERSION): ${BINARIES[*]}" -pkgs=() -for b in "${BINARIES[@]}"; do - pkgs+=("./cmd/$b") -done -( cd "$SOURCE" && go install -ldflags "-X main.Version=$VERSION" "${pkgs[@]}" ) - -# ── 4b. Install the fish completion if fish is configured ──────────── -# Only when a fish config dir already exists — we don't create one for -# users who don't run fish. -fish_config_dir="${__fish_config_dir:-${XDG_CONFIG_HOME:-$HOME/.config}/fish}" -completion_src="$SOURCE/completions/sherlock.fish" -if [ -d "$fish_config_dir" ] && [ -f "$completion_src" ]; then - mkdir -p "$fish_config_dir/completions" - cp "$completion_src" "$fish_config_dir/completions/sherlock.fish" - info "Installed fish completion to $fish_config_dir/completions/sherlock.fish" -fi - -# ── 5. Report where they landed ────────────────────────────────────── -bindir="$(go env GOBIN)" -[ -n "$bindir" ] || bindir="$(go env GOPATH)/bin" -info "Installed to $bindir" - -# Standard Go install dir; ensuring it's on PATH is normal Go setup, so -# we just note it (no shell-specific workarounds — `go install` doesn't -# do them either) when it isn't already there. -case ":$PATH:" in - *":$bindir:"*) : ;; - *) info "Note: $bindir is the Go install dir — make sure it's on your PATH." ;; -esac - -info "Done. Try: sherlock copilot" +cd "$(dirname "$0")" +exec go run ./setup "$@" diff --git a/internal/installer/helpers.go b/internal/installer/helpers.go new file mode 100644 index 0000000..17f9c2b --- /dev/null +++ b/internal/installer/helpers.go @@ -0,0 +1,171 @@ +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)) +} diff --git a/internal/installer/installer.go b/internal/installer/installer.go new file mode 100644 index 0000000..386cb25 --- /dev/null +++ b/internal/installer/installer.go @@ -0,0 +1,226 @@ +// 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/ 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"} + +// 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. + 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) + } + + if opts.SeedConfig { + cfgPath, err := config.DefaultPath() + if err != nil { + return err + } + if err := seedConfig(&opts, example, cfgPath); err != nil { + return err + } + 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() +} diff --git a/internal/installer/installer_test.go b/internal/installer/installer_test.go new file mode 100644 index 0000000..7c9b287 --- /dev/null +++ b/internal/installer/installer_test.go @@ -0,0 +1,95 @@ +package installer + +import ( + "os" + "path/filepath" + "testing" +) + +func TestVersionFile(t *testing.T) { + dir := t.TempDir() + if v := versionFile(dir); v != "" { + t.Fatalf("missing VERSION should yield empty, got %q", v) + } + if err := os.WriteFile(filepath.Join(dir, "VERSION"), []byte("0.3.0\n"), 0o644); err != nil { + t.Fatal(err) + } + if v := versionFile(dir); v != "0.3.0" { + t.Fatalf("versionFile = %q, want 0.3.0", v) + } +} + +func TestPickEditor_PrefersVisualThenEditor(t *testing.T) { + t.Setenv("VISUAL", "myvisual") + t.Setenv("EDITOR", "myeditor") + if got := pickEditor(); got != "myvisual" { + t.Fatalf("pickEditor = %q, want myvisual", got) + } + t.Setenv("VISUAL", "") + if got := pickEditor(); got != "myeditor" { + t.Fatalf("pickEditor = %q, want myeditor", got) + } +} + +func TestContainsPlaceholders(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "config.toml") + _ = os.WriteFile(p, []byte("client_id = \"REPLACE_ME\"\n"), 0o600) + if !containsPlaceholders(p) { + t.Fatal("expected placeholders detected") + } + _ = os.WriteFile(p, []byte("client_id = \"abc123\"\n"), 0o600) + if containsPlaceholders(p) { + t.Fatal("did not expect placeholders") + } +} + +func TestSeedConfig_DoesNotClobber(t *testing.T) { + dir := t.TempDir() + example := filepath.Join(dir, "config.example.toml") + _ = os.WriteFile(example, []byte("example\n"), 0o644) + cfg := filepath.Join(dir, "out", "config.toml") + + opts := &Options{Out: io_discard{}, Err: io_discard{}} + + // First seed: creates the file from the example. + if err := seedConfig(opts, example, cfg); err != nil { + t.Fatalf("seedConfig: %v", err) + } + if b, _ := os.ReadFile(cfg); string(b) != "example\n" { + t.Fatalf("seeded content = %q", b) + } + // Operator fills it in. + _ = os.WriteFile(cfg, []byte("filled\n"), 0o600) + // Re-seed must NOT overwrite. + if err := seedConfig(opts, example, cfg); err != nil { + t.Fatalf("seedConfig re-run: %v", err) + } + if b, _ := os.ReadFile(cfg); string(b) != "filled\n" { + t.Fatalf("re-seed clobbered config: %q", b) + } +} + +func TestFishCompletionsDir(t *testing.T) { + tmp := t.TempDir() + t.Setenv("__fish_config_dir", "") + t.Setenv("XDG_CONFIG_HOME", tmp) + // No fish dir yet → empty. + if d := fishCompletionsDir(); d != "" { + t.Fatalf("no fish dir should yield empty, got %q", d) + } + // Create the fish dir → returns its completions subdir. + if err := os.MkdirAll(filepath.Join(tmp, "fish"), 0o755); err != nil { + t.Fatal(err) + } + want := filepath.Join(tmp, "fish", "completions") + if d := fishCompletionsDir(); d != want { + t.Fatalf("fishCompletionsDir = %q, want %q", d, want) + } +} + +// io_discard is a tiny io.Writer that drops everything (avoids importing +// io just for io.Discard in this test file). +type io_discard struct{} + +func (io_discard) Write(p []byte) (int, error) { return len(p), nil } diff --git a/setup/main.go b/setup/main.go new file mode 100644 index 0000000..f07f520 --- /dev/null +++ b/setup/main.go @@ -0,0 +1,56 @@ +// Command setup installs sherlock and its MCP binaries. It is the +// bootstrap entry point — run it from a clone of the repo: +// +// git clone https://gitea.alexandru.macocian.me/amacocian/sherlock.git +// cd sherlock +// go run ./setup +// +// It clones (or updates) the source under ${XDG_CACHE_HOME:-~/.cache}/ +// sherlock/src, checks out the latest release tag, seeds your config +// (opening $EDITOR), then builds and installs every binary with the +// release version baked in, plus shell completions. The same logic +// backs `sherlock update`; there is no duplicate install path. +// +// Flags: +// +// -y, --yes skip the editor step (config already filled in) +// --config-only set up/edit the config but do not build/install +// --local build from THIS checkout instead of the cache clone +// -h, --help show this help +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/signal" + "syscall" + + "gitea.alexandru.macocian.me/amacocian/sherlock/internal/installer" +) + +func main() { + yes := flag.Bool("y", false, "skip the editor step (config already filled in)") + flag.BoolVar(yes, "yes", false, "skip the editor step (config already filled in)") + configOnly := flag.Bool("config-only", false, "set up/edit the config but do not build/install") + local := flag.Bool("local", false, "build from this checkout instead of cloning to the cache") + flag.Parse() + + wd, _ := os.Getwd() + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + err := installer.Install(ctx, installer.Options{ + Local: *local, + LocalDir: wd, + SeedConfig: true, + OpenEditor: !*yes, + ConfigOnly: *configOnly, + }) + if err != nil { + fmt.Fprintln(os.Stderr, "setup:", err) + os.Exit(1) + } +}