Files

535 lines
13 KiB
JavaScript

#!/usr/bin/env node
// Daily background updater for wind-mcp-skill.
// The CLI starts this script detached; failures are recorded but never block data calls.
import {
closeSync,
existsSync,
mkdirSync,
openSync,
readFileSync,
readdirSync,
statSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import {
createHash
} from 'node:crypto';
import {
spawnSync
} from 'node:child_process';
import {
homedir
} from 'node:os';
import {
basename,
dirname,
join,
resolve
} from 'node:path';
import {
fileURLToPath
} from 'node:url';
const SCRIPT_DIR = dirname(fileURLToPath(
import.meta.url));
const SKILL_DIR = process.argv[2] ? resolve(process.argv[2]) : dirname(SCRIPT_DIR);
const SKILL_SCRIPTS_DIR = join(SKILL_DIR, 'scripts');
const LOCK_FILE = join(SKILL_SCRIPTS_DIR, 'update.lock');
const SKILL_NAME = basename(SKILL_DIR);
const DEFAULT_SOURCES = [
'Wind-Information-Co-Ltd/wind-skills',
'git@gitee.com:wind_info/wind-skills.git',
];
const LOCK_STALE_MS = 30 * 60 * 1000;
const QUIET_MS = 10 * 1000;
const MAX_WAIT_MS = 10 * 60 * 1000;
function normalizePath(value) {
const normalized = resolve(value).replace(/\\/g, '/');
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
function updateScope() {
const globalRoot = normalizePath(join(homedir(), '.agents', 'skills'));
const skillDir = normalizePath(SKILL_DIR);
return skillDir.startsWith(`${globalRoot}/`) ? 'global' : 'project';
}
function projectRoot() {
return resolve(SKILL_DIR, '..', '..', '..');
}
function uniquePaths(paths) {
const seen = new Set();
const result = [];
for (const path of paths.filter(Boolean).map((value) => resolve(value))) {
const key = normalizePath(path);
if (seen.has(key)) continue;
seen.add(key);
result.push(path);
}
return result;
}
function updateCommand() {
const command = ['npx', 'skills', 'update', SKILL_NAME, '-y'];
if (updateScope() === 'global') command.push('-g');
return command;
}
function projectLockCandidates() {
const roots = [projectRoot(), process.cwd(), process.env.INIT_CWD];
let current = resolve(SKILL_DIR);
while (true) {
roots.push(current);
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
return uniquePaths(
roots.filter(Boolean).map((root) => join(root, 'skills-lock.json')),
);
}
function globalLockCandidates() {
const xdg = process.env.XDG_STATE_HOME;
return uniquePaths([
xdg ? join(xdg, 'skills', '.skill-lock.json') : null,
join(homedir(), '.agents', '.skill-lock.json'),
]);
}
function lockFileCandidates() {
const globalCandidates = globalLockCandidates();
const projectCandidates = projectLockCandidates();
return updateScope() === 'global' ?
uniquePaths([...globalCandidates, ...projectCandidates]) :
uniquePaths([...projectCandidates, ...globalCandidates]);
}
function readLockInfo() {
const candidates = lockFileCandidates();
let firstExistingFile = null;
for (const file of candidates) {
try {
if (!existsSync(file)) continue;
firstExistingFile ||= file;
const data = JSON.parse(readFileSync(file, 'utf8'));
const entry = data?.skills?.[SKILL_NAME] || null;
if (entry) return {
file,
entry,
candidates
};
} catch {}
}
return {
file: firstExistingFile || candidates[0] || null,
entry: null,
candidates,
};
}
function readLockEntry() {
return readLockInfo().entry;
}
function isGiteeSource(entry) {
const values = [entry?.sourceType, entry?.source, entry?.sourceUrl]
.filter(Boolean)
.map((value) => String(value).toLowerCase());
return values.some((value) => value.includes('gitee'));
}
function sourceUrl(entry) {
if (!entry) return null;
if (entry.sourceUrl) return entry.sourceUrl;
if (entry.sourceType === 'github' && /^[^/\s]+\/[^/\s]+$/.test(entry.source || '')) {
return `https://github.com/${entry.source}.git`;
}
if (
(entry.sourceType === 'gitee' || entry.sourceType === 'git') &&
/^[^/\s]+\/[^/\s]+$/.test(entry.source || '')
) {
return `https://gitee.com/${entry.source}.git`;
}
return entry.source || null;
}
function updateEnv() {
return {
...process.env
};
}
function remoteHead(entry) {
const source = sourceUrl(entry);
if (!source) return null;
try {
const result = spawnSync('git', ['ls-remote', source, 'HEAD'], {
encoding: 'utf8',
env: updateEnv(),
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 60 * 1000,
windowsHide: true,
});
if (result.status !== 0) return null;
const head = (result.stdout || '').trim().split(/\s+/)[0];
return /^[0-9a-f]{40}$/i.test(head) ? head : null;
} catch {
return null;
}
}
function addCommandForSource(source) {
if (!source) return null;
const command = ['npx', 'skills', 'add', source, '--skill', SKILL_NAME, '-y'];
if (updateScope() === 'global') command.push('-g');
return command;
}
function addCommand(entry) {
return addCommandForSource(sourceUrl(entry));
}
function fallbackAddCommands(entry) {
const sources = [sourceUrl(entry), ...DEFAULT_SOURCES];
const seen = new Set();
return sources
.filter(Boolean)
.filter((source) => {
const key = String(source).toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.map(addCommandForSource)
.filter(Boolean);
}
function commandForUpdate() {
const entry = readLockEntry();
if (isGiteeSource(entry)) {
const command = addCommand(entry);
if (command) {
return {
command,
method: 'add',
sourceType: entry?.sourceType || null,
};
}
}
return {
command: updateCommand(),
method: 'update',
sourceType: entry?.sourceType || null,
};
}
function updateStateFile() {
return join(SKILL_SCRIPTS_DIR, 'update-state.json');
}
function todayKey() {
return new Date().toISOString().slice(0, 10);
}
function readState() {
try {
const stateFile = updateStateFile();
if (!existsSync(stateFile)) return null;
return JSON.parse(readFileSync(stateFile, 'utf8'));
} catch {
return null;
}
}
function alreadyUpdatedToday() {
const state = readState();
if (!state || state.date !== todayKey() || state.status !== 'success') return false;
const entry = readLockEntry();
if (!entry || isGiteeSource(entry)) return true;
const head = remoteHead(entry);
return !head || head === state.lastAppliedRemoteHead;
}
function lastUsedAt() {
try {
const state = readState();
const timestamp = new Date(state?.lastUsedAt).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;
} catch {
return 0;
}
}
function quietLongEnough() {
const last = lastUsedAt();
return last === 0 || Date.now() - last >= QUIET_MS;
}
function sleep(ms) {
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
}
async function waitForQuietWindow() {
const startedAt = Date.now();
while (!quietLongEnough()) {
if (Date.now() - startedAt >= MAX_WAIT_MS) return false;
await sleep(QUIET_MS);
}
return true;
}
function acquireLock() {
try {
if (!existsSync(SKILL_SCRIPTS_DIR)) mkdirSync(SKILL_SCRIPTS_DIR, {
recursive: true
});
try {
const st = statSync(LOCK_FILE);
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) unlinkSync(LOCK_FILE);
} catch {}
return openSync(LOCK_FILE, 'wx');
} catch {
return null;
}
}
function releaseLock(fd) {
try {
if (fd !== null) closeSync(fd);
} catch {}
try {
unlinkSync(LOCK_FILE);
} catch {}
}
function writeState(patch) {
const {
command,
method,
sourceType
} = commandForUpdate();
const lock = readLockInfo();
const stateFile = updateStateFile();
const state = {
date: todayKey(),
scope: updateScope(),
lockFile: lock.file,
lockFound: Boolean(lock.entry),
command: command.join(' '),
method,
sourceType,
updatedAt: new Date().toISOString(),
...patch,
};
mkdirSync(dirname(stateFile), {
recursive: true
});
writeFileSync(stateFile, `${JSON.stringify(state, null, 2)}\n`);
}
function hashSkillDir() {
const hash = createHash('sha256');
const files = [];
function walk(dir) {
for (const entry of readdirSync(dir, {
withFileTypes: true
})) {
const full = join(dir, entry.name);
const rel = full.slice(SKILL_DIR.length + 1).replace(/\\/g, '/');
if (rel === 'config.json' || rel === 'scripts/update-state.json') continue;
if (entry.isDirectory()) {
walk(full);
} else if (entry.isFile()) {
files.push({
full,
rel
});
}
}
}
walk(SKILL_DIR);
files.sort((a, b) => a.rel.localeCompare(b.rel));
for (const file of files) {
hash.update(file.rel);
hash.update('\0');
hash.update(readFileSync(file.full));
hash.update('\0');
}
return hash.digest('hex');
}
function runSkillCommand(command, method) {
const cwd = updateScope() === 'global' ? homedir() : projectRoot();
const isWin = process.platform === 'win32';
const bin = isWin ? 'cmd.exe' : 'npx';
const args = isWin ? ['/d', '/s', '/c', command.join(' ')] : command.slice(1);
const result = spawnSync(bin, args, {
cwd,
encoding: 'utf8',
env: updateEnv(),
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 10 * 60 * 1000,
windowsHide: true,
});
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
const failedByOutput =
/failed to (update|add|install)|No installed skills found matching/i.test(output);
return {
command,
method,
result,
output,
ok: result.status === 0 && !failedByOutput,
error: result.error ?
String(result.error.message || result.error) :
failedByOutput ?
`npx skills ${method} reported failure` :
null,
};
}
function runUpdate() {
const entry = readLockEntry();
const {
command,
method,
sourceType
} = commandForUpdate();
const state = readState();
const beforeRemoteHead = remoteHead(entry);
const remoteChanged = Boolean(beforeRemoteHead && beforeRemoteHead !== state?.lastAppliedRemoteHead);
const beforeHash = hashSkillDir();
let attempt = runSkillCommand(command, method);
let usedFallback = false;
let fallbackReason = null;
if ((!attempt.ok || (attempt.ok && remoteChanged && beforeHash === hashSkillDir())) && method !== 'add') {
const fallbackCommands = fallbackAddCommands(entry);
if (fallbackCommands.length > 0) {
fallbackReason = entry ?
attempt.ok ?
'remote changed but update did not change local files' :
'update failed' :
'lock entry missing or update did not find installed skill';
const outputs = [attempt.output].filter(Boolean);
usedFallback = true;
for (const fallbackCommand of fallbackCommands) {
const fallback = runSkillCommand(fallbackCommand, 'add');
outputs.push(fallback.output);
attempt = {
...fallback,
output: outputs.filter(Boolean).join('\n\n--- fallback: npx skills add ---\n\n'),
error: fallback.ok ? null : fallback.error || attempt.error,
};
if (fallback.ok) break;
}
}
}
const afterHash = hashSkillDir();
writeState({
status: attempt.ok ? 'success' : 'failed',
finishedAt: new Date().toISOString(),
exitCode: attempt.result.status,
method: attempt.method,
usedFallback,
fallbackReason,
sourceType,
command: attempt.command.join(' '),
error: attempt.error,
remoteHead: beforeRemoteHead,
remoteChanged,
lastAppliedRemoteHead: attempt.ok ?
beforeRemoteHead || state?.lastAppliedRemoteHead || null :
state?.lastAppliedRemoteHead || null,
changed: beforeHash !== afterHash,
beforeHash,
afterHash,
output: attempt.output.slice(-2000),
});
}
async function main() {
if (alreadyUpdatedToday()) return;
const fd = acquireLock();
if (fd === null) return;
try {
if (alreadyUpdatedToday()) return;
if (!(await waitForQuietWindow())) {
writeState({
status: 'deferred',
finishedAt: new Date().toISOString(),
exitCode: null,
error: 'skill kept being used; update deferred after max wait',
changed: false,
});
return;
}
writeState({
status: 'updating',
startedAt: new Date().toISOString(),
exitCode: null,
changed: false,
});
runUpdate();
} finally {
releaseLock(fd);
}
}
main().catch((err) => {
try {
writeState({
status: 'failed',
finishedAt: new Date().toISOString(),
exitCode: null,
error: String(err?.message || err),
changed: false,
});
} catch {}
});