feat(update): detect install method to tailor or suppress update notices - #107
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds install-method detection to tailor (or suppress) update notifications, moves the “new release available” notice to run before command execution using a cached latest-version in state.json, and throttles notices to once per week while skipping update checks entirely for auto-updating package-manager installs.
Changes:
- Introduces install-method detection (
homebrew,scoop,npm,package,script,unknown) and a cheapIsAutoUpdatinggate to suppress notices and network checks for system packages. - Adds cached update metadata (
KnownLatestVersion,LastNotified) tostate.jsonand uses it to show notices inPersistentPreRun. - Adds tailored per-method upgrade commands and updates packaging/docs/config to support detection and messaging.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents new update-notification behavior and env overrides. |
| packaging/install-source | Adds package-manager marker file content used for suppression. |
| internal/update.go | Caches latest version in state; adds weekly throttle + notification helpers; skips checks for auto-updating installs. |
| internal/update_test.go | Unit tests for cached-notification/throttle logic. |
| internal/state/state.go | Extends persisted state with LastNotified and KnownLatestVersion. |
| internal/install.go | Adds install-method detection + cheap auto-updating check. |
| internal/install_test.go | Unit tests for method detection and parsing. |
| internal/config/upsun-cli.yaml | Populates npm/installer fields used to build upgrade commands. |
| internal/config/schema.go | Extends wrapper config schema with npm/installer/install_method fields. |
| internal/config/platformsh-cli.yaml | Adds installer URL for the platform flavor (see review comments). |
| docs/design/update-message-install-detection.md | Design doc describing approach, precedence, and planned Phase 2. |
| commands/root.go | Moves notice to PersistentPreRun; prints from cache; runs background refresh; adds per-method upgrade commands. |
| commands/root_test.go | Tests upgrade-command selection per install method. |
| CLAUDE.md | Updates internal documentation about update checks and install detection. |
| .goreleaser.yaml | Installs package marker file in nfpm packages to enable suppression. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Detect how the CLI was installed and adjust the "new release available" notice accordingly: - System package managers (apt, yum/dnf, apk) are detected via a marker file installed by the nfpm packages. The notice is suppressed and the network check is skipped, since the OS handles updates. - Homebrew, Scoop, npm, and the bash installer get the exact upgrade command, built from config fields so vendor builds stay correct. - Unknown installs keep the generic GitHub link. Detection precedence: an explicit override (<PREFIX>INSTALL_METHOD env or wrapper.install_method config), then the package marker, then the resolved executable path (npm/scoop/homebrew), then standard bin dirs (script). The notice is now shown before the command runs, using a latest-version cache in state.json refreshed by the background check, and throttled to once a week (LastNotified). This removes the per-run nagging. Adds Wrapper.NpmPackage, Wrapper.InstallerURL, and Wrapper.InstallMethod config fields, and the packaging/install-source marker to both nfpm entries. Documents the behavior in README and the design doc; Phase 2 (opt-in self-update) is planned in docs/design. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Upgrade command for script installs pins INSTALL_DIR to the current binary's directory. This replaces the binary in place and forces the installer's raw method, which it would not otherwise pick on hosts with apt/yum/apk, and pipes to sh (the installer is POSIX sh). - Drop installer_url from the platform flavor: installer.sh only installs the upsun binary. - Remove the leading blank line from the notice, now that it is printed before the command's output. - Clarify in the design doc which flavors set npm_package/installer_url. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A --quiet run discards stderr, so showing the notice there would consume the weekly throttle without the user seeing it. Leave it for a later run. Shell-quote INSTALL_DIR in the suggested installer command, so paths with spaces or metacharacters are copied safely. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dab628b to
61e1b66
Compare
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 1 warning · 🔵 4 minor points
🔍 Full review · 14 files reviewed
Verification
- The nfpm marker paths
/usr/share/{upsun,platformsh-cli}/install-sourcematchApplication.Slugin both bundled configs, sopackageMarkerExists(prefix = parent of /usr/bin) resolves them. scoop update <Application.Executable>matches the scoop manifest names (upsun,platform) in .goreleaser.yaml.- The notice block runs after the quiet/colour writer setup and after the
--versionearly exit, so it never writes to a discarded or uncoloured stderr. MarkNotifiedruns synchronously before theCheckForUpdategoroutine is launched, so that goroutine's state save does not clobber the just-writtenLastNotifiedin-process.shellQuoteescapes single quotes as'\''and the tests cover spaces, quotes and$(…)in the INSTALL_DIR path.
The diff adds unit tests for detection (internal/install_test.go), the weekly throttle core (internal/update_test.go) and the per-method upgrade strings (commands/root_test.go); the CI test job runs make test, golangci-lint and make goreleaser-check. Nothing covers the new PersistentPreRun wiring, IsAutoUpdating/marker lookup against a real filesystem, or that the generated installer command actually performs a raw install.
Review details
- Commit: 61e1b66
- Model: claude-opus-5
Review 1 of 10 for this pull request · View the full run
- Add INSTALL_METHOD=raw to the script upgrade command. installer.sh only reads INSTALL_DIR after choosing the raw method, so without it the command would install via apt/yum/apk or Homebrew instead. - Before printing a notice, detect package installs that predate the marker file by asking dpkg/rpm/apk whether they own the binary, and stay silent for them. - Only treat a Cellar path as Homebrew when it contains the tap's formula (/Cellar/<formula>/). - Treat a LastNotified in the future (clock skew) as stale. - Add state.Update, which reloads and saves state under a mutex, so the update check and config update goroutines no longer drop each other's fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 2 minor points
🔁 Incremental · 10 files reviewed
🔵 Minor points
internal/state/state.go:64—state.Updatereturns early whenLoadfails, so an unparsablestate.jsonis never rewritten. PreviouslyCheckForUpdateandalt.Updateloaded state, ignored the load error and calledstate.Saveunconditionally, which overwrote a bad file with valid JSON.Saveuses a plainos.WriteFile(non-atomic) and theCheckForUpdate/alt.Updategoroutines are never joined —exitWithError/os.Exitcan kill the process mid-write, or two concurrent CLI processes can interleave writes, leaving trailing garbage. After that,json.Unmarshalerrors forever:Updatenever saves,LastCheckedis never refreshed so every command performs the GitHub release request and the config fetch, andKnownLatestVersion/LastNotifiedare never stored, so the update notice never appears again. Treating an unmarshal error as empty state (or writing anyway) restores self-healing.internal/install.go:218—ownedBySystemPackagerunsdpkg-query -S,rpm -qfandapk info --who-ownswithexec.Command(...).Run()— nocontextand no timeout — and it is reached synchronously fromPersistentPreRunviainternal.DetectInstallMethod(cnf)(commands/root.go:84) whenever a pending notice exists and the binary is in a standard bin dir on Linux. If the package database is slow or wedged (rpmdb on NFS, a stalleddpkg-queryon a slow disk, anapkreading a hung network-mounted cache), the user's command blocks indefinitely before it has run at all, for a cosmetic notice. Usingexec.CommandContextwith a short deadline bounds the delay.
Verification
INSTALL_METHOD=rawin the generated installer command is honoured: installer.sh'scheck_install_methodonly auto-picks apt/yum/apk whenINSTALL_METHODis empty, and the top-level flow then runscheck_directories, which usesINSTALL_DIR.isHomebrewnow requires/cellar/<formula>/with formula frompath.Base(cnf.Wrapper.HomebrewTap)("upsun-cli"), and the empty-tap case is guarded byformula != ".", so/srv/cellar/bin/upsunno longer reports homebrew.state.Updateserialises load-modify-save under a package mutex and all three writers (CheckForUpdate, MarkNotified, alt.Update) now go through it, so one goroutine no longer drops the other's fields.- The clock-skew guard
LastNotified <= nowmakes a future timestamp fall through to notifying, andLastNotified == nowstill throttles. - The pkgOwned probe only runs on Linux after
inStandardBinDir, so npm/scoop/homebrew paths still short-circuit before any subprocess.
Unit tests in the diff cover the new package-ownership branch, the cellar-formula narrowing and the future-LastNotified case (internal/install_test.go, internal/update_test.go), plus the updated INSTALL_METHOD=raw expectation in commands/root_test.go; they run in the CI test job (make test) alongside golangci-lint. Nothing tests state.Update's behaviour on an unreadable state file, and no test exercises ownedBySystemPackage itself (only the injected fake).
Review 2 of 10 for this pull request · View the full run
Superseded: the latest Upsun Dispatch review no longer requests changes.
- state.Update starts from empty state when state.json cannot be read, so a corrupt file is rewritten instead of blocking every later save (which would stop update throttling and notices). - Give the dpkg/rpm/apk ownership query a one-second time limit, since it runs before the user's command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 2 minor points
🔁 Incremental · 3 files reviewed
🔵 Minor points
internal/install.go:215— The one-second deadline is created once and shared by all three package-database queries, and a timeout is indistinguishable from "not owned". On a Debian/Ubuntu host with a cold or large dpkg database,dpkg-query -S /usr/bin/upsuncan exceed 1s; the context then expires,rpmandapkreturn immediately with the deadline error,ownedBySystemPackagereturns false anddetectInstallMethodreportsInstallScriptfor a package install. The user is then showncurl -fsSL <installer> | INSTALL_METHOD=raw INSTALL_DIR=/usr/bin shinstead of the intended silence, i.e. a suggestion to overwrite a dpkg-owned file. A per-query deadline, or treating timeout as "unknown" rather than "not owned", would bound this.internal/state/state.go:66—Updatenow discards everyLoaderror, not just JSON corruption, and replaces the state with a zeroState. BecauseSaveuses a non-atomicos.WriteFile(truncate + write), a concurrent process — e.g. thealt.Updategoroutine of another CLI invocation — can leave the file momentarily truncated/partial; a reader hitting that window unmarshals with an error, resets toState{}and writes back, droppingKnownLatestVersion,LastNotifiedandConfigUpdates.LastChecked. The visible effect is a lost update notice or a repeated one. Writing via a temp file + rename, or only resetting onjson.SyntaxError, would keep the repair behaviour without the cross-process data loss.
Verification
state.Updatetakesmuand reloads before applyingfn, so the CheckForUpdate and alt.Update goroutines in one process no longer clobber each other's fields.notificationFromStatenow requiresLastNotified <= nowbefore throttling, so a future timestamp from clock skew is treated as stale rather than suppressing notices forever.- The script upgrade command now sets
INSTALL_METHOD=rawalongsideINSTALL_DIR, which is what installer.sh'scheck_install_methodhonours before falling back to apt/yum/brew. detectInstallMethodqueries dpkg/rpm/apk only afterinStandardBinDirand only on Linux, so the subprocess cost is not paid on macOS/Windows or for npm/scoop/brew paths.TestUpdateRepairsCorruptFilewrites trailing garbage after valid JSON, which does makejson.Unmarshalfail, so it genuinely exercises the new reset branch.
This increment adds internal/state/state_test.go covering the corrupt-file repair path; the shared-context timeout in ownedBySystemPackage has no test (it shells out to real package managers). Tests run via make test in the .github/workflows/ci.yml job.
Review 3 of 10 for this pull request · View the full run
- state.Save writes to a temporary file and renames it, so a concurrent process never reads a partial file and resets the state. - Give each dpkg/rpm/apk query its own time limit, and treat a timeout as package-owned so a slow database never leads to suggesting the installer over a package-owned binary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 2 minor points · ⚪ 1 nitpick
🔁 Incremental · 3 files reviewed
🔵 Minor points
internal/state/state.go:67—Savenow replaces the state file withos.Renameover the destination. On Windows,os.ReadFile/os.Open(used bystate.Load, in this process and in any other concurrently running CLI invocation) opens the file withFILE_SHARE_READ|FILE_SHARE_WRITEonly — noFILE_SHARE_DELETE— so while a reader holds the handle,MoveFileEx(..., REPLACE_EXISTING)fails with a sharing violation andSavereturns an error. Every caller (state.UpdatefromCheckForUpdate,MarkNotified,alt.Update) discards that error with//nolint:errcheck, so on windows/amd64 the write is silently dropped:KnownLatestVersion/LastNotifiedare not persisted and the notice is lost or repeated. The previousos.WriteFilesucceeded in that window because write sharing is permitted.internal/install.go:226— The 1s timeout is now created per query instead of once for the whole function, and a timeout is treated as "owned". Two consequences: (a) worst case the function blocks 3s (dpkg-query + rpm + apk each timing out) instead of the previous 1s, and this runs synchronously inPersistentPreRunviaDetectInstallMethodbefore the user's command starts; (b) a transient stall (dpkg frontend lock, cold/slow rpm DB, NFS home) on a machine where the CLI was actually installed by the script makes detection returnInstallPackage, socommands/root.goskipsprintUpdateMessageyet still callsinternal.MarkNotified(cnf)— the notice is suppressed and the weekly throttle is consumed, delaying the message another week.
⚪ Nitpick
internal/state/state.go:55— If the process dies (SIGKILL, Ctrl-C handling that bypasses defers, or aos.Exitsuch as the--versionpath in root.go) betweenos.CreateTempand thedefer os.Remove, a.state-*.tmpfile is left behind in the user's writable dir and nothing ever cleans it up; repeated occurrences accumulate. The new test only observes the happy path, where the deferred remove runs.
Verification
state.Savewrites the temp file withos.CreateTemp's 0600 mode and renames in the same directory, so the state file stays private and on one filesystem.state.Updatereloads under the package mutex before applyingfn, so two goroutines in one process no longer drop each other's fields.ownedBySystemPackagenow skips a missing tool viaexec.LookPathbefore spending a timeout on it, andcancel()is called on every loop iteration (no context leak).ctx.Err() != nilin the loop can only be set by this query's own timeout, sincecancel()runs after the check.
The diff adds assertions to TestUpdateRepairsCorruptFile (no leftover temp file, 0600 perms) covering the new atomic Save; the changed ownedBySystemPackage timeout/loop behaviour has no test. Both are exercised only by the make test step of the .github/workflows/ci.yml Go job, which runs on ubuntu-latest, so the rename-on-Windows path is not covered there.
Review 4 of 10 for this pull request · View the full run
…adline - If renaming the temporary state file fails (on Windows, while another process has state.json open), write the file directly instead of dropping the update. - Share one one-second deadline across the dpkg/rpm/apk queries, so detection delays a command by at most a second. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 1 minor point
🔁 Incremental · 2 files reviewed
🔵 Minor point
internal/install.go:228—ownedBySystemPackagenow shares one 1-second context across all three probes instead of giving each its own. The loop's success test iserr == nil || ctx.Err() != nil, so once the budget is consumed by an earlier probe, every remaining probe reports "owned" without running:exec.CommandContext(...).Run()on an already-expired context fails immediately at Start andctx.Err()is non-nil. On a host where the first probe is slow (e.g. dpkg-query -S against a cold/large database taking ~1s and exiting non-zero for an unowned binary) and a second tool exists (rpm or apk, common on containers with mixed tooling), detection returns InstallPackage for a binary the OS does not manage. That makesdetectInstallMethodclassify a script install aspackage, so the CLI permanently suppresses update notices and skips the network check for that user.
Verification
- state.Save now falls back to a direct os.WriteFile only after writeAndRename fails, and the temp file is still removed by the deferred os.Remove on that path.
- writeAndRename correctly renames onto its new
pathparameter, matching the statePath passed by Save. - The 1s timeout context in ownedBySystemPackage is cancelled via defer, so no context leak remains after the refactor.
- state.Update still serialises load-modify-save under the package mutex, so the new Save path does not reintroduce lost fields.
internal/state/state_test.go covers Update repairing a corrupt state file and leaving no temp files behind; nothing in the diff tests the Windows rename-fallback path or the shared-context timeout in ownedBySystemPackage. Tests run via .github/workflows/ci.yml.
Review 5 of 10 for this pull request · View the full run
Summary
Detects how the CLI was installed and adjusts the "new release available" notice accordingly, instead of showing the same generic GitHub link to everyone after every command.
INSTALL_METHOD=rawandINSTALL_DIRto the current binary's directory, so it replaces the binary in place (otherwise the installer would pick apt/yum/apk or Homebrew where available).The notice is now shown before the command runs, using a latest-version cache in
state.jsonrefreshed by the background check, and is throttled to once a week. This removes the per-run nagging.--quietruns don't show it or consume the throttle.Detection precedence
Explicit override (
<PREFIX>INSTALL_METHODenv orwrapper.install_methodconfig) → package marker → resolved executable path (npm / scoop / homebrew) → standard bin dirs (script) → unknown.Changes
internal/install.go:InstallMethodtype,DetectInstallMethod, cheapIsAutoUpdating.internal/update.go: cacheKnownLatestVersion;PendingNotification/MarkNotifiedwith a weekly throttle; suppress checks for auto-updating installs.commands/root.go: show the notice inPersistentPreRunfrom the cache; per-method upgrade command.Wrapper.NpmPackage,Wrapper.InstallerURL,Wrapper.InstallMethod. Only theupsunflavor setsnpm_package/installer_url, since npm andinstaller.shonly shipupsun.packaging/install-sourcemarker added to both nfpm entries.docs/design/update-message-install-detection.md(which also describes the planned Phase 2: opt-in self-update).Scope
This is Phase 1 (detection + tailored/suppressed messages + weekly throttle + show-before-via-cache). Phase 2 (interactive prompt + auto-update + re-exec) and the external docs update are tracked separately in the design doc.
Verification
🤖 Generated with Claude Code