|
23 | 23 | ;;;; guessing. |
24 | 24 |
|
25 | 25 | (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] |
27 | 29 | [clojure.java.io :as io] |
28 | 30 | [clojure.string :as str] |
29 | 31 | [clojure.tools.reader :as tr] |
|
152 | 154 | {:out "" :err ""} |
153 | 155 | responses)) |
154 | 156 |
|
| 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 | + |
155 | 281 | ;; --------------------------------------------------------------------------- |
156 | 282 | ;; Config / env |
157 | 283 | ;; --------------------------------------------------------------------------- |
|
873 | 999 | ;; produce nil. Probe failure is non-fatal; the original |
874 | 1000 | ;; try-and-see path handles that case. |
875 | 1001 | 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))))] |
877 | 1015 | (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)))})) |
884 | 1023 | (try |
885 | 1024 | (let [health (inject-runtime! build-id {:capture? capture?}) |
886 | 1025 | version-err (version-failure health) |
|
896 | 1035 | (flush))) |
897 | 1036 | (cond |
898 | 1037 | (not (:ok? health)) |
899 | | - (emit health) |
| 1038 | + (emit (with-version health)) |
900 | 1039 |
|
901 | 1040 | (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."})) |
904 | 1044 |
|
905 | 1045 | (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."})) |
908 | 1049 |
|
909 | 1050 | (some? version-err) |
910 | | - (emit version-err) |
| 1051 | + (emit (with-version version-err)) |
911 | 1052 |
|
912 | 1053 | :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)))))))) |
926 | 1068 | (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)}))))))) |
930 | 1073 |
|
931 | 1074 | ;; --------------------------------------------------------------------------- |
932 | 1075 | ;; Subcommand: eval |
|
0 commit comments