Skip to content

feat(cli): cached non-blocking update notice + civitai upgrade self-update - #39

Merged
ZacxDev merged 2 commits into
mainfrom
zach/update-notice-and-upgrade
Jun 25, 2026
Merged

feat(cli): cached non-blocking update notice + civitai upgrade self-update#39
ZacxDev merged 2 commits into
mainfrom
zach/update-notice-and-upgrade

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

What

Two related features, one PR:

1. Cached, non-blocking daily "new version available" notice

A root PersistentPostRun hook prints at most one dim line to stderr after any successful command:

A new version of civitai is available: v0.1.12 (you have v0.1.11). Run 'civitai upgrade' to update.

It never blocks: it reads a cache (~/.config/civitai/update-check.json) and, when stale, spawns a detached civitai __update-check (hidden subcommand) that fetches the latest release and rewrites the cache. The current run uses the cached value; the refresh is for next time. First run (no cache) just kicks off the refresh — no notice.

Anti-annoyance rules (all enforced + tested):

  • Refresh at most once / 24h (last_check); notice at most once / 24h (last_notified) even while behind.
  • Suppressed when: stderr is not a TTY, a CI env var is set, --no-update-check / CIVITAI_NO_UPDATE_CHECK, or the command is version/upgrade/completion/help/__update-check/__complete*.
  • Only fires when the current version parses and latest > current.
  • stderr only — never pollutes stdout, so pipes/scripts are unaffected.
  • Corrupt/missing cache → empty, fail-silent.

Reuses the existing fetchLatestRelease / semver helpers; version keeps its own explicit synchronous check (no double-notify).

2. civitai upgrade self-update

  • Resolves the latest release (unauthenticated GitHub — no token ever sent). Already ≥ latest and not --force → "already up to date" no-op.
  • Homebrew detection: if the resolved executable lives under a brew path, prints brew upgrade civitai/tap/civitai instead of self-replacing (--force overrides).
  • Otherwise: downloads the platform tarball + checksums.txt, verifies the tarball SHA-256 against checksums.txt and ABORTS on mismatch (binary untouched), extracts the binary, and atomically replaces the running executable via github.com/minio/selfupdate. Permission-denied → clear sudo/brew/go-install guidance, non-zero exit, no half-written binary.

Detaching uses a build-tagged platform split (unix Setpgid / windows CREATE_NEW_PROCESS_GROUP); the parent never Waits. Spawn / apply / executable-path / TTY are behind injectable seams so tests assert behavior without forking or replacing the test binary.

Security

The download path has two integrity controls; they are defense-in-depth, not redundant:

  1. Checksum-verify-before-replace (integrity gate): the platform tarball's SHA-256 is verified against checksums.txt and a mismatch aborts before the binary on disk is touched.
  2. HTTPS + GitHub-host enforcement on asset downloads (transport gate, added here): asset URLs come straight out of the GitHub release JSON (attacker-influenced data). Before fetching either the tarball or checksums.txt, each URL is validated to be https on an allowlisted GitHub release host (github.com, objects.githubusercontent.com, release-assets.githubusercontent.com); anything else aborts the upgrade before any bytes are read. The asset download client also uses a CheckRedirect that re-applies this check on every hop, so an https → http downgrade (or host-pivot) redirect is rejected rather than followed. This closes the case where an http:// checksums.txt + http:// tarball pair would make the checksum gate self-referential (both halves attacker-controlled).

Accepted baseline limitation + follow-up: checksums.txt integrity currently rests on HTTPS-to-GitHub (the standard goreleaser baseline) — it is not cryptographically signed. The highest-value follow-up is to add a goreleaser signs: block (cosign keyless or minisign) and have civitai upgrade verify a signature over checksums.txt before trusting it. That is tracked as a follow-up, not in this PR. Until then, the checksum-verify-before-replace gate plus this PR's https/host enforcement are the current integrity controls.

New dependencies

  • github.com/minio/selfupdate — well-vetted cross-platform atomic self-replace (the download + checksum verify are done by us; selfupdate only does the verified-binary apply).
  • golang.org/x/term — TTY detection on stderr.

