Skip to content

Commit b402c8c

Browse files
committed
refactor: hexagonal update-check with Alfred resilience (#287)
- createUpdateChecker(ports) with DI for fetchLatest, readCache, writeCache - defaultPorts() wires real fs + fetch + @git-stunts/alfred (lazy-loaded) - Alfred policy: 3s timeout wrapping 1 retry with decorrelated jitter - External AbortSignal threaded through retry for CLI cancellation - GIT_MIND_DISABLE_UPGRADE_CHECK env guard at composition root - Tests use injected fakes — no network, no filesystem - Split test scripts: npm test (unit), npm run test:integration (Docker)
1 parent 0e298da commit b402c8c

5 files changed

Lines changed: 321 additions & 99 deletions

File tree

bin/git-mind.js

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,24 @@ import { init, link, view, list, remove, nodes, status, at, importCmd, importMar
99
import { parseDiffRefs, collectDiffPositionals } from '../src/diff.js';
1010
import { createContext } from '../src/context-envelope.js';
1111
import { registerBuiltinExtensions } from '../src/extension.js';
12-
import { VERSION } from '../src/version.js';
13-
import { getUpdateNotification, triggerUpdateCheck } from '../src/update-check.js';
12+
import { VERSION, NAME } from '../src/version.js';
13+
import { createUpdateChecker, defaultPorts } from '../src/update-check.js';
1414

1515
const args = process.argv.slice(2);
1616
const command = args[0];
1717
const cwd = process.cwd();
1818
const jsonMode = args.includes('--json');
1919

20-
// Fire-and-forget: fetch latest version in background
21-
triggerUpdateCheck();
20+
// Wire update checker — real adapters in production, no-op when suppressed
21+
const updateController = new AbortController();
22+
const checker = process.env.GIT_MIND_DISABLE_UPGRADE_CHECK
23+
? { getNotification: () => null, triggerCheck: () => {} }
24+
: createUpdateChecker({
25+
...defaultPorts(`https://registry.npmjs.org/${NAME}/latest`),
26+
currentVersion: VERSION,
27+
packageName: NAME,
28+
});
29+
checker.triggerCheck(updateController.signal);
2230

2331
function printUsage() {
2432
console.log(`git-mind v${VERSION}
@@ -514,8 +522,11 @@ switch (command) {
514522
break;
515523
}
516524

525+
// Cancel background fetch — don't hold the process open
526+
updateController.abort();
527+
517528
// Show update notification on stderr (never in --json mode)
518529
if (!jsonMode) {
519-
const note = getUpdateNotification();
530+
const note = checker.getNotification();
520531
if (note) process.stderr.write(note);
521532
}

package-lock.json

Lines changed: 13 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,15 @@
3131
"access": "public"
3232
},
3333
"scripts": {
34-
"test": "vitest run",
34+
"test": "vitest run --exclude test/contracts.integration.test.js --exclude test/content.test.js --exclude test/version.test.js",
35+
"test:integration": "docker build -f test/Dockerfile -t git-mind-integration . && docker run --rm git-mind-integration",
36+
"test:all": "npm test && npm run test:integration",
3537
"test:watch": "vitest",
3638
"lint": "eslint src/ bin/",
3739
"format": "prettier --write 'src/**/*.js' 'bin/**/*.js'"
3840
},
3941
"dependencies": {
42+
"@git-stunts/alfred": "^0.10.3",
4043
"@git-stunts/git-warp": "^11.5.0",
4144
"@git-stunts/plumbing": "^2.8.0",
4245
"ajv": "^8.17.1",

src/update-check.js

Lines changed: 119 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,28 @@
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

1217
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
1318
import { join } from 'node:path';
1419
import { homedir } from 'node:os';
15-
import { NAME, VERSION } from './version.js';
1620

1721
const CACHE_DIR = join(homedir(), '.gitmind');
1822
const 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

Comments
 (0)