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_pathObject

—- cache ————————————————————



97
98
99
# File 'lib/rubino/update_check.rb', line 97

def cache_path
  File.join(Rubino::Config::Loader.default_home_path, CACHE_FILE)
end

.cached_latestObject



101
102
103
104
105
106
107
# File 'lib/rubino/update_check.rb', line 101

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)


135
136
137
138
139
# File 'lib/rubino/update_check.rb', line 135

def checks_enabled?
  !opted_out? &&
    $stdout.tty? &&
    ENV["CI"].to_s.strip.empty?
end

.clear_cache!Object



120
121
122
123
124
# File 'lib/rubino/update_check.rb', line 120

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).



76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/rubino/update_check.rb', line 76

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.



167
168
169
# File 'lib/rubino/update_check.rb', line 167

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).



155
156
157
# File 'lib/rubino/update_check.rb', line 155

def install_method
  installed_gem_version(GEM_NAME) ? :gem : :source
end

.installed_gem_version(name) ⇒ Object



159
160
161
162
163
# File 'lib/rubino/update_check.rb', line 159

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)


179
180
181
182
183
184
185
# File 'lib/rubino/update_check.rb', line 179

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
# File 'lib/rubino/update_check.rb', line 43

def notice_from_cache
  return nil if opted_out?

  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)


130
131
132
# File 'lib/rubino/update_check.rb', line 130

def opted_out?
  !ENV["RUBINO_NO_UPDATE_CHECK"].to_s.strip.empty?
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.



57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/rubino/update_check.rb', line 57

def refresh_async_if_stale
  return nil unless checks_enabled?
  return nil unless stale?

  Thread.new do
    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)


174
175
176
# File 'lib/rubino/update_check.rb', line 174

def semver?(str)
  !!(str.to_s =~ /\A\d+\.\d+(\.\d+)?([-.][0-9A-Za-z.-]+)?\z/)
end

.stale?Boolean

True when the cache is missing or its checked_at is older than 24h.

Returns:

  • (Boolean)


142
143
144
145
146
147
148
149
# File 'lib/rubino/update_check.rb', line 142

def stale?
  return true unless File.exist?(cache_path)

  checked_at = JSON.parse(File.read(cache_path))["checked_at"]
  Time.now.utc - Time.parse(checked_at) >= CHECK_INTERVAL
rescue StandardError
  true
end

.write_cache(latest) ⇒ Object

Atomic write (temp + rename) so a crashed refresh never leaves a torn file.



110
111
112
113
114
115
116
117
118
# File 'lib/rubino/update_check.rb', line 110

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