This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
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.
|
||||
#
|
||||
# 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.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
runs-on: self-hosted
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG_PAT: ${{ secrets.RELEASE_TAG_PAT }}
|
||||
steps:
|
||||
- name: Tag release from VERSION
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
# 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.
|
||||
git -c "http.extraheader=Authorization: Basic ${auth}" \
|
||||
fetch --quiet --tags origin || true
|
||||
|
||||
version="$(tr -d ' \t\r\n' < VERSION)"
|
||||
if [ -z "$version" ]; then
|
||||
echo "VERSION file is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
tag="v${version}"
|
||||
|
||||
if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
|
||||
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."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Creating tag ${tag} at ${GITHUB_SHA}"
|
||||
git config user.name "sherlock-release"
|
||||
git config user.email "release@sherlock.local"
|
||||
git tag -a "${tag}" -m "Release ${tag}"
|
||||
git -c "http.extraheader=Authorization: Basic ${auth}" \
|
||||
push origin "refs/tags/${tag}"
|
||||
echo "Pushed ${tag}."
|
||||
@@ -7,6 +7,7 @@ Per-operator credential wallet + agent-CLI wrapper for the Charlie homelab. Hold
|
||||
- [Installation](docs/installation.md)
|
||||
- [Architecture](docs/architecture.md)
|
||||
- [Configuration](docs/configuration.md)
|
||||
- [Versioning & self-update](docs/versioning.md)
|
||||
- [Auth model](docs/auth-model.md)
|
||||
- [Storage & keyring](docs/storage.md)
|
||||
- [Agents](docs/agents.md)
|
||||
|
||||
@@ -33,12 +33,12 @@ import (
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/agent"
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/authn"
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/browser"
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/config"
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/xdg"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/agent"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/authn"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/browser"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/config"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/xdg"
|
||||
)
|
||||
|
||||
// serviceName is the wallet key under which gitea tokens live, and the
|
||||
|
||||
+13
-13
@@ -208,17 +208,17 @@ func getTreeHandler(api *giteaAPI) mcp.ToolHandlerFor[getTreeInput, getTreeOutpu
|
||||
|
||||
// isHexSHA reports whether s looks like a 7-40 char hex git SHA.
|
||||
func isHexSHA(s string) bool {
|
||||
if len(s) < 7 || len(s) > 40 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
case r >= 'a' && r <= 'f':
|
||||
case r >= 'A' && r <= 'F':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
if len(s) < 7 || len(s) > 40 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
case r >= 'a' && r <= 'f':
|
||||
case r >= 'A' && r <= 'F':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -46,12 +46,12 @@ import (
|
||||
"github.com/grafana/mcp-grafana/tools"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/agent"
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/authn"
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/browser"
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/config"
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/xdg"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/agent"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/authn"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/browser"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/config"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/xdg"
|
||||
)
|
||||
|
||||
// serviceName is the wallet key under which grafana tokens live, and
|
||||
@@ -244,7 +244,7 @@ func probeGrafana(ctx context.Context, baseURL, token string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
|
||||
@@ -32,12 +32,12 @@ import (
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/agent"
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/authn"
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/browser"
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/config"
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/xdg"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/agent"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/authn"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/browser"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/config"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/xdg"
|
||||
)
|
||||
|
||||
// serviceName is the wallet key under which gssh tokens live, and the
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/coder/websocket"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/wsdatagram"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/wsdatagram"
|
||||
)
|
||||
|
||||
// hardClientTimeout caps the entire run_command tool call. The server
|
||||
@@ -108,7 +108,7 @@ func execOverWebSocket(parent context.Context, api *gsshAPI, host, command strin
|
||||
// Per coder/websocket convention, always Close to release the
|
||||
// goroutine. StatusNormalClosure is the polite signal back to the
|
||||
// server even on errors; the server already finalised by then.
|
||||
defer conn.Close(websocket.StatusNormalClosure, "")
|
||||
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
|
||||
|
||||
// Lift the per-message size limit. The default 32 KiB is enough
|
||||
// for one datagram (2 KiB) but the lift is cheap defensive code
|
||||
|
||||
+17
-4
@@ -17,9 +17,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/agent"
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/mcp"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/agent"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp"
|
||||
)
|
||||
|
||||
// Version is overwritten at build time via -ldflags "-X main.Version=...".
|
||||
@@ -41,7 +41,7 @@ func main() {
|
||||
|
||||
switch sub {
|
||||
case "version", "--version", "-v":
|
||||
fmt.Println(Version)
|
||||
runVersion()
|
||||
return
|
||||
case "help", "--help", "-h":
|
||||
usage(os.Stdout)
|
||||
@@ -63,6 +63,8 @@ func main() {
|
||||
runStatus(store, rest)
|
||||
case "logout":
|
||||
runLogout(store, rest)
|
||||
case "update":
|
||||
runUpdate(rest)
|
||||
case "run":
|
||||
if len(rest) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "sherlock: run requires an agent name")
|
||||
@@ -80,6 +82,13 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// runVersion prints the running version and, for release builds, does a
|
||||
// bounded best-effort check for a newer one.
|
||||
func runVersion() {
|
||||
fmt.Println(Version)
|
||||
notifyUpdateAvailable()
|
||||
}
|
||||
|
||||
func usage(w *os.File) {
|
||||
_, _ = fmt.Fprintf(w, `sherlock - Charlie credential broker + agent wrapper
|
||||
|
||||
@@ -91,6 +100,7 @@ Subcommands:
|
||||
logout [<service>] Forget all stored tokens, or just one service's.
|
||||
run <agent> [args...] Spawn an agent.
|
||||
<agent> [args...] Alias for `+"`run <agent> ...`"+`. Known agents: %s.
|
||||
update [--force] Update sherlock + MCPs to the latest release.
|
||||
version Print the sherlock version and exit.
|
||||
|
||||
Authentication is not a user-initiated step. The first time an MCP
|
||||
@@ -189,6 +199,9 @@ func runAgent(name string, userArgs []string) {
|
||||
fmt.Fprintln(os.Stderr, "sherlock:", err)
|
||||
os.Exit(exitGeneric)
|
||||
}
|
||||
// Best-effort, bounded update hint before we hand off to the agent.
|
||||
// Spawn replaces this process, so the check has to happen here.
|
||||
notifyUpdateAvailable()
|
||||
if err := a.Spawn(agent.Context{Servers: servers}, userArgs); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "sherlock:", err)
|
||||
os.Exit(exitGeneric)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"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
|
||||
// never meaningfully delays a command. On the homelab LAN the Gitea
|
||||
// tags API answers in well under this; if it doesn't (offline, slow),
|
||||
// the hint is silently skipped.
|
||||
const updateCheckTimeout = 1500 * time.Millisecond
|
||||
|
||||
// notifyUpdateAvailable runs a bounded, best-effort check and prints a
|
||||
// one-line hint to stderr if a newer release exists. It never blocks
|
||||
// for longer than updateCheckTimeout and never fails the caller. No-op
|
||||
// for dev builds or when $SHERLOCK_NO_UPDATE_CHECK is set.
|
||||
func notifyUpdateAvailable() {
|
||||
if os.Getenv(selfupdate.EnvDisable) != "" {
|
||||
return
|
||||
}
|
||||
if Version == selfupdate.DevVersion {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), updateCheckTimeout)
|
||||
defer cancel()
|
||||
res, err := selfupdate.CheckForUpdate(ctx, Version, nil)
|
||||
if err != nil || !res.Available {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"sherlock: a newer version is available (%s → %s). Run `sherlock update`.\n",
|
||||
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.
|
||||
func runUpdate(args []string) {
|
||||
force := false
|
||||
for _, a := range args {
|
||||
switch a {
|
||||
case "-f", "--force":
|
||||
force = true
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "sherlock update: unknown flag %q\n", a)
|
||||
os.Exit(exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "sherlock update: checking for latest release:", err)
|
||||
os.Exit(exitGeneric)
|
||||
}
|
||||
if latest == "" {
|
||||
fmt.Fprintln(os.Stderr, "sherlock update: the repository has no published version tags yet.")
|
||||
os.Exit(exitGeneric)
|
||||
}
|
||||
|
||||
res, _ := selfupdate.CheckForUpdate(context.Background(), Version, nil)
|
||||
if !res.Available && !force {
|
||||
if Version == selfupdate.DevVersion {
|
||||
fmt.Printf("Running a dev build; latest release is %s. Use `sherlock update --force` to install it.\n", latest)
|
||||
} else {
|
||||
fmt.Printf("Already up to date (%s).\n", latest)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
src, err := updateSourceCheckout(latest)
|
||||
if 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()
|
||||
}
|
||||
+1
-1
@@ -46,7 +46,7 @@ Drop a new file in `internal/agent/`:
|
||||
// internal/agent/aider.go
|
||||
package agent
|
||||
|
||||
import "gitea.alexandru.macocian.me/Charlie/sherlock/internal/mcp"
|
||||
import "gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp"
|
||||
|
||||
func init() { Register(&aider{}) }
|
||||
|
||||
|
||||
+9
-5
@@ -16,16 +16,19 @@ No top-level `pkg/` until we have an external consumer.
|
||||
|
||||
## Go
|
||||
|
||||
- Module path: `gitea.alexandru.macocian.me/Charlie/sherlock`.
|
||||
- Module path: `gitea.alexandru.macocian.me/amacocian/sherlock`.
|
||||
- Target Go toolchain: `go 1.25` (pinned via `go.mod`'s `go` directive; CI installs the matching minor).
|
||||
- One `package main` per `cmd/<binary>/`. No multiple-`main`-files trickery.
|
||||
- Every other package has a top-of-file `Package <name> ...` doc comment (in the leading `.go` source file; we drop standalone `doc.go` files once a real source file exists).
|
||||
- Third-party deps are added with a one-line justification. The current set:
|
||||
- `github.com/zalando/go-keyring` — OS keyring for token persistence (Decision #8).
|
||||
- `github.com/BurntSushi/toml` — parse `services.d/*.toml` (Phase 2+).
|
||||
- `github.com/zalando/go-keyring` — OS keyring for token persistence.
|
||||
- `github.com/BurntSushi/toml` — parse the operator config (`config.toml`).
|
||||
- `github.com/coreos/go-oidc/v3` — OIDC discovery + ID-token verification against Authentik.
|
||||
- `golang.org/x/oauth2` — auth-code + PKCE + refresh against Authentik's token endpoint.
|
||||
- `golang.org/x/sync` — `singleflight` for collapsing concurrent token refreshes.
|
||||
- `golang.org/x/mod` — `semver` for version comparison in self-update.
|
||||
- `github.com/grafana/mcp-grafana` + `github.com/mark3labs/mcp-go` — upstream Grafana MCP tool set, served in-process by `cmd/grafana-mcp`.
|
||||
- `github.com/modelcontextprotocol/go-sdk` — MCP server SDK for the gitea/gssh MCPs.
|
||||
- `github.com/coder/websocket` — gssh exec WebSocket client.
|
||||
|
||||
## Docs
|
||||
|
||||
@@ -54,7 +57,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.
|
||||
- No deploy pipeline — sherlock is operator-installed via `go install`. Release artifacts (binaries to the gitea registry / releases) land in Phase 5 if at all.
|
||||
- `.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).
|
||||
- Sherlock is operator-installed via `install.sh` (which clones + `go install`s) and self-updates via `sherlock update`. No host-deploy pipeline.
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
+26
-7
@@ -5,25 +5,30 @@ Sherlock installs from a clone of this repo with a single script.
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone https://gitea.alexandru.macocian.me/Charlie/sherlock.git
|
||||
git clone https://gitea.alexandru.macocian.me/amacocian/sherlock.git
|
||||
cd sherlock
|
||||
./install.sh
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
1. **Seeds the config.** Copies [`config.example.toml`](../config.example.toml)
|
||||
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.)
|
||||
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
|
||||
values.
|
||||
2. **Opens it in your editor** (`$VISUAL` / `$EDITOR`, falling back to
|
||||
3. **Opens it in your editor** (`$VISUAL` / `$EDITOR`, falling back to
|
||||
`nano`/`vi`) so you can fill in the Authentik issuer/client IDs and
|
||||
each service's base URL. See [configuration.md](configuration.md) for
|
||||
the schema.
|
||||
3. **Builds and installs** `sherlock` and every MCP binary
|
||||
(`gitea-mcp`, `grafana-mcp`, `gssh-mcp`) with `go install ./cmd/...`
|
||||
once you save and close the editor.
|
||||
4. **Installs shell completions** where it can — currently a `fish`
|
||||
4. **Builds and installs** `sherlock` and every MCP binary
|
||||
(`gitea-mcp`, `grafana-mcp`, `gssh-mcp`) with `go install ./cmd/...`,
|
||||
baking in the release version.
|
||||
5. **Installs shell completions** where it can — currently a `fish`
|
||||
completion into `~/.config/fish/completions/sherlock.fish`, but only
|
||||
if you already have a fish config directory (it won't create one for
|
||||
non-fish users).
|
||||
@@ -51,6 +56,7 @@ sherlock copilot
|
||||
|---|---|
|
||||
| `-y`, `--yes` | Skip the editor step (config is already filled in). |
|
||||
| `--config-only` | Seed/edit the config but don't build or install. |
|
||||
| `--local` | Build from the current checkout instead of cloning to the cache (development). |
|
||||
| `-h`, `--help` | Show usage. |
|
||||
|
||||
## Env overrides
|
||||
@@ -58,8 +64,21 @@ sherlock copilot
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `SHERLOCK_CONFIG` | Exact config path to write (else `$XDG_CONFIG_HOME` / `~/.config`). |
|
||||
| `SHERLOCK_UPDATE_REPO_URL` | Clone URL to build from (else the public sherlock repo). |
|
||||
| `VISUAL` / `EDITOR` | Editor to open. |
|
||||
|
||||
## Updating
|
||||
|
||||
Sherlock updates itself in place:
|
||||
|
||||
```bash
|
||||
sherlock update # if a newer release exists
|
||||
sherlock update --force # reinstall the latest regardless
|
||||
```
|
||||
|
||||
Release builds also print a one-line hint when a newer version is
|
||||
available. See [versioning.md](versioning.md).
|
||||
|
||||
## Re-running
|
||||
|
||||
`./install.sh` is safe to re-run: it edits your existing config in place
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Versioning & self-update
|
||||
|
||||
Sherlock is versioned by git tags on the public repo and can update
|
||||
itself in place.
|
||||
|
||||
## Version scheme
|
||||
|
||||
- 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.
|
||||
- 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".
|
||||
|
||||
## How a build gets its version
|
||||
|
||||
| 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 |
|
||||
| `sherlock update` | the latest published tag |
|
||||
| plain `go install ./cmd/...` | `0.0.0-dev` (no `-ldflags`) |
|
||||
|
||||
## Update checking
|
||||
|
||||
`internal/selfupdate` reads the repo's tags through Gitea's public REST
|
||||
API (no auth) and compares the highest `vX.Y.Z` against the running
|
||||
binary using `golang.org/x/mod/semver`.
|
||||
|
||||
- **`sherlock version`** prints the running version and, for release
|
||||
builds, appends a one-line hint if a newer tag exists.
|
||||
- **Agent launches** (`sherlock copilot`, …) do a bounded, best-effort
|
||||
check just before handing off to the agent and print the same hint.
|
||||
It never blocks for more than ~1.5s and is silent on dev builds,
|
||||
offline, or when `SHERLOCK_NO_UPDATE_CHECK` is set.
|
||||
|
||||
The check is intentionally **uncached** — it runs each time — but is
|
||||
always bounded and non-fatal, so it can't break or noticeably delay a
|
||||
command.
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
sherlock update # update to the latest release if newer
|
||||
sherlock update --force # reinstall the latest even if not newer
|
||||
```
|
||||
|
||||
`update`:
|
||||
|
||||
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.
|
||||
|
||||
## Environment overrides
|
||||
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `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`. |
|
||||
@@ -1,8 +1,9 @@
|
||||
module gitea.alexandru.macocian.me/Charlie/sherlock
|
||||
module gitea.alexandru.macocian.me/amacocian/sherlock
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/go-jose/go-jose/v4 v4.1.4
|
||||
@@ -10,12 +11,12 @@ require (
|
||||
github.com/mark3labs/mcp-go v0.46.0
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/mod v0.35.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
connectrpc.com/connect v1.19.1 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/PaesslerAG/gval v1.2.4 // indirect
|
||||
github.com/PaesslerAG/jsonpath v0.1.1 // indirect
|
||||
github.com/apache/arrow-go/v18 v18.5.1 // indirect
|
||||
@@ -157,7 +158,6 @@ require (
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
|
||||
+65
-23
@@ -2,42 +2,50 @@
|
||||
#
|
||||
# sherlock installer.
|
||||
#
|
||||
# Run from a clone of this repo:
|
||||
#
|
||||
# ./install.sh
|
||||
#
|
||||
# It:
|
||||
# 1. copies config.example.toml to your sherlock config path
|
||||
# 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,
|
||||
# 2. opens it in your editor so you can fill in your deployment values,
|
||||
# 3. on save, `go install`s sherlock and every MCP binary.
|
||||
# 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.
|
||||
#
|
||||
# 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 to write (else XDG / ~/.config)
|
||||
# VISUAL / EDITOR editor to open (falls back to nano, then vi)
|
||||
# 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)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
EXAMPLE="$SCRIPT_DIR/config.example.toml"
|
||||
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,25p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
sed -n '3,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
@@ -45,13 +53,52 @@ 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
|
||||
|
||||
# ── Resolve the config path (mirrors internal/config.DefaultPath) ─────
|
||||
# ── 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"
|
||||
@@ -61,12 +108,9 @@ config_path() {
|
||||
printf '%s/.config/sherlock/config.toml\n' "$HOME"
|
||||
fi
|
||||
}
|
||||
|
||||
CONFIG="$(config_path)"
|
||||
|
||||
# ── 1. Seed the config from the example (never clobber a filled one) ──
|
||||
[ -f "$EXAMPLE" ] || die "config.example.toml not found next to this script ($EXAMPLE). Run install.sh from inside the repo."
|
||||
|
||||
# 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
|
||||
@@ -76,7 +120,7 @@ else
|
||||
chmod 600 "$CONFIG"
|
||||
fi
|
||||
|
||||
# ── 2. Open it in the editor and wait for the user to finish ─────────
|
||||
# ── 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
|
||||
@@ -106,28 +150,26 @@ if [ "$CONFIG_ONLY" -eq 1 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 3. Build + install every binary from this clone ──────────────────
|
||||
command -v go >/dev/null 2>&1 || die "the Go toolchain is required but 'go' was not found on PATH. Install Go, then re-run."
|
||||
|
||||
info "Installing binaries: ${BINARIES[*]}"
|
||||
# ── 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 "$SCRIPT_DIR" && go install "${pkgs[@]}" )
|
||||
( cd "$SOURCE" && go install -ldflags "-X main.Version=$VERSION" "${pkgs[@]}" )
|
||||
|
||||
# ── 3b. Install the fish completion if fish is configured ────────────
|
||||
# ── 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="$SCRIPT_DIR/completions/sherlock.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
|
||||
|
||||
# ── 4. Report where they landed ──────────────────────────────────────
|
||||
# ── 5. Report where they landed ──────────────────────────────────────
|
||||
bindir="$(go env GOBIN)"
|
||||
[ -n "$bindir" ] || bindir="$(go env GOPATH)/bin"
|
||||
info "Installed to $bindir"
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/mcp"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp"
|
||||
)
|
||||
|
||||
// Context is everything the CLI hands an agent at spawn time. Kept
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/mcp"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp"
|
||||
)
|
||||
|
||||
func init() { Register(&claude{}) }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/mcp"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp"
|
||||
)
|
||||
|
||||
func init() { Register(&copilot{}) }
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
|
||||
)
|
||||
|
||||
// EnsureOptions tunes Ensure for tests and for MCPs that want to
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
|
||||
fakekeyring "gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring/fake"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
|
||||
fakekeyring "gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring/fake"
|
||||
)
|
||||
|
||||
func TestEnsure_FastPath_NoOAuthFlow(t *testing.T) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
|
||||
)
|
||||
|
||||
// ErrNotConfigured is returned by Login when Config.Configured() is
|
||||
|
||||
@@ -22,8 +22,8 @@ import (
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
|
||||
fakekeyring "gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring/fake"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
|
||||
fakekeyring "gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring/fake"
|
||||
)
|
||||
|
||||
// stubAuthentik signs ES256 ID tokens and returns them through a real
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
|
||||
)
|
||||
|
||||
// RefreshSkew is how much headroom EnsureFresh leaves before the ID
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
|
||||
)
|
||||
|
||||
// minRenewInterval bounds how often Run attempts a renewal, even when
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
|
||||
fakekeyring "gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring/fake"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
|
||||
fakekeyring "gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring/fake"
|
||||
)
|
||||
|
||||
func TestNewTokenSource_RequiresCallback(t *testing.T) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
|
||||
)
|
||||
|
||||
// New returns a fresh in-memory Store.
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
|
||||
)
|
||||
|
||||
func TestFake_RoundTrip(t *testing.T) {
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/xdg"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/xdg"
|
||||
)
|
||||
|
||||
// Server is one stdio MCP server entry.
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Package selfupdate checks whether a newer sherlock release is
|
||||
// available and reports it. The source of truth is the git tags on the
|
||||
// public sherlock repo, read through Gitea's REST API (no auth — the
|
||||
// repo is public).
|
||||
//
|
||||
// Versions are git tags of the form `vMAJOR.MINOR.PATCH`. The binary's
|
||||
// own version is baked in at build time via -ldflags "-X main.Version".
|
||||
// A build that still reads the sentinel dev version ("0.0.0-dev") is
|
||||
// treated as "not a release" and never nags about updates.
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// DevVersion is the sentinel main.Version of an unversioned local
|
||||
// build. CheckForUpdate is a no-op for it.
|
||||
const DevVersion = "0.0.0-dev"
|
||||
|
||||
// defaultTagsAPI is the Gitea tags endpoint for the public sherlock
|
||||
// repo. Overridable via $SHERLOCK_UPDATE_TAGS_URL for forks/tests.
|
||||
const defaultTagsAPI = "https://gitea.alexandru.macocian.me/api/v1/repos/amacocian/sherlock/tags"
|
||||
|
||||
// DefaultRepoURL is the public clone URL the self-updater builds from.
|
||||
// Overridable via $SHERLOCK_UPDATE_REPO_URL.
|
||||
const DefaultRepoURL = "https://gitea.alexandru.macocian.me/amacocian/sherlock"
|
||||
|
||||
// EnvRepoURL overrides the clone URL (forks, tests, mirrors).
|
||||
const EnvRepoURL = "SHERLOCK_UPDATE_REPO_URL"
|
||||
|
||||
// RepoURL returns the configured clone URL.
|
||||
func RepoURL() string {
|
||||
if u := os.Getenv(EnvRepoURL); u != "" {
|
||||
return u
|
||||
}
|
||||
return DefaultRepoURL
|
||||
}
|
||||
|
||||
// EnvTagsURL overrides the tags API URL (forks, tests, air-gapped).
|
||||
const EnvTagsURL = "SHERLOCK_UPDATE_TAGS_URL"
|
||||
|
||||
// EnvDisable, when set to a non-empty value, disables the passive
|
||||
// update check entirely.
|
||||
const EnvDisable = "SHERLOCK_NO_UPDATE_CHECK"
|
||||
|
||||
// tagsURL returns the configured tags endpoint.
|
||||
func tagsURL() string {
|
||||
if u := os.Getenv(EnvTagsURL); u != "" {
|
||||
return u
|
||||
}
|
||||
return defaultTagsAPI
|
||||
}
|
||||
|
||||
// normalize coerces a raw version string into the canonical, comparable
|
||||
// `vX.Y.Z` form semver expects. Accepts both "0.1.0" and "v0.1.0".
|
||||
// Returns "" if the input isn't valid semver.
|
||||
func normalize(v string) string {
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
if v[0] != 'v' {
|
||||
v = "v" + v
|
||||
}
|
||||
if !semver.IsValid(v) {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
type tag struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// LatestTag fetches the highest semver tag from the repo. It returns
|
||||
// the canonical `vX.Y.Z` string, or "" with a nil error when the repo
|
||||
// has no valid version tags yet.
|
||||
func LatestTag(ctx context.Context, client *http.Client) (string, error) {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 5 * time.Second}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tagsURL(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("selfupdate: tags API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var tags []tag
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil {
|
||||
return "", fmt.Errorf("selfupdate: decode tags: %w", err)
|
||||
}
|
||||
latest := ""
|
||||
for _, t := range tags {
|
||||
v := normalize(t.Name)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if latest == "" || semver.Compare(v, latest) > 0 {
|
||||
latest = v
|
||||
}
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
// Result is the outcome of a CheckForUpdate call.
|
||||
type Result struct {
|
||||
Current string // the running binary's version, normalized (e.g. "v0.1.0")
|
||||
Latest string // the latest published tag, normalized (e.g. "v0.2.0")
|
||||
Available bool // true iff Latest is a strictly newer valid release
|
||||
}
|
||||
|
||||
// CheckForUpdate compares the running version against the latest tag.
|
||||
// It never errors out the caller's flow for "expected" cases (dev
|
||||
// build, no tags, network down): those yield Available=false. A non-nil
|
||||
// error is returned only for genuinely unexpected failures the caller
|
||||
// may want to surface in an explicit `sherlock update`.
|
||||
func CheckForUpdate(ctx context.Context, current string, client *http.Client) (Result, error) {
|
||||
res := Result{Current: normalize(current)}
|
||||
if current == DevVersion || res.Current == "" {
|
||||
// Unversioned/dev build — don't nag.
|
||||
return res, nil
|
||||
}
|
||||
latest, err := LatestTag(ctx, client)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Latest = latest
|
||||
if latest == "" {
|
||||
return res, nil
|
||||
}
|
||||
res.Available = semver.Compare(latest, res.Current) > 0
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func tagsServer(t *testing.T, body string, status int) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestLatestTag_PicksHighestSemver(t *testing.T) {
|
||||
srv := tagsServer(t, `[{"name":"v0.1.0"},{"name":"v0.10.0"},{"name":"v0.2.0"},{"name":"not-a-tag"}]`, 200)
|
||||
t.Setenv(EnvTagsURL, srv.URL)
|
||||
got, err := LatestTag(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("LatestTag: %v", err)
|
||||
}
|
||||
if got != "v0.10.0" {
|
||||
t.Fatalf("latest = %q, want v0.10.0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestTag_NoValidTags(t *testing.T) {
|
||||
srv := tagsServer(t, `[{"name":"nightly"},{"name":"latest"}]`, 200)
|
||||
t.Setenv(EnvTagsURL, srv.URL)
|
||||
got, err := LatestTag(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("LatestTag: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("latest = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckForUpdate_Available(t *testing.T) {
|
||||
srv := tagsServer(t, `[{"name":"v0.2.0"}]`, 200)
|
||||
t.Setenv(EnvTagsURL, srv.URL)
|
||||
res, err := CheckForUpdate(context.Background(), "v0.1.0", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckForUpdate: %v", err)
|
||||
}
|
||||
if !res.Available {
|
||||
t.Fatalf("expected update available, got %+v", res)
|
||||
}
|
||||
if res.Latest != "v0.2.0" || res.Current != "v0.1.0" {
|
||||
t.Fatalf("res = %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckForUpdate_UpToDate(t *testing.T) {
|
||||
srv := tagsServer(t, `[{"name":"v0.2.0"}]`, 200)
|
||||
t.Setenv(EnvTagsURL, srv.URL)
|
||||
res, err := CheckForUpdate(context.Background(), "0.2.0", nil) // bare version accepted
|
||||
if err != nil {
|
||||
t.Fatalf("CheckForUpdate: %v", err)
|
||||
}
|
||||
if res.Available {
|
||||
t.Fatalf("expected up to date, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckForUpdate_NewerThanRemote(t *testing.T) {
|
||||
srv := tagsServer(t, `[{"name":"v0.1.0"}]`, 200)
|
||||
t.Setenv(EnvTagsURL, srv.URL)
|
||||
res, err := CheckForUpdate(context.Background(), "v0.2.0", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckForUpdate: %v", err)
|
||||
}
|
||||
if res.Available {
|
||||
t.Fatalf("local ahead of remote should not report available, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckForUpdate_DevBuildSkips(t *testing.T) {
|
||||
// No server hit expected; point at an unreachable URL to prove it's
|
||||
// never called for a dev build.
|
||||
t.Setenv(EnvTagsURL, "http://127.0.0.1:1/never")
|
||||
res, err := CheckForUpdate(context.Background(), DevVersion, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dev build should not error: %v", err)
|
||||
}
|
||||
if res.Available {
|
||||
t.Fatal("dev build should never report an update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"0.1.0": "v0.1.0",
|
||||
"v0.1.0": "v0.1.0",
|
||||
"": "",
|
||||
"garbage": "",
|
||||
"v1": "v1", // semver.IsValid accepts v1
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalize(in); got != want {
|
||||
t.Errorf("normalize(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,20 @@ func ConfigHome() (string, error) {
|
||||
return ensureDir(filepath.Join(home, ".config"), 0o700)
|
||||
}
|
||||
|
||||
// CacheHome returns $XDG_CACHE_HOME, falling back to $HOME/.cache. The
|
||||
// path is created if missing. Used for the self-update source checkout
|
||||
// under <CacheHome>/sherlock.
|
||||
func CacheHome() (string, error) {
|
||||
if d := os.Getenv("XDG_CACHE_HOME"); d != "" {
|
||||
return ensureDir(d, 0o700)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("xdg: resolve home: %w", err)
|
||||
}
|
||||
return ensureDir(filepath.Join(home, ".cache"), 0o700)
|
||||
}
|
||||
|
||||
// RefreshLockPath returns the canonical flock path used by EnsureFresh
|
||||
// to serialise concurrent Authentik refresh attempts across sherlock
|
||||
// invocations.
|
||||
|
||||
Reference in New Issue
Block a user