Shared infra for installer
Release / release (push) Successful in 24s

This commit is contained in:
2026-06-13 14:25:40 +02:00
parent 1c2108dd2b
commit 53b0d025f5
12 changed files with 733 additions and 441 deletions
-136
View File
@@ -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 ./...
+109 -25
View File
@@ -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."
+1 -1
View File
@@ -1 +1 @@
0.1.1
0.1.2
+16 -72
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 "$@"
+171
View File
@@ -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))
}
+226
View File
@@ -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()
}
+95
View File
@@ -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 }
+56
View File
@@ -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)
}
}