* fix(claude): restore namespaced /gsd:<command> references * test(claude): align slash-command expectations to /gsd: form * test(claude): align generated command references to /gsd: * test(claude): finish /gsd: namespace expectation updates
135 lines
4.4 KiB
JavaScript
Executable File
135 lines
4.4 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// gsd-hook-version: {{GSD_VERSION}}
|
|
// SessionStart banner that surfaces GSD update availability when GSD's
|
|
// statusline isn't installed. Reads the cache that
|
|
// gsd-check-update-worker.js writes to ~/.cache/gsd/gsd-update-check.json.
|
|
//
|
|
// Opt-in by design: bin/install.js only registers this hook when the user
|
|
// declines to install (or replace) the GSD statusline. The presence of the
|
|
// SessionStart entry IS the opt-in — there is no separate runtime flag.
|
|
//
|
|
// See issue #2795 for the rationale.
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
|
|
// Suppress repeat parse-error banners for 24 hours so a genuinely broken
|
|
// cache file doesn't nag the user every session.
|
|
const RATE_LIMIT_SECONDS = 24 * 60 * 60;
|
|
|
|
/**
|
|
* Build the SessionStart JSON envelope to emit, given parsed cache state.
|
|
* Pure function — no I/O. Returns null when the hook should print nothing.
|
|
*
|
|
* @param {object} state
|
|
* @param {object|null} state.cache Parsed cache, or null if missing/unreadable.
|
|
* @param {boolean} state.parseError True iff cache file existed but JSON.parse failed.
|
|
* @param {boolean} state.suppressFailureWarning True when a recent failure warning already fired.
|
|
* @returns {{systemMessage: string}|null} JSON envelope, or null for silent exit.
|
|
*/
|
|
function buildBannerOutput(state) {
|
|
const { cache, parseError, suppressFailureWarning } = state || {};
|
|
if (parseError) {
|
|
if (suppressFailureWarning) return null;
|
|
return { systemMessage: 'GSD update check failed.' };
|
|
}
|
|
if (!cache) return null;
|
|
if (!cache.update_available) return null;
|
|
const installed = cache.installed || 'unknown';
|
|
const latest = cache.latest || 'unknown';
|
|
return {
|
|
systemMessage: `GSD update available: ${installed} → ${latest}. Run /gsd:update.`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Read and parse the update-check cache file.
|
|
*
|
|
* @param {string} cacheFile
|
|
* @returns {{cache: object|null, parseError: boolean}}
|
|
*/
|
|
function readCache(cacheFile) {
|
|
let cache = null;
|
|
let parseError = false;
|
|
try {
|
|
if (fs.existsSync(cacheFile)) {
|
|
const raw = fs.readFileSync(cacheFile, 'utf8');
|
|
cache = JSON.parse(raw);
|
|
}
|
|
} catch (e) {
|
|
// Distinguish "file unreadable" from "JSON malformed": both fail-open to
|
|
// null cache, but a JSON parse error becomes a one-time diagnostic.
|
|
parseError = e instanceof SyntaxError;
|
|
}
|
|
return { cache, parseError };
|
|
}
|
|
|
|
/**
|
|
* Has a failure warning been emitted within the rate-limit window?
|
|
*
|
|
* @param {string} sentinelFile
|
|
* @param {number} nowSeconds
|
|
* @returns {boolean}
|
|
*/
|
|
function shouldSuppressFailureWarning(sentinelFile, nowSeconds) {
|
|
try {
|
|
if (!fs.existsSync(sentinelFile)) return false;
|
|
const last = parseInt(fs.readFileSync(sentinelFile, 'utf8').trim(), 10);
|
|
if (!Number.isFinite(last)) return false;
|
|
return nowSeconds - last < RATE_LIMIT_SECONDS;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function recordFailureWarning(sentinelFile, nowSeconds) {
|
|
try {
|
|
fs.writeFileSync(sentinelFile, String(nowSeconds));
|
|
} catch (e) {
|
|
// Best-effort: a non-writable cache dir means we'll re-warn next session,
|
|
// which is no worse than the un-instrumented baseline.
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
const cacheDir = path.join(os.homedir(), '.cache', 'gsd');
|
|
const cacheFile = path.join(cacheDir, 'gsd-update-check.json');
|
|
const sentinelFile = path.join(cacheDir, 'banner-failure-warned-at');
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
const { cache, parseError } = readCache(cacheFile);
|
|
const suppressFailureWarning = parseError
|
|
? shouldSuppressFailureWarning(sentinelFile, now)
|
|
: false;
|
|
const output = buildBannerOutput({ cache, parseError, suppressFailureWarning });
|
|
|
|
if (parseError && !suppressFailureWarning) {
|
|
// Ensure cache dir exists before writing the sentinel — first-run case
|
|
// where ~/.cache/gsd was created by check-update but the parent dir got
|
|
// wiped between runs.
|
|
try {
|
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
} catch (e) {
|
|
// Best-effort: failure to create the dir means we'll re-warn next
|
|
// session, which is no worse than the un-instrumented baseline.
|
|
}
|
|
recordFailureWarning(sentinelFile, now);
|
|
}
|
|
|
|
if (output) {
|
|
process.stdout.write(JSON.stringify(output));
|
|
}
|
|
}
|
|
|
|
if (require.main === module) main();
|
|
|
|
module.exports = {
|
|
buildBannerOutput,
|
|
readCache,
|
|
shouldSuppressFailureWarning,
|
|
RATE_LIMIT_SECONDS,
|
|
};
|