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
The HIGHEST installed version of
nameon disk, or nil when none is installed. -
.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.
210 211 212 |
# File 'lib/rubino/update_check.rb', line 210 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
The HIGHEST installed version of name on disk, or nil when none is
installed. Uses find_all_by_name (NOT find_by_name) because the running
rubino process has its own gem version ACTIVATED — find_by_name returns
that activated spec, so right after gem update pulls a newer gem it still
reported the old version and rubino update wrongly said "already up to
date". find_all_by_name isn't activation-filtered; taking the max sees the
freshly-installed version (callers that need post-install freshness run
Gem.refresh first — see Commands#update).
202 203 204 205 206 |
# File 'lib/rubino/update_check.rb', line 202 def installed_gem_version(name) Gem::Specification.find_all_by_name(name).map(&:version).max&.to_s rescue StandardError nil end |
.newer?(latest) ⇒ Boolean
latest is a valid version strictly greater than the running VERSION.
222 223 224 225 226 227 228 |
# File 'lib/rubino/update_check.rb', line 222 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.
217 218 219 |
# File 'lib/rubino/update_check.rb', line 217 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 |