Skip to content

Commit ea76312

Browse files
feat(discover): surface :version + :version-check (rfp-isvq)
discover-app.sh now carries the local skill version in every response, plus a structured :version-check map that names whether a newer release is available. Operators reporting bugs always know their version; sessions on stale installs see a one-glance pointer at the changelog. Implementation: - current-version reads package.json (anchored to ops.clj's load-time *file* so resolution survives any cwd / bb -e / REPL invocation). - fetch-latest-release hits api.github.com/repos/day8/re-frame-pair/ releases?per_page=1 (latest endpoint excludes pre-releases — we're in pre-release territory until v1.0). 3-second timeout. - read/write-version-cache caches the GH lookup at $XDG_CACHE_HOME/re-frame-pair/version-check.edn (fallback ~/.cache/) for 24h. After the first hit-and-cache, subsequent discover calls are sub-50ms instead of network-bound. - version-check orchestrates: opt out via env RE_FRAME_PAIR_SKIP_VERSION_CHECK, return nil if local version unreadable, return :status :unknown if fetch fails (never block discover), :current when versions match, :stale when they differ. - discover wires :version + :version-check into every emit (success AND structured-failure paths), so even a :browser-runtime-not-attached response carries the version info an operator needs to file a bug. Tests: - :each fixture mocks fetch-latest-release / read-version-cache / write-version-cache for every test by default — discover-touching tests stay offline. Mocking primitives (not the orchestrator) leaves version-check itself testable; dedicated tests below override the primitives with controlled values. - 8 new deftests cover: current-version reads package.json; nil current-version short-circuits before any fetch; status :current when versions match; status :stale when they differ; status :unknown when fetch fails; cache-fresh skips network; discover surfaces the field on success; discover surfaces it on failure paths too. 97 tests / 248 assertions, all green. Live verification against the fixture: success path returns :version "0.1.0-beta.6" + :version-check {:status :current ...}; failure path (no browser tab) returns the same fields alongside :reason :browser-runtime-not-attached. Closes rfp-isvq. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e28ea29 commit ea76312

2 files changed

Lines changed: 312 additions & 31 deletions

File tree

scripts/ops.clj

Lines changed: 173 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
;;;; guessing.
2424

