Module: Rubino::UpdateCheck
- Defined in:
- lib/rubino/update_check.rb
Overview
Boot-time “new version available” notice + the ‘rubino update` mechanics.
Two decoupled concerns, mirroring how ‘gh`/update-notifier do it:
* SHOW (sync, zero network): `notice_from_cache` reads
<RUBINO_HOME>/update_check.json and returns a one-line notice only when
the cached `latest` is a valid Gem::Version strictly greater than the
running VERSION. Pure local read — cannot slow boot, works offline.
* REFRESH (out-of-band): `refresh_async_if_stale` spawns a detached,
fully-rescued Thread (≈1.5s timeout) that GETs RubyGems and rewrites the
cache for the NEXT boot. It is never joined, so this boot never blocks.
Gated to once/24h, TTY-only, not-in-CI, and skipped entirely when
RUBINO_NO_UPDATE_CHECK is set.
The whole feature no-ops until rubino-agent is actually published: RubyGems currently returns “version”:“unknown”, and “unknown” / non-semver / nil / any network error are all treated as “no info” → no notice.
Constant Summary collapse
- LATEST_URL =
"https://rubygems.org/api/v1/versions/rubino-agent/latest.json"- GEM_NAME =
"rubino-agent"- CACHE_FILE =
"update_check.json"- CHECK_INTERVAL =
24h, like gh/Homebrew
24 * 60 * 60
- NET_TIMEOUT =
1.5
Class Method Summary collapse
-
.cache_age ⇒ Object
Seconds since the cached checked_at (negative if it is in the future), or nil when the cache is missing, unreadable, or has an unparseable timestamp.
-
.cache_path ⇒ Object
—- cache ————————————————————.
- .cached_latest ⇒ Object
-
.checks_enabled? ⇒ Boolean
All must hold (mirrors gh): no opt-out env, interactive TTY, not CI.
- .clear_cache! ⇒ Object
-
.fetch_latest ⇒ Object
The latest published version string, or nil on failure / “unknown” / non-semver.
-
.gem_update_command ⇒ Object
Argv form (no shell) + active interpreter via Gem.ruby → updates the right install on a multi-Ruby machine and is injection-safe.
-
.install_method ⇒ Object
How rubino was installed: :gem when a matching RubyGems spec is loaded, else :source (dev checkout / built from source / vendored).
- .installed_gem_version(name) ⇒ Object
-
.newer?(latest) ⇒ Boolean
latest is a valid version strictly greater than the running VERSION.
-
.notice_from_cache ⇒ Object
One-line dim notice when a newer version is cached, else nil.
-
.opted_out? ⇒ Boolean
The user’s full opt-out: RUBINO_NO_UPDATE_CHECK set (to anything non-blank) disables refresh AND the cached boot notice.
-
.poisoned_cache? ⇒ Boolean
A cache whose checked_at is in the future is poison: it cannot reflect a real check and must never drive the boot notice.
-
.refresh_async_if_stale ⇒ Object
Refresh the cache in a detached thread iff enabled and stale.
-
.semver?(str) ⇒ Boolean
X.Y / X.Y.Z — strict enough to reject “unknown” and other garbage.
-
.stale? ⇒ Boolean
True when the cache is missing, unreadable, its checked_at is older than 24h — AND, crucially, also true when checked_at is in the FUTURE.
-
.write_cache(latest) ⇒ Object
Atomic write (temp + rename) so a crashed refresh never leaves a torn file.
Class Method Details
.cache_age ⇒ Object
Seconds since the cached checked_at (negative if it is in the future), or nil when the cache is missing, unreadable, or has an unparseable timestamp.
177 178 179 180 181 182 183 184 |
# File 'lib/rubino/update_check.rb', line 177 def cache_age return nil unless File.exist?(cache_path) checked_at = JSON.parse(File.read(cache_path))["checked_at"] Time.now.utc - Time.parse(checked_at) rescue StandardError nil end |
.cache_path ⇒ Object
—- cache ————————————————————
104 105 106 |
# File 'lib/rubino/update_check.rb', line 104 def cache_path File.join(Rubino::Config::Loader.default_home_path, CACHE_FILE) end |
.cached_latest ⇒ Object
108 109 110 111 112 113 114 |
# File 'lib/rubino/update_check.rb', line 108 def cached_latest return nil unless File.exist?(cache_path) JSON.parse(File.read(cache_path))["latest"] rescue StandardError nil end |
.checks_enabled? ⇒ Boolean
All must hold (mirrors gh): no opt-out env, interactive TTY, not CI.
142 143 144 145 146 |
# File 'lib/rubino/update_check.rb', line 142 def checks_enabled? !opted_out? && $stdout.tty? && ENV["CI"].to_s.strip.empty? end |
.clear_cache! ⇒ Object
127 128 129 130 131 |
# File 'lib/rubino/update_check.rb', line 127 def clear_cache! File.delete(cache_path) if File.exist?(cache_path) rescue StandardError nil end |
.fetch_latest ⇒ Object
The latest published version string, or nil on failure / “unknown” / non-semver. Synchronous + short-timeout; callers that must not block run it on a detached thread (refresh_async_if_stale).
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/rubino/update_check.rb', line 83 def fetch_latest uri = URI(LATEST_URL) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = (uri.scheme == "https") http.open_timeout = NET_TIMEOUT http.read_timeout = NET_TIMEOUT req = Net::HTTP::Get.new(uri) req["User-Agent"] = "Rubino/#{Rubino::VERSION}" res = http.request(req) return nil unless res.is_a?(Net::HTTPSuccess) version = JSON.parse(res.body)["version"].to_s semver?(version) ? version : nil rescue StandardError nil end |
.gem_update_command ⇒ Object
Argv form (no shell) + active interpreter via Gem.ruby → updates the right install on a multi-Ruby machine and is injection-safe.
202 203 204 |
# File 'lib/rubino/update_check.rb', line 202 def gem_update_command [Gem.ruby, "-S", "gem", "update", GEM_NAME] end |
.install_method ⇒ Object
How rubino was installed: :gem when a matching RubyGems spec is loaded, else :source (dev checkout / built from source / vendored).
190 191 192 |
# File 'lib/rubino/update_check.rb', line 190 def install_method installed_gem_version(GEM_NAME) ? :gem : :source end |
.installed_gem_version(name) ⇒ Object
194 195 196 197 198 |
# File 'lib/rubino/update_check.rb', line 194 def installed_gem_version(name) Gem::Specification.find_by_name(name).version.to_s rescue Gem::MissingSpecError, StandardError nil end |
.newer?(latest) ⇒ Boolean
latest is a valid version strictly greater than the running VERSION.
214 215 216 217 218 219 220 |
# File 'lib/rubino/update_check.rb', line 214 def newer?(latest) return false unless semver?(latest) Gem::Version.new(latest) > Gem::Version.new(Rubino::VERSION) rescue ArgumentError false end |
.notice_from_cache ⇒ Object
One-line dim notice when a newer version is cached, else nil. The RUBINO_NO_UPDATE_CHECK opt-out disables the feature ENTIRELY — “no network, no notice” per docs/commands.md — so a previously-cached notice must not leak through either (#66).
43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
# File 'lib/rubino/update_check.rb', line 43 def notice_from_cache return nil if opted_out? # Never render off a poisoned cache: a future-dated checked_at (the # "v99.0.0" phantom came from {"checked_at":"2099-…","latest":"99.0.0"}) # is suppressed here, and stale? treats it as stale so a refresh # repopulates it for next boot. A legitimately >24h-old cache is still # trusted for the notice — only forward-dated / corrupt entries are poison. return nil if poisoned_cache? latest = cached_latest return nil unless newer?(latest) "▸ rubino v#{latest} available — run `rubino update`" end |
.opted_out? ⇒ Boolean
The user’s full opt-out: RUBINO_NO_UPDATE_CHECK set (to anything non-blank) disables refresh AND the cached boot notice.
137 138 139 |
# File 'lib/rubino/update_check.rb', line 137 def opted_out? !ENV["RUBINO_NO_UPDATE_CHECK"].to_s.strip.empty? end |
.poisoned_cache? ⇒ Boolean
A cache whose checked_at is in the future is poison: it cannot reflect a real check and must never drive the boot notice. Missing/unreadable caches are NOT “poisoned” (they simply yield no notice); only a forward-dated or unparseable timestamp is.
168 169 170 171 172 173 |
# File 'lib/rubino/update_check.rb', line 168 def poisoned_cache? return false unless File.exist?(cache_path) age = cache_age age.nil? || age.negative? end |
.refresh_async_if_stale ⇒ Object
Refresh the cache in a detached thread iff enabled and stale. Returns the spawned Thread (tests can join it) or nil when gated out. The caller never joins it on the boot path, so this boot is never slowed.
63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'lib/rubino/update_check.rb', line 63 def refresh_async_if_stale return nil unless checks_enabled? return nil unless stale? Thread.new do Thread.current[:rubino_update_refresh] = true latest = fetch_latest write_cache(latest) if latest rescue StandardError # Offline, DNS, TLS, JSON garbage, FS — silent. The cache is left as-is, # so a transient failure simply shows no notice. nil end end |
.semver?(str) ⇒ Boolean
X.Y / X.Y.Z — strict enough to reject “unknown” and other garbage.
209 210 211 |
# File 'lib/rubino/update_check.rb', line 209 def semver?(str) !!(str.to_s =~ /\A\d+\.\d+(\.\d+)?([-.][0-9A-Za-z.-]+)?\z/) end |
.stale? ⇒ Boolean
True when the cache is missing, unreadable, its checked_at is older than 24h — AND, crucially, also true when checked_at is in the FUTURE.
A future checked_at (clock skew, a hand-edited or corrupt cache, a bogus fixture) yields a NEGATIVE age, which ‘>= 24h` is never true for — so the old code latched the poisoned cache forever and re-rendered its bogus `latest` on every boot (the live “rubino v99.0.0 available” phantom, from a cache pinned at “checked_at”:“2099-…”,“latest”:“99“checked_at”:“2099-…”,“latest”:“99.0“checked_at”:“2099-…”,“latest”:“99.0.0”). Treating a negative age as stale forces a re-check, letting the notifier self-heal.
157 158 159 160 161 162 |
# File 'lib/rubino/update_check.rb', line 157 def stale? age = cache_age return true if age.nil? age.negative? || age >= CHECK_INTERVAL end |
.write_cache(latest) ⇒ Object
Atomic write (temp + rename) so a crashed refresh never leaves a torn file.
117 118 119 120 121 122 123 124 125 |
# File 'lib/rubino/update_check.rb', line 117 def write_cache(latest) dir = File.dirname(cache_path) FileUtils.mkdir_p(dir) tmp = "#{cache_path}.#{Process.pid}.tmp" File.write(tmp, JSON.generate("checked_at" => Time.now.utc.iso8601, "latest" => latest)) File.rename(tmp, cache_path) rescue StandardError nil end |