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

Class Method Details

.cache_ageObject

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_pathObject

—- 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_latestObject



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.

Returns:

  • (Boolean)


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_latestObject

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_commandObject

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_methodObject

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.

Returns:

  • (Boolean)


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_cacheObject

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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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_staleObject

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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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