#!/usr/bin/env node // Hook: update-check.mjs // Event: UserPromptSubmit // Purpose: Check for newer plugin versions (max 1x/24h, cached). // // Protocol: // - Read JSON from stdin (consume, don't use) // - If newer version available: exit 0, stdout JSON { systemMessage: "..." } // - Otherwise: exit 0 silently // - Never block the user (always exit 0) // // Disable: LLM_SECURITY_UPDATE_CHECK=off import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; // --------------------------------------------------------------------------- // Exports for testing // --------------------------------------------------------------------------- export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours /** * Return true if `remote` is a newer semver than `local`. * Simple numeric comparison — no pre-release/build metadata. */ export function isNewer(remote, local) { const r = remote.split('.').map(Number); const l = local.split('.').map(Number); for (let i = 0; i < Math.max(r.length, l.length); i++) { const rv = r[i] ?? 0; const lv = l[i] ?? 0; if (rv > lv) return true; if (rv < lv) return false; } return false; } // --------------------------------------------------------------------------- // Main (only runs when executed directly, not when imported for tests) // --------------------------------------------------------------------------- const __dirname = dirname(fileURLToPath(import.meta.url)); const isDirectExecution = process.argv[1] && resolve(process.argv[1]) === resolve(__dirname, 'update-check.mjs'); if (isDirectExecution) { main().catch(() => process.exit(0)); } async function main() { // Opt-out if (process.env.LLM_SECURITY_UPDATE_CHECK === 'off') { process.exit(0); } // Consume stdin (prevent pipe errors) try { readFileSync(0, 'utf8'); } catch { /* ignore */ } // Resolve plugin root const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || resolve(__dirname, '../..'); // Read installed version let installed; try { const pluginJson = JSON.parse(readFileSync(resolve(pluginRoot, '.claude-plugin/plugin.json'), 'utf8')); installed = pluginJson.version; } catch { process.exit(0); } // Read repo URL let repoUrl; try { const pkg = JSON.parse(readFileSync(resolve(pluginRoot, 'package.json'), 'utf8')); repoUrl = pkg.repository?.url; } catch { process.exit(0); } if (!installed || !repoUrl) process.exit(0); // Cache const cacheDir = resolve(homedir(), '.cache/llm-security'); const cachePath = resolve(cacheDir, 'update-check.json'); // Check cache try { if (existsSync(cachePath)) { const cache = JSON.parse(readFileSync(cachePath, 'utf8')); if (Date.now() - cache.checkedAt < CHECK_INTERVAL_MS) { // Cache is fresh if (cache.latestVersion && isNewer(cache.latestVersion, installed)) { console.log(JSON.stringify({ systemMessage: `🔄 llm-security v${installed} → v${cache.latestVersion} available. Update: ${repoUrl}` })); } process.exit(0); } } } catch { // Corrupt cache — proceed to fetch } // Fetch latest version from Forgejo raw API const fetchUrl = `${repoUrl}/raw/branch/main/.claude-plugin/plugin.json`; try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 3000); const res = await fetch(fetchUrl, { signal: controller.signal }); clearTimeout(timeout); if (!res.ok) process.exit(0); const remote = JSON.parse(await res.text()); const latestVersion = remote.version; if (!latestVersion) process.exit(0); // Write cache try { mkdirSync(cacheDir, { recursive: true }); writeFileSync(cachePath, JSON.stringify({ checkedAt: Date.now(), latestVersion })); } catch { // Cache write failure is non-fatal } // Notify if newer if (isNewer(latestVersion, installed)) { console.log(JSON.stringify({ systemMessage: `🔄 llm-security v${installed} → v${latestVersion} available. Update: ${repoUrl}` })); } } catch { // Network error, timeout, parse error — silent exit } process.exit(0); }