Verification

  • go build ./..., go vet ./..., go test ./... green; gofmt -l clean. Cross-compiles for windows/amd64, darwin/arm64, linux/amd64 (rc=0 each).
  • New tests: validateAssetURL allow/reject table (https GitHub hosts allowed; http downgrade, off-host, host-suffix spoof, wrong scheme, raw.githubusercontent rejected), an http:// asset URL aborts the upgrade before download with the binary untouched, an off-host https asset URL aborts before download, and the asset client rejects an https → http redirect. The httptest fixtures now serve TLS and inject the loopback host via a test seam so the production allowlist is never weakened to pass tests.
  • civitai --help lists upgrade; __update-check is hidden; --no-update-check is a persistent flag. civitai upgrade --help renders.
  • Live self-update exercised end-to-end against the real v0.1.11 GitHub release on linux/amd64: downloaded the real tarball + checksums, verified SHA-256, atomically replaced a throwaway binary — the replaced binary then reported civitai 0.1.11 (real commit/build date). The detached __update-check refresh ran independently under a PTY and rewrote the cache (last_check → today, corrected a stale latest_version) while the parent command returned in ~21ms.

Note

The notice appears only after a successful command — cobra runs PersistentPostRun only when the command's RunE succeeds. This matches the gh/npm update-notifier model (don't append a notice to a failed command).

🤖 Generated with Claude Code

ZacxDev and others added 2 commits June 25, 2026 15:14
…-update

Adds two related features in one PR:

PART 1 — daily, cached, non-blocking "new version available" notice
- A root PersistentPostRun hook prints at most ONE dim stderr line after any
  successful command. It never does a synchronous network call: it reads a
  cache (~/.config/civitai/update-check.json) and, when stale, spawns a
  DETACHED `civitai __update-check` (hidden subcommand) that fetches the latest
  release and rewrites the cache. The current run uses the cached value; the
  refresh lands for next time. First run (no cache) only kicks off the refresh.
- Refresh at most once / 24h (last_check); notice shown at most once / 24h
  (last_notified) even while behind. Corrupt/missing cache => empty, fail-silent.
- Suppressed when: stderr is not a TTY, CI env is set, --no-update-check /
  CIVITAI_NO_UPDATE_CHECK, or the command is version/upgrade/completion/help/
  __update-check/__complete*. Only fires when current parses and latest > current.
- stderr only — never pollutes stdout, so pipes/scripts are unaffected.
- Reuses the existing fetchLatestRelease / semver helpers from update_check.go;
  `version` keeps its own explicit synchronous check (no double-notify).

PART 2 — `civitai upgrade` self-update
- Resolves the latest release (unauthenticated GitHub, no token ever sent).
  Already >= latest and not --force => "already up to date" no-op.
- Homebrew detection: if the resolved executable lives under a brew path
  (/Cellar/, /Caskroom/, /opt/homebrew, /usr/local/Homebrew|Cellar,
  /home/linuxbrew/.linuxbrew), prints the brew upgrade command instead of
  self-replacing (--force overrides).
- Otherwise downloads the platform tarball + checksums.txt, VERIFIES the
  tarball SHA-256 against checksums.txt and ABORTS on mismatch (binary left
  untouched), extracts the binary, and atomically replaces the running
  executable via github.com/minio/selfupdate. Permission-denied => clear
  sudo/brew/go-install guidance, non-zero exit, no half-written binary.

Detaching uses a small build-tagged platform split (unix Setpgid /
windows CREATE_NEW_PROCESS_GROUP); the parent never Waits. Spawn + apply +
executable-path + TTY are behind injectable seams so tests assert behavior
without forking or replacing the test binary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit 🟡#1 (https/host enforcement). Asset download URLs come straight
out of the GitHub release JSON and were fetched with http.DefaultClient,
which follows redirects including https->http downgrades with no
scheme/host check. An http:// checksums.txt + http:// tarball pair would
make the SHA-256 gate self-referential (both halves attacker-controlled).

- validateAssetURL: require scheme==https AND host in an allowlist
  {github.com, objects.githubusercontent.com,
  release-assets.githubusercontent.com}; reject (abort, no download)
  otherwise. Applied to BOTH the tarball and checksums.txt URLs before
  any bytes are read.
- assetDownloadClient: dedicated *http.Client with CheckRedirect that
  re-validates every hop, so an https->http downgrade redirect is
  rejected rather than followed. Context timeouts + body-size caps kept.
- Checksum-verify-before-replace gate unchanged (defense-in-depth on
  transport).

Tests: validateAssetURL allow/reject table; an http:// asset URL and an
off-host https asset URL each abort before download with the binary
untouched; the asset client rejects an https->http redirect. httptest
fixtures now serve TLS and inject the loopback host via a test seam so
the production allowlist is never weakened to pass tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ZacxDev
ZacxDev merged commit 4dbfb55 into main Jun 25, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant