diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..fed0b61 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -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}." diff --git a/README.md b/README.md index a354b9e..4a08269 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..17e51c3 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.1 diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go index 67fb266..fd0d135 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -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 diff --git a/cmd/gitea-mcp/tools_tree.go b/cmd/gitea-mcp/tools_tree.go index 266c0d9..48eed77 100644 --- a/cmd/gitea-mcp/tools_tree.go +++ b/cmd/gitea-mcp/tools_tree.go @@ -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 } diff --git a/cmd/grafana-mcp/main.go b/cmd/grafana-mcp/main.go index 49e48ef..00392ab 100644 --- a/cmd/grafana-mcp/main.go +++ b/cmd/grafana-mcp/main.go @@ -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)) diff --git a/cmd/gssh-mcp/main.go b/cmd/gssh-mcp/main.go index 733a587..42784f3 100644 --- a/cmd/gssh-mcp/main.go +++ b/cmd/gssh-mcp/main.go @@ -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 diff --git a/cmd/gssh-mcp/tools_ssh.go b/cmd/gssh-mcp/tools_ssh.go index 9a42307..dc7fc72 100644 --- a/cmd/gssh-mcp/tools_ssh.go +++ b/cmd/gssh-mcp/tools_ssh.go @@ -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 diff --git a/cmd/sherlock/main.go b/cmd/sherlock/main.go index 5ae5ebb..db15fe5 100644 --- a/cmd/sherlock/main.go +++ b/cmd/sherlock/main.go @@ -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 [] Forget all stored tokens, or just one service's. run [args...] Spawn an agent. [args...] Alias for `+"`run ...`"+`. 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) diff --git a/cmd/sherlock/update.go b/cmd/sherlock/update.go new file mode 100644 index 0000000..11350db --- /dev/null +++ b/cmd/sherlock/update.go @@ -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() +} diff --git a/docs/agents.md b/docs/agents.md index 0a0e103..b988674 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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{}) } diff --git a/docs/conventions.md b/docs/conventions.md index 0a3edf5..aece01d 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -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//`. No multiple-`main`-files trickery. - Every other package has a top-of-file `Package ...` 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 diff --git a/docs/installation.md b/docs/installation.md index da2e272..2b19d9d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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 diff --git a/docs/versioning.md b/docs/versioning.md new file mode 100644 index 0000000..5a46f30 --- /dev/null +++ b/docs/versioning.md @@ -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="`. 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=" ./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`. | diff --git a/gitea-mcp b/gitea-mcp new file mode 100755 index 0000000..9d1da6d Binary files /dev/null and b/gitea-mcp differ diff --git a/go.mod b/go.mod index 3972d20..63c2c75 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/install.sh b/install.sh index 24d7122..d25b051 100755 --- a/install.sh +++ b/install.sh @@ -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/ 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" diff --git a/internal/agent/agent.go b/internal/agent/agent.go index f956537..e3f2adb 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -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 diff --git a/internal/agent/claude.go b/internal/agent/claude.go index 78583c6..52c338d 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -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{}) } diff --git a/internal/agent/copilot.go b/internal/agent/copilot.go index 7e32eb9..57c5894 100644 --- a/internal/agent/copilot.go +++ b/internal/agent/copilot.go @@ -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{}) } diff --git a/internal/authn/ensure.go b/internal/authn/ensure.go index 23c058e..d30f3d7 100644 --- a/internal/authn/ensure.go +++ b/internal/authn/ensure.go @@ -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 diff --git a/internal/authn/ensure_test.go b/internal/authn/ensure_test.go index 5122eb4..950f7f6 100644 --- a/internal/authn/ensure_test.go +++ b/internal/authn/ensure_test.go @@ -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) { diff --git a/internal/authn/login.go b/internal/authn/login.go index dfc3914..6ec7389 100644 --- a/internal/authn/login.go +++ b/internal/authn/login.go @@ -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 diff --git a/internal/authn/login_test.go b/internal/authn/login_test.go index c37317a..abc70ad 100644 --- a/internal/authn/login_test.go +++ b/internal/authn/login_test.go @@ -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 diff --git a/internal/authn/refresh.go b/internal/authn/refresh.go index 9dc9d4b..a4271ef 100644 --- a/internal/authn/refresh.go +++ b/internal/authn/refresh.go @@ -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 diff --git a/internal/authn/source.go b/internal/authn/source.go index a25645e..727cde8 100644 --- a/internal/authn/source.go +++ b/internal/authn/source.go @@ -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 diff --git a/internal/authn/source_test.go b/internal/authn/source_test.go index 2516c27..fc85126 100644 --- a/internal/authn/source_test.go +++ b/internal/authn/source_test.go @@ -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) { diff --git a/internal/keyring/fake/fake.go b/internal/keyring/fake/fake.go index 916013a..4d332dc 100644 --- a/internal/keyring/fake/fake.go +++ b/internal/keyring/fake/fake.go @@ -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. diff --git a/internal/keyring/fake/fake_test.go b/internal/keyring/fake/fake_test.go index 6ed8edf..db06059 100644 --- a/internal/keyring/fake/fake_test.go +++ b/internal/keyring/fake/fake_test.go @@ -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) { diff --git a/internal/mcp/config.go b/internal/mcp/config.go index 68c161d..4eb8f14 100644 --- a/internal/mcp/config.go +++ b/internal/mcp/config.go @@ -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. diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go new file mode 100644 index 0000000..be6fbe4 --- /dev/null +++ b/internal/selfupdate/selfupdate.go @@ -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 +} diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go new file mode 100644 index 0000000..3c442d5 --- /dev/null +++ b/internal/selfupdate/selfupdate_test.go @@ -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) + } + } +} diff --git a/internal/xdg/xdg.go b/internal/xdg/xdg.go index 6ec5d07..fecea5b 100644 --- a/internal/xdg/xdg.go +++ b/internal/xdg/xdg.go @@ -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 /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.