@@ -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 ./...
|
||||
+107
-23
@@ -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
|
||||
# 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.
|
||||
#
|
||||
# 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 will be used instead.
|
||||
#
|
||||
# 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.
|
||||
# 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."
|
||||
|
||||
+16
-72
@@ -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()
|
||||
}
|
||||
|
||||
+5
-3
@@ -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
|
||||
|
||||
+21
-11
@@ -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
|
||||
|
||||
+17
-16
@@ -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=<v>"`. 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=<tag>" ./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=<tag>" ./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. |
|
||||
|
||||
+16
-177
@@ -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/<dir> 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 "$@"
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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/<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"}
|
||||
|
||||
// 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()
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user