From 2f095e05edec2e63c9ef0551a78563e9ca57ff38 Mon Sep 17 00:00:00 2001 From: Tom Nash Date: Tue, 7 Apr 2026 20:06:37 +1000 Subject: [PATCH 1/2] FEAT: add daily CLI auto-update checks --- README.md | 12 + cli/cmd/root.go | 11 + cli/internal/updatecheck/updatecheck.go | 247 +++++++++++++++++++ cli/internal/updatecheck/updatecheck_test.go | 113 +++++++++ 4 files changed, 383 insertions(+) create mode 100644 cli/internal/updatecheck/updatecheck.go create mode 100644 cli/internal/updatecheck/updatecheck_test.go diff --git a/README.md b/README.md index ad7b99f..e125381 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,18 @@ curl -fsSL https://raw.githubusercontent.com/cordon-co/cordon-cli/main/scripts/i go install github.com/cordon-co/cordon-cli/cmd/cordon@latest ``` +## Auto Update Checks + +When running `cordon` interactively (without `--json` and not in `--mcp` mode), the CLI performs a quick GitHub release check at most once every 24 hours. + +- `~/.cordon/config.json` supports: + - `skip_update_check` (`true`/`false`) to disable daily checks + - `last_update_check` (RFC3339 timestamp), updated automatically after a check attempt +- If a newer release is detected, Cordon prompts: + - `A new version of cordon-cli is available on github, install the update? [Y/n]:` + - `Y` (or Enter) runs the installer script + - `n` prints a reminder about `skip_update_check` + ## Quick Start **1. Initialise Cordon in your repository:** diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 3918920..df8086f 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -9,8 +9,10 @@ import ( "github.com/cordon-co/cordon-cli/cli/cmd/command" "github.com/cordon-co/cordon-cli/cli/cmd/file" "github.com/cordon-co/cordon-cli/cli/cmd/pass" + "github.com/cordon-co/cordon-cli/cli/internal/buildinfo" "github.com/cordon-co/cordon-cli/cli/internal/flags" "github.com/cordon-co/cordon-cli/cli/internal/mcpserver" + "github.com/cordon-co/cordon-cli/cli/internal/updatecheck" "github.com/spf13/cobra" ) @@ -34,6 +36,15 @@ temporary access; the audit log captures every enforcement decision.`, } return cmd.Help() }, + PersistentPostRun: func(cmd *cobra.Command, args []string) { + if flags.JSON || mcpMode { + return + } + if cmd.Name() == "hook" { + return + } + updatecheck.MaybeRun(cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), buildinfo.Version) + }, } // Execute is the entry point called from main.go. diff --git a/cli/internal/updatecheck/updatecheck.go b/cli/internal/updatecheck/updatecheck.go new file mode 100644 index 0000000..43dad20 --- /dev/null +++ b/cli/internal/updatecheck/updatecheck.go @@ -0,0 +1,247 @@ +package updatecheck + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "net/url" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + "time" +) + +const ( + latestReleaseURL = "https://github.com/cordon-co/cordon-cli/releases/latest" + installScriptURL = "https://raw.githubusercontent.com/cordon-co/cordon-cli/main/scripts/install.sh" +) + +type config struct { + SkipUpdateCheck bool `json:"skip_update_check"` + LastUpdateCheck string `json:"last_update_check"` +} + +// MaybeRun performs a best-effort daily update check for interactive CLI usage. +// It never returns an error and should not affect command execution. +func MaybeRun(in io.Reader, out io.Writer, errOut io.Writer, currentVersion string) { + if normalizeVersion(currentVersion) == "dev" { + return + } + + if !isInteractive(in, out) { + return + } + + cfgPath, err := configPath() + if err != nil { + return + } + + cfg, raw, err := readConfig(cfgPath) + if err != nil { + return + } + if cfg.SkipUpdateCheck { + return + } + + now := time.Now().UTC() + if checkedWithin24Hours(cfg.LastUpdateCheck, now) { + return + } + + cfg.LastUpdateCheck = now.Format(time.RFC3339) + if err := writeConfig(cfgPath, cfg, raw); err != nil { + return + } + + latest, err := fetchLatestReleaseTag(http.DefaultClient) + if err != nil { + return + } + if !isDifferentVersion(currentVersion, latest) { + return + } + + fmt.Fprintf(out, "A new version of cordon-cli is available on github, install the update? [Y/n]: ") + ok, err := readYesNo(in) + if err != nil { + return + } + if !ok { + fmt.Fprintln(out, `Daily update checks can be disabled by setting "skip_update_check" to true in ~/.cordon/config.json`) + return + } + + if err := runInstaller(in, out, errOut); err != nil { + fmt.Fprintf(errOut, "Failed to install update automatically: %v\n", err) + } +} + +func configPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".cordon", "config.json"), nil +} + +func readConfig(p string) (config, map[string]json.RawMessage, error) { + data, err := os.ReadFile(p) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return config{}, map[string]json.RawMessage{}, nil + } + return config{}, nil, err + } + + raw := map[string]json.RawMessage{} + if err := json.Unmarshal(data, &raw); err != nil { + return config{}, nil, err + } + + var cfg config + if err := json.Unmarshal(data, &cfg); err != nil { + return config{}, nil, err + } + + return cfg, raw, nil +} + +func writeConfig(p string, cfg config, raw map[string]json.RawMessage) error { + if raw == nil { + raw = map[string]json.RawMessage{} + } + + last, err := json.Marshal(cfg.LastUpdateCheck) + if err != nil { + return err + } + raw["last_update_check"] = last + + data, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + return os.WriteFile(p, data, 0o600) +} + +func checkedWithin24Hours(raw string, now time.Time) bool { + if strings.TrimSpace(raw) == "" { + return false + } + last, err := time.Parse(time.RFC3339, raw) + if err != nil { + return false + } + return now.Sub(last) < 24*time.Hour +} + +func fetchLatestReleaseTag(client *http.Client) (string, error) { + return fetchLatestReleaseTagFromURL(client, latestReleaseURL) +} + +func fetchLatestReleaseTagFromURL(client *http.Client, latestURL string) (string, error) { + httpClient := *client + httpClient.Timeout = 2 * time.Second + httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + req, err := http.NewRequest(http.MethodGet, latestURL, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "cordon-cli") + + resp, err := httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode < 300 || resp.StatusCode > 399 { + return "", fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + + loc := resp.Header.Get("Location") + if strings.TrimSpace(loc) == "" { + return "", errors.New("missing redirect location") + } + + u, err := url.Parse(loc) + if err != nil { + return "", err + } + tag := path.Base(strings.TrimSuffix(u.Path, "/")) + if strings.TrimSpace(tag) == "" || tag == "latest" { + return "", errors.New("invalid release tag") + } + return tag, nil +} + +func isDifferentVersion(current string, latest string) bool { + c := normalizeVersion(current) + l := normalizeVersion(latest) + if c == "" || c == "dev" || l == "" { + return false + } + return c != l +} + +func normalizeVersion(v string) string { + v = strings.TrimSpace(strings.ToLower(v)) + v = strings.TrimPrefix(v, "v") + return v +} + +func readYesNo(in io.Reader) (bool, error) { + line, err := bufio.NewReader(in).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return false, err + } + answer := strings.TrimSpace(strings.ToLower(line)) + switch answer { + case "", "y", "yes": + return true, nil + case "n", "no": + return false, nil + default: + return false, nil + } +} + +func runInstaller(in io.Reader, out io.Writer, errOut io.Writer) error { + cmd := exec.Command("sh", "-c", "curl -fsSL "+installScriptURL+" | sh") + cmd.Stdin = in + cmd.Stdout = out + cmd.Stderr = errOut + return cmd.Run() +} + +func isInteractive(in io.Reader, out io.Writer) bool { + inFile, inOK := in.(*os.File) + outFile, outOK := out.(*os.File) + if !inOK || !outOK { + return false + } + inInfo, err := inFile.Stat() + if err != nil { + return false + } + outInfo, err := outFile.Stat() + if err != nil { + return false + } + return (inInfo.Mode()&os.ModeCharDevice) != 0 && (outInfo.Mode()&os.ModeCharDevice) != 0 +} diff --git a/cli/internal/updatecheck/updatecheck_test.go b/cli/internal/updatecheck/updatecheck_test.go new file mode 100644 index 0000000..f78470f --- /dev/null +++ b/cli/internal/updatecheck/updatecheck_test.go @@ -0,0 +1,113 @@ +package updatecheck + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestCheckedWithin24Hours(t *testing.T) { + now := time.Date(2026, 4, 7, 12, 0, 0, 0, time.UTC) + if !checkedWithin24Hours(now.Add(-2*time.Hour).Format(time.RFC3339), now) { + t.Fatalf("expected recent check to be considered within 24h") + } + if checkedWithin24Hours(now.Add(-25*time.Hour).Format(time.RFC3339), now) { + t.Fatalf("expected stale check to be outside 24h") + } + if checkedWithin24Hours("bad", now) { + t.Fatalf("expected invalid timestamp to be treated as stale") + } +} + +func TestIsDifferentVersion(t *testing.T) { + if !isDifferentVersion("v0.1.0", "v0.2.0") { + t.Fatalf("expected differing versions to be detected") + } + if isDifferentVersion("v0.2.0", "0.2.0") { + t.Fatalf("expected equivalent versions to match") + } + if isDifferentVersion("dev", "v0.2.0") { + t.Fatalf("dev builds should skip update prompts") + } +} + +func TestFetchLatestReleaseTagFromRedirect(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/cordon-co/cordon-cli/releases/tag/v1.2.3", http.StatusFound) + })) + defer ts.Close() + + tag, err := fetchLatestReleaseTagFromURL(http.DefaultClient, ts.URL) + if err != nil { + t.Fatalf("fetchLatestReleaseTagFromURL() error = %v", err) + } + if tag != "v1.2.3" { + t.Fatalf("tag = %q, want v1.2.3", tag) + } +} + +func TestWriteConfigPreservesUnknownFields(t *testing.T) { + tmp := t.TempDir() + p := filepath.Join(tmp, "config.json") + data := []byte(`{"api_url":"https://api.cordon.sh","skip_update_check":false}`) + if err := os.WriteFile(p, data, 0o600); err != nil { + t.Fatalf("seed config: %v", err) + } + + cfg, raw, err := readConfig(p) + if err != nil { + t.Fatalf("readConfig: %v", err) + } + cfg.LastUpdateCheck = "2026-04-07T00:00:00Z" + if err := writeConfig(p, cfg, raw); err != nil { + t.Fatalf("writeConfig: %v", err) + } + + out, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read output: %v", err) + } + text := string(out) + if !strings.Contains(text, `"api_url": "https://api.cordon.sh"`) { + t.Fatalf("expected api_url to be preserved, got: %s", text) + } + if !strings.Contains(text, `"last_update_check": "2026-04-07T00:00:00Z"`) { + t.Fatalf("expected last_update_check to be written, got: %s", text) + } +} + +func TestReadYesNo(t *testing.T) { + yes, err := readYesNo(strings.NewReader("\n")) + if err != nil { + t.Fatalf("readYesNo empty: %v", err) + } + if !yes { + t.Fatalf("empty answer should default to yes") + } + no, err := readYesNo(strings.NewReader("n\n")) + if err != nil { + t.Fatalf("readYesNo n: %v", err) + } + if no { + t.Fatalf("n should be treated as no") + } +} + +func TestMaybeRunDevVersionDoesNotWriteConfig(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + var out bytes.Buffer + var errOut bytes.Buffer + MaybeRun(strings.NewReader(""), &out, &errOut, "dev") + + p := filepath.Join(tmpHome, ".cordon", "config.json") + if _, err := os.Stat(p); err == nil { + t.Fatalf("expected no config write for dev version") + } +} From 3d451d690d6c06f29af2dfdde4758581d407f6f7 Mon Sep 17 00:00:00 2001 From: Tom Nash Date: Tue, 7 Apr 2026 20:06:43 +1000 Subject: [PATCH 2/2] CHORE: allow VERSION override in dev install script --- scripts/dev-install.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/dev-install.sh b/scripts/dev-install.sh index 938caa5..c10bec7 100755 --- a/scripts/dev-install.sh +++ b/scripts/dev-install.sh @@ -2,10 +2,12 @@ # dev-install.sh — build cordon and install it to a local bin directory for testing # # By default installs to ~/.local/bin (created if absent). Override with INSTALL_DIR. +# Version defaults to dev. Override with VERSION to test update behavior. # # Usage: # ./scripts/dev-install.sh # INSTALL_DIR=/usr/local/bin ./scripts/dev-install.sh +# VERSION=v0.5.4 ./scripts/dev-install.sh set -euo pipefail @@ -13,11 +15,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR/.." INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" +VERSION="${VERSION:-dev}" BUILD_DIR="build" BINARY="${BUILD_DIR}/cordon" -echo "Building cordon (dev)..." -go build -ldflags "-X github.com/cordon-co/cordon-cli/cli/internal/buildinfo.Version=dev" -o "$BINARY" ./cmd/cordon +echo "Building cordon (version: ${VERSION})..." +go build -ldflags "-X github.com/cordon-co/cordon-cli/cli/internal/buildinfo.Version=${VERSION}" -o "$BINARY" ./cmd/cordon mkdir -p "$INSTALL_DIR" cp "$BINARY" "${INSTALL_DIR}/cordon"