ktg-plugin-marketplace/plugins/llm-security/hooks/scripts/update-check.mjs

140 lines
4.3 KiB
JavaScript

#!/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);
}