2525
(ns ops
26-
(:require [clojure.edn :as edn]
26+
(:require [babashka.http-client :as http]
27+
[cheshire.core :as json]
28+
[clojure.edn :as edn]
2729
[clojure.java.io :as io]
2830
[clojure.string :as str]
2931
[clojure.tools.reader :as tr]
@@ -152,6 +154,130 @@
152154
{:out "" :err ""}
153155
responses))
154156

157+
;; ---------------------------------------------------------------------------
158+
;; Version check (rfp-isvq) — surface :version + :version-check on discover
159+
;; so operators see when a newer release is available. Read package.json for
160+
;; the local version; hit GitHub's releases API for the latest. Cache the
161+
;; remote lookup for 24h to avoid hammering the API + leaking usage signal.
162+
;; Opt out via env var. Failure-tolerant: a network outage / rate limit /
163+
;; missing release just omits :version-check, never blocks discover.
164+
;; ---------------------------------------------------------------------------
165+
166+
(def ^:private version-cache-ttl-ms (* 24 60 60 1000))
167+
(def ^:private github-releases-url
168+
"https://api.github.com/repos/day8/re-frame-pair/releases?per_page=1")
169+
(def ^:private version-check-timeout-ms 3000)
170+
171+
(def ^:private ops-clj-load-path
172+
"Absolute path to ops.clj at load time. *file* is only bound while
173+
load-file is running; capturing it here gives skill-root a stable
174+
anchor regardless of the calling context (bb -e, REPL, etc.)."
175+
*file*)
176+
177+
(defn- skill-root
178+
"ops.clj's containing project root — used to anchor reads of package.json."
179+
[]
180+
(.. (io/file ops-clj-load-path) getAbsoluteFile getParentFile getParentFile))
181+
182+
(defn- current-version
183+
"Local skill version, read from package.json. Single source of truth per
184+
RELEASING.md; .claude-plugin/plugin.json must match (release workflow
185+
gates on it). Returns nil if package.json can't be read or doesn't
186+
carry a version (e.g. running from a non-repo install layout)."
187+
[]
188+
(try
189+
(let [path (io/file (skill-root) "package.json")]
190+
(when (.exists path)
191+
(-> (slurp path) (json/parse-string true) :version)))
192+
(catch Exception _ nil)))
193+
194+
(defn- version-cache-file []
195+
(io/file (or (System/getenv "XDG_CACHE_HOME")
196+
(str (System/getProperty "user.home") "/.cache"))
197+
"re-frame-pair" "version-check.edn"))
198+
199+
(defn- read-version-cache
200+
"Read the cached latest-version data if it exists and is fresher than
201+
`version-cache-ttl-ms`. Returns the cached map or nil."
202+
[]
203+
(try
204+
(let [f (version-cache-file)]
205+
(when (.exists f)
206+
(let [{:keys [checked-at] :as data} (edn/read-string (slurp f))]
207+
(when (and checked-at
208+
(< (- (System/currentTimeMillis) checked-at)
209+
version-cache-ttl-ms))
210+
data))))
211+
(catch Exception _ nil)))
212+
213+
(defn- write-version-cache
214+
"Persist `data` (already includes :checked-at) to the cache file.
215+
Best-effort — failures (read-only filesystem, etc.) are swallowed."
216+
[data]
217+
(try
218+
(let [f (version-cache-file)]
219+
(.mkdirs (.getParentFile f))
220+
(spit f (pr-str data)))
221+
(catch Exception _ nil)))
222+
223+
(defn- fetch-latest-release
224+
"Hit GitHub releases API for the latest release of day8/re-frame-pair.
225+
Returns {:latest <tag-without-v> :released <iso-date> :url <html-url>}
226+
or nil on any failure (timeout, network, rate-limit, parse error,
227+
empty release list). Uses /releases?per_page=1 instead of
228+
/releases/latest because /latest excludes pre-releases — and the
229+
project is in pre-release territory until v1.0."
230+
[]
231+
(try
232+
(let [resp (http/get github-releases-url
233+
{:headers {"User-Agent" "re-frame-pair-version-check"
234+
"Accept" "application/vnd.github+json"}
235+
:timeout version-check-timeout-ms})
236+
releases (json/parse-string (:body resp))
237+
latest (first releases)]
238+
(when (map? latest)
239+
{:latest (str/replace (get latest "tag_name") #"^v" "")
240+
:released (get latest "published_at")
241+
:url (get latest "html_url")}))
242+
(catch Exception _ nil)))
243+
244+
(defn- version-check
245+
"Compare the local skill version to the latest GitHub release. Returns
246+
a map suitable for splicing into discover's emit:
247+
248+
{:status :current | :stale | :unknown
249+
:current \"0.1.0-beta.6\"
250+
:latest \"0.1.0-beta.7\" ;; only when known
251+
:released \"2026-05-04T11:19:47Z\" ;; only when known
252+
:changelog \"https://...\"} ;; only when known
253+
254+
Returns nil entirely when:
255+
- The opt-out env var `RE_FRAME_PAIR_SKIP_VERSION_CHECK` is set
256+
- We can't read the local version (no package.json)
257+
258+
Failure modes that aren't outages of the local install (network down,
259+
rate limit, etc.) surface as `:status :unknown` so the caller still
260+
sees a structured result and the operator knows the check ran."
261+
[]
262+
(when-not (System/getenv "RE_FRAME_PAIR_SKIP_VERSION_CHECK")
263+
(when-let [current (current-version)]
264+
(let [cached (read-version-cache)
265+
fresh (when-not cached
266+
(when-let [data (fetch-latest-release)]
267+
(let [stamped (assoc data :checked-at
268+
(System/currentTimeMillis))]
269+
(write-version-cache stamped)
270+
stamped)))
271+
data (or cached fresh)]
272+
(cond-> {:current current}
273+
data (assoc :status (if (= current (:latest data))
274+
:current
275+
:stale)
276+
:latest (:latest data)
277+
:released (:released data)
278+
:changelog (:url data))
279+
(nil? data) (assoc :status :unknown))))))
280+
155281
;; ---------------------------------------------------------------------------
156282
;; Config / env
157283
;; ---------------------------------------------------------------------------
@@ -873,14 +999,27 @@
873999
;; produce nil. Probe failure is non-fatal; the original
8741000
;; try-and-see path handles that case.
8751001
builds (try (list-builds-on-port (read-port))
876-
(catch Exception _ nil))]
1002+
(catch Exception _ nil))
1003+
;; Compute version-check once at the top so every emit path
1004+
;; (success and structured failure) carries :version and (when
1005+
;; available) :version-check. Operators reporting bugs from a
1006+
;; failure response need to know what version they're on.
1007+
;; Returns nil only when opted out via env var or
1008+
;; package.json is unreadable; cached for 24h.
1009+
vc (version-check)
1010+
with-version (fn [m]
1011+
(cond-> m
1012+
(:current vc) (assoc :version (:current vc))
1013+
(some? vc) (assoc :version-check
1014+
(dissoc vc :current))))]
8771015
(if (ambiguous-build? explicit-build? build-id builds)
878-
(emit {:ok? false
879-
:reason :ambiguous-build
880-
:candidates (vec builds)
881-
:picked-default build-id
882-
:hint (format "Default build %s is not active on this nREPL port. Pass --build=<id> or set SHADOW_CLJS_BUILD_ID. Active builds: %s"
883-
build-id (str/join ", " (map str builds)))})
1016+
(emit (with-version
1017+
{:ok? false
1018+
:reason :ambiguous-build
1019+
:candidates (vec builds)
1020+
:picked-default build-id
1021+
:hint (format "Default build %s is not active on this nREPL port. Pass --build=<id> or set SHADOW_CLJS_BUILD_ID. Active builds: %s"
1022+
build-id (str/join ", " (map str builds)))}))
8841023
(try
8851024
(let [health (inject-runtime! build-id {:capture? capture?})
8861025
version-err (version-failure health)
@@ -896,37 +1035,41 @@
8961035
(flush)))
8971036
(cond
8981037
(not (:ok? health))
899-
(emit health)
1038+
(emit (with-version health))
9001039

9011040
(not (:ten-x-loaded? health))
902-
(emit {:ok? false :reason :ns-not-loaded :missing :re-frame-10x
903-
:hint "Add re-frame-10x to your dev deps and preloads."})
1041+
(emit (with-version
1042+
{:ok? false :reason :ns-not-loaded :missing :re-frame-10x
1043+
:hint "Add re-frame-10x to your dev deps and preloads."}))
9041044

9051045
(not (:trace-enabled? health))
906-
(emit {:ok? false :reason :trace-enabled-false
907-
:hint "Set re-frame.trace.trace-enabled? to true via :closure-defines."})
1046+
(emit (with-version
1047+
{:ok? false :reason :trace-enabled-false
1048+
:hint "Set re-frame.trace.trace-enabled? to true via :closure-defines."}))
9081049

9091050
(some? version-err)
910-
(emit version-err)
1051+
(emit (with-version version-err))
9111052

9121053
:else
913-
(emit (cond-> health
914-
true (assoc :ok? true
915-
:build-id build-id
916-
:startup-context context)
917-
(not capture?) (assoc :capture-skipped? true)
918-
(not (:re-com-debug? health)) (assoc :warning :re-com-debug-disabled
919-
:note "DOM ↔ source ops will degrade; otherwise functional.")
920-
;; Multi-build wins as the structured :warning when
921-
;; both apply — it's likely the cause of any other
922-
;; surprises (wrong build picked).
923-
multi? (assoc :warning :multiple-builds
924-
:picked build-id
925-
:others (vec (remove #(= % build-id) builds)))))))
1054+
(emit (with-version
1055+
(cond-> health
1056+
true (assoc :ok? true
1057+
:build-id build-id
1058+
:startup-context context)
1059+
(not capture?) (assoc :capture-skipped? true)
1060+
(not (:re-com-debug? health)) (assoc :warning :re-com-debug-disabled
1061+
:note "DOM ↔ source ops will degrade; otherwise functional.")
1062+
;; Multi-build wins as the structured :warning when
1063+
;; both apply — it's likely the cause of any other
1064+
;; surprises (wrong build picked).
1065+
multi? (assoc :warning :multiple-builds
1066+
:picked build-id
1067+
:others (vec (remove #(= % build-id) builds))))))))
9261068
(catch Exception e
927-
(emit {:ok? false
928-
:reason (or (:reason (ex-data e)) :unknown)
929-
:message (.getMessage e)}))))))
1069+
(emit (with-version
1070+
{:ok? false
1071+
:reason (or (:reason (ex-data e)) :unknown)
1072+
:message (.getMessage e)})))))))
9301073

9311074
;; ---------------------------------------------------------------------------
9321075
;; Subcommand: eval

0 commit comments

Comments
 (0)