22 * @module update-check
33 * Non-blocking update check against the npm registry.
44 *
5- * Design:
6- * - triggerUpdateCheck() fires a fetch in the background (never awaited)
7- * - getUpdateNotification() reads the PREVIOUS run's cache (sync)
5+ * Hexagonal design:
6+ * - createUpdateChecker(ports) — pure domain logic, no I/O at module level
7+ * - Ports: fetchLatest, readCache, writeCache (injected)
8+ * - defaultPorts() — real adapters: fs for cache, fetch + @git-stunts/alfred for registry
9+ * - Tests inject fakes; CLI wires real adapters
10+ *
11+ * Flow:
12+ * - triggerCheck(signal?) fires a background fetch (never awaited)
13+ * - getNotification() reads the PREVIOUS run's cache (sync)
814 * - Cache lives at ~/.gitmind/update-check.json with 24h TTL
9- * - All errors silently swallowed — must never break any command
1015 */
1116
1217import { readFileSync , writeFileSync , mkdirSync } from 'node:fs' ;
1318import { join } from 'node:path' ;
1419import { homedir } from 'node:os' ;
15- import { NAME , VERSION } from './version.js' ;
1620
1721const CACHE_DIR = join ( homedir ( ) , '.gitmind' ) ;
1822const CACHE_FILE = join ( CACHE_DIR , 'update-check.json' ) ;
19- const CACHE_TTL_MS = 24 * 60 * 60 * 1000 ; // 24 hours
20- const FETCH_TIMEOUT_MS = 3000 ;
21- const REGISTRY_URL = `https://registry.npmjs.org/${ NAME } /latest` ;
23+ const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000 ; // 24 hours
24+ const DEFAULT_FETCH_TIMEOUT_MS = 3000 ;
25+
26+ // ── Pure domain logic (no I/O) ──────────────────────────────────
2227
2328/**
2429 * Compare two semver strings. Returns true if remote > local.
@@ -38,67 +43,124 @@ export function isNewer(local, remote) {
3843 return false ;
3944}
4045
41- /**
42- * Read cached update check result (sync).
43- * Returns a notification string if a newer version is available, null otherwise.
44- * @returns {string|null }
45- */
46- export function getUpdateNotification ( ) {
47- try {
48- const data = JSON . parse ( readFileSync ( CACHE_FILE , 'utf-8' ) ) ;
49- if ( ! data . latest || ! isNewer ( VERSION , data . latest ) ) return null ;
50- return formatNotification ( data . latest ) ;
51- } catch {
52- return null ;
53- }
54- }
55-
5646/**
5747 * Format the update notification banner.
5848 * @param {string } latest
49+ * @param {string } currentVersion
50+ * @param {string } packageName
5951 * @returns {string }
6052 */
61- export function formatNotification ( latest ) {
62- // Dynamic imports would be async; chalk and figures are already deps so
63- // we can import them at module level, but to keep this module light for
64- // the sync path, we do a simple string.
65- return `\n Update available: ${ VERSION } → ${ latest } \n Run \`npm install -g ${ NAME } \` to update\n` ;
53+ export function formatNotification ( latest , currentVersion , packageName ) {
54+ return `\n Update available: ${ currentVersion } → ${ latest } \n Run \`npm install -g ${ packageName } \` to update\n` ;
6655}
6756
6857/**
69- * Fire-and-forget: fetch latest version from npm registry and write cache.
70- * Never throws — all errors silently swallowed.
58+ * @typedef {object } UpdateCheckPorts
59+ * @property {(signal?: AbortSignal) => Promise<string|null> } fetchLatest Fetch latest version string from registry
60+ * @property {() => {latest: string, checkedAt: number}|null } readCache Read cached check result (sync)
61+ * @property {(data: {latest: string, checkedAt: number}) => void } writeCache Write check result to cache
62+ * @property {string } currentVersion Current installed version
63+ * @property {string } packageName Package name (for notification)
64+ * @property {number } [cacheTtlMs] Cache TTL in ms (default 24h)
7165 */
72- export function triggerUpdateCheck ( ) {
73- _doCheck ( ) . catch ( ( ) => { } ) ;
74- }
7566
76- /** @internal */
77- async function _doCheck ( ) {
78- // Skip if cache is fresh
79- try {
80- const data = JSON . parse ( readFileSync ( CACHE_FILE , 'utf-8' ) ) ;
81- if ( Date . now ( ) - data . checkedAt < CACHE_TTL_MS ) return ;
82- } catch {
83- // No cache or corrupt — proceed with fetch
67+ /**
68+ * Create an update checker service.
69+ * @param {UpdateCheckPorts } ports
70+ * @returns {{ getNotification: () => string|null, triggerCheck: (signal?: AbortSignal) => void } }
71+ */
72+ export function createUpdateChecker ( ports ) {
73+ const { fetchLatest, readCache, writeCache, currentVersion, packageName,
74+ cacheTtlMs = DEFAULT_CACHE_TTL_MS } = ports ;
75+
76+ function getNotification ( ) {
77+ try {
78+ const data = readCache ( ) ;
79+ if ( ! data ?. latest || ! isNewer ( currentVersion , data . latest ) ) return null ;
80+ return formatNotification ( data . latest , currentVersion , packageName ) ;
81+ } catch {
82+ return null ;
83+ }
8484 }
8585
86- const controller = new AbortController ( ) ;
87- const timer = setTimeout ( ( ) => controller . abort ( ) , FETCH_TIMEOUT_MS ) ;
88-
89- try {
90- const res = await fetch ( REGISTRY_URL , {
91- signal : controller . signal ,
92- headers : { Accept : 'application/json' } ,
93- } ) ;
94- if ( ! res . ok ) return ;
95- const body = await res . json ( ) ;
96- const latest = body . version ;
97- if ( ! latest ) return ;
86+ function triggerCheck ( signal ) {
87+ _doCheck ( signal ) . catch ( ( ) => { } ) ;
88+ }
89+
90+ async function _doCheck ( signal ) {
91+ // Skip if cache is fresh
92+ try {
93+ const data = readCache ( ) ;
94+ if ( data && Date . now ( ) - data . checkedAt < cacheTtlMs ) return ;
95+ } catch {
96+ // No cache or corrupt — proceed with fetch
97+ }
9898
99- mkdirSync ( CACHE_DIR , { recursive : true } ) ;
100- writeFileSync ( CACHE_FILE , JSON . stringify ( { latest, checkedAt : Date . now ( ) } ) ) ;
101- } finally {
102- clearTimeout ( timer ) ;
99+ const latest = await fetchLatest ( signal ) ;
100+ if ( ! latest ) return ;
101+ writeCache ( { latest, checkedAt : Date . now ( ) } ) ;
103102 }
103+
104+ return { getNotification, triggerCheck } ;
105+ }
106+
107+ // ── Default adapters (real I/O) ─────────────────────────────────
108+
109+ /**
110+ * Build real adapters for production use.
111+ * Alfred is lazy-loaded only when a fetch is needed.
112+ * @param {string } registryUrl npm registry URL for the package
113+ * @param {object } [opts]
114+ * @param {number } [opts.timeoutMs] Fetch timeout (default 3000)
115+ * @returns {Pick<UpdateCheckPorts, 'fetchLatest' | 'readCache' | 'writeCache'> }
116+ */
117+ export function defaultPorts ( registryUrl , opts = { } ) {
118+ const { timeoutMs = DEFAULT_FETCH_TIMEOUT_MS } = opts ;
119+
120+ /** @type {import('@git-stunts/alfred').Policy|null } */
121+ let policy = null ;
122+
123+ return {
124+ readCache ( ) {
125+ try {
126+ return JSON . parse ( readFileSync ( CACHE_FILE , 'utf-8' ) ) ;
127+ } catch {
128+ return null ;
129+ }
130+ } ,
131+
132+ writeCache ( data ) {
133+ mkdirSync ( CACHE_DIR , { recursive : true } ) ;
134+ writeFileSync ( CACHE_FILE , JSON . stringify ( data ) ) ;
135+ } ,
136+
137+ async fetchLatest ( signal ) {
138+ // Lazy-load Alfred on first fetch
139+ const { Policy } = await import ( '@git-stunts/alfred' ) ;
140+
141+ // External signal goes on retry options for cancellation;
142+ // timeout's internal signal is passed to fn for fetch abort.
143+ const registryPolicy = Policy . timeout ( timeoutMs )
144+ . wrap ( Policy . retry ( {
145+ retries : 1 ,
146+ delay : 200 ,
147+ backoff : 'exponential' ,
148+ jitter : 'decorrelated' ,
149+ signal,
150+ } ) ) ;
151+
152+ const body = await registryPolicy . execute (
153+ ( timeoutSignal ) =>
154+ fetch ( registryUrl , {
155+ signal : timeoutSignal ,
156+ headers : { Accept : 'application/json' } ,
157+ } ) . then ( ( res ) => {
158+ if ( ! res . ok ) throw new Error ( `registry ${ res . status } ` ) ;
159+ return res . json ( ) ;
160+ } ) ,
161+ ) ;
162+
163+ return body . version || null ;
164+ } ,
165+ } ;
104166}
0 commit comments