|
| 1 | +// 新版本提醒器:每次跑 CLI 命令时,在命令前打一条简短提示(如果缓存里 |
| 2 | +// 已知有新版本),在命令后用最长 1.5s 的超时悄悄刷新一次缓存供下次使用。 |
| 3 | +// |
| 4 | +// 设计目标: |
| 5 | +// 1. 零延迟感——同步段只读本地 JSON 文件,毫秒级;异步段只在缓存过期 |
| 6 | +// 那次跑命令时多花 ≤1.5s,且失败静默 |
| 7 | +// 2. 零网络浪费——24h 才查一次 npm registry |
| 8 | +// 3. 零依赖——纯 Node 内置 fetch + fs |
| 9 | +// 4. 可禁用——UNIVERSAL_IMAGE_SKIP_UPDATE_NOTIFIER=1 或 CI=true |
| 10 | + |
| 11 | +import fs from 'node:fs/promises' |
| 12 | +import path from 'node:path' |
| 13 | +import os from 'node:os' |
| 14 | +import { PACKAGE_NAME } from './paths.mjs' |
| 15 | +import { compareSemver } from './update.mjs' |
| 16 | + |
| 17 | +// 缓存放在 ~/.claude/ 下,跟 skills/ 同级,方便用户排查/清理 |
| 18 | +// lazy 计算:让测试可以通过覆盖 HOME / USERPROFILE 重定向到临时目录 |
| 19 | +function getCachePath() { |
| 20 | + return path.join(os.homedir(), '.claude', 'universal-image-cache.json') |
| 21 | +} |
| 22 | +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 // 24h |
| 23 | +const FETCH_TIMEOUT_MS = 1500 |
| 24 | + |
| 25 | +// npm registry URL 也允许测试覆盖(指向 mockHttpServer) |
| 26 | +function getRegistryUrl() { |
| 27 | + return process.env.UNIVERSAL_IMAGE_REGISTRY_URL || 'https://registry.npmjs.org' |
| 28 | +} |
| 29 | + |
| 30 | +function isDisabled() { |
| 31 | + return process.env.UNIVERSAL_IMAGE_SKIP_UPDATE_NOTIFIER === '1' |
| 32 | + || process.env.CI === 'true' |
| 33 | +} |
| 34 | + |
| 35 | +async function readCache() { |
| 36 | + try { |
| 37 | + const raw = await fs.readFile(getCachePath(), 'utf8') |
| 38 | + return JSON.parse(raw) |
| 39 | + } catch { |
| 40 | + return null |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +async function writeCache(data) { |
| 45 | + try { |
| 46 | + const p = getCachePath() |
| 47 | + await fs.mkdir(path.dirname(p), { recursive: true }) |
| 48 | + await fs.writeFile(p, JSON.stringify(data, null, 2), 'utf8') |
| 49 | + return true |
| 50 | + } catch { |
| 51 | + return false |
| 52 | + } |
| 53 | +} |
| 54 | + |
| 55 | +async function fetchLatestVersion() { |
| 56 | + const controller = new AbortController() |
| 57 | + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) |
| 58 | + try { |
| 59 | + const res = await fetch(`${getRegistryUrl()}/${PACKAGE_NAME}`, { |
| 60 | + headers: { |
| 61 | + 'Accept': 'application/json', |
| 62 | + 'User-Agent': 'universal-image-skill-cli', |
| 63 | + }, |
| 64 | + signal: controller.signal, |
| 65 | + }) |
| 66 | + if (!res.ok) throw new Error(`HTTP ${res.status}`) |
| 67 | + const data = await res.json() |
| 68 | + const latest = data?.['dist-tags']?.latest |
| 69 | + if (!latest) throw new Error('no latest tag') |
| 70 | + return latest |
| 71 | + } finally { |
| 72 | + clearTimeout(timer) |
| 73 | + } |
| 74 | +} |
| 75 | + |
| 76 | +/** |
| 77 | + * 命令开始前调用:根据缓存比对版本,如有新版本则打一条提示。 |
| 78 | + * 只读本地文件,不发请求,瞬时返回。 |
| 79 | + */ |
| 80 | +export async function showNotificationIfAvailable(currentVersion) { |
| 81 | + if (isDisabled()) return |
| 82 | + if (!currentVersion) return |
| 83 | + const cache = await readCache() |
| 84 | + if (!cache?.latestVersion) return |
| 85 | + if (compareSemver(cache.latestVersion, currentVersion) > 0) { |
| 86 | + console.log('') |
| 87 | + console.log(`ℹ 新版本可用: v${currentVersion} → v${cache.latestVersion}`) |
| 88 | + console.log(` 运行 \`universal-image-skill update\` 一键升级`) |
| 89 | + console.log('') |
| 90 | + } |
| 91 | +} |
| 92 | + |
| 93 | +/** |
| 94 | + * 命令结束后调用:缓存过期才发请求查 registry。失败/超时静默忽略。 |
| 95 | + * 即便 await 也最多多花 1.5s(FETCH_TIMEOUT_MS)。 |
| 96 | + */ |
| 97 | +export async function refreshCacheIfStale() { |
| 98 | + if (isDisabled()) return |
| 99 | + const cache = await readCache() |
| 100 | + if (cache && Number.isFinite(cache.lastCheck) |
| 101 | + && Date.now() - cache.lastCheck < CHECK_INTERVAL_MS) { |
| 102 | + return |
| 103 | + } |
| 104 | + try { |
| 105 | + const latest = await fetchLatestVersion() |
| 106 | + await writeCache({ lastCheck: Date.now(), latestVersion: latest }) |
| 107 | + } catch { |
| 108 | + // 静默忽略,等下一次跑命令时再试 |
| 109 | + } |
| 110 | +} |
| 111 | + |
| 112 | +/** |
| 113 | + * 让别的模块(如 update 命令)查到 latest 后顺手喂给缓存, |
| 114 | + * 这样后续 refreshCacheIfStale 就不会再触发一次重复请求。 |
| 115 | + */ |
| 116 | +export async function recordLatestVersion(latestVersion) { |
| 117 | + if (!latestVersion) return |
| 118 | + await writeCache({ lastCheck: Date.now(), latestVersion }) |
| 119 | +} |
| 120 | + |
| 121 | +// 测试用:暴露 lazy getter 让测试断言缓存路径 |
| 122 | +export const __test = { getCachePath, CHECK_INTERVAL_MS, FETCH_TIMEOUT_MS } |
0 commit comments