#!/usr/bin/env node // install-kb-cron.mjs — Standalone cross-OS install helper for the weekly // KB-update cron job. Reads the appropriate template from // scripts/kb-update/templates/, fills {{NODE_BIN}}, {{PLUGIN_ROOT}}, // {{LOG_FILE}}, {{SCHEDULE_HOUR/MINUTE/DAY_OF_WEEK}} placeholders, writes // to the platform-specific scheduler dir, and registers the job with the // host scheduler. // // macOS → ~/Library/LaunchAgents/com.fromaitochitta.ms-ai-architect.kb-update.plist // launchctl bootstrap gui/ (EIO fallback to load -w) // Linux → ~/.config/systemd/user/{ms-ai-architect-kb-update.service, .timer} // systemctl --user daemon-reload && enable --now ms-ai-architect-kb-update.timer // Windows → invoke ms-ai-architect-kb-update.ps1 via // powershell -ExecutionPolicy Bypass -File // (template registers via Register-ScheduledTask itself). // Marked beta — not validated against a real Windows machine. // // Usage: // install-kb-cron.mjs [--print-only] [--target macos|linux|windows] // [--uninstall [--purge]] // [--node-bin ] [--claude-bin ] // [--schedule "M H * * D"] default: "23 4 * * 3" (Wed 04:23) // // --print-only renders the filled template to stdout and exits without // touching the filesystem (no scheduler dirs, no log dirs, nothing under // HOME). Use it for inspection or for cross-target rendering on a // developer machine. import { spawnSync } from 'node:child_process'; import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, rmSync, } from 'node:fs'; import { homedir, platform as osPlatform } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = join(__dirname, '..'); const TEMPLATES_DIR = join(__dirname, 'kb-update', 'templates'); const APP = 'ms-ai-architect'; const LAUNCHD_LABEL = 'com.fromaitochitta.ms-ai-architect.kb-update'; const SYSTEMD_UNIT = 'ms-ai-architect-kb-update'; const WIN_TASK_NAME = 'ms-ai-architect-kb-update'; // ---------- CLI parsing ---------- function printUsage() { console.log(`Usage: install-kb-cron.mjs [options] Installs (or removes) the weekly Microsoft Learn KB-update job for the ms-ai-architect plugin on the host scheduler. Options: --print-only Print filled template to stdout, no file writes --target Target OS: macos|linux|windows (default: auto-detect) --uninstall Reverse the registration (idempotent) --purge With --uninstall: also delete logs/status/.kb-backup --node-bin Path to node binary (default: process.execPath) --claude-bin Path to claude binary (default: 'claude' on PATH) --schedule "M H * * D" Cron expression (default: "23 4 * * 3" = Wed 04:23) --help, -h Show this message and exit `); } function parseArgs(argv) { const args = { printOnly: false, target: null, uninstall: false, purge: false, nodeBin: null, claudeBin: null, schedule: '23 4 * * 3', }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; const eq = (name) => a.startsWith(`${name}=`) ? a.slice(name.length + 1) : null; if (a === '--print-only') args.printOnly = true; else if (a === '--uninstall') args.uninstall = true; else if (a === '--purge') args.purge = true; else if (a === '--help' || a === '-h') { printUsage(); process.exit(0); } else if (a === '--target') args.target = argv[++i]; else if (eq('--target') !== null) args.target = eq('--target'); else if (a === '--node-bin') args.nodeBin = argv[++i]; else if (eq('--node-bin') !== null) args.nodeBin = eq('--node-bin'); else if (a === '--claude-bin') args.claudeBin = argv[++i]; else if (eq('--claude-bin') !== null) args.claudeBin = eq('--claude-bin'); else if (a === '--schedule') args.schedule = argv[++i]; else if (eq('--schedule') !== null) args.schedule = eq('--schedule'); else { console.error(`Unknown argument: ${a}`); console.error('Run with --help to see usage.'); process.exit(2); } } return args; } // ---------- Target detection ---------- function detectHostTarget() { switch (process.platform) { case 'darwin': return 'macos'; case 'linux': return 'linux'; case 'win32': return 'windows'; default: return null; } } const VALID_TARGETS = new Set(['macos', 'linux', 'windows']); // ---------- Schedule parsing ---------- function parseSchedule(expr) { if (typeof expr !== 'string') { throw new Error(`invalid schedule: not a string`); } const parts = expr.trim().split(/\s+/); if (parts.length !== 5) { throw new Error( `invalid schedule "${expr}" — expected 5 cron fields "M H * * D"`, ); } const [m, h, dom, mon, dow] = parts; if (dom !== '*' || mon !== '*') { throw new Error( `invalid schedule "${expr}" — day-of-month and month must be "*"`, ); } const minute = Number(m); const hour = Number(h); const dayOfWeek = Number(dow); if (!Number.isInteger(minute) || minute < 0 || minute > 59) { throw new Error(`invalid schedule minute "${m}" (expected 0-59)`); } if (!Number.isInteger(hour) || hour < 0 || hour > 23) { throw new Error(`invalid schedule hour "${h}" (expected 0-23)`); } if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6) { throw new Error(`invalid schedule day-of-week "${dow}" (expected 0-6)`); } return { minute, hour, dayOfWeek }; } // ---------- Path resolution (no side effects) ---------- function resolveAppPaths(target) { const h = homedir(); if (target === 'macos') { return { logFile: join(h, 'Library', 'Logs', APP, 'kb-update.log'), logDir: join(h, 'Library', 'Logs', APP), cacheDir: join(h, 'Library', 'Caches', APP), }; } if (target === 'windows') { const lad = process.env.LOCALAPPDATA || join(h, 'AppData', 'Local'); return { logFile: join(lad, APP, 'Logs', 'kb-update.log'), logDir: join(lad, APP, 'Logs'), cacheDir: join(lad, APP, 'Cache'), }; } // linux const xdgState = process.env.XDG_STATE_HOME || join(h, '.local', 'state'); const xdgCache = process.env.XDG_CACHE_HOME || join(h, '.cache'); return { logFile: join(xdgState, APP, 'logs', 'kb-update.log'), logDir: join(xdgState, APP, 'logs'), cacheDir: join(xdgCache, APP), }; } // ---------- Template substitution ---------- function fillTemplate(content, vars) { return content .replace(/\{\{NODE_BIN\}\}/g, vars.nodeBin) .replace(/\{\{PLUGIN_ROOT\}\}/g, vars.pluginRoot) .replace(/\{\{LOG_FILE\}\}/g, vars.logFile) .replace(/\{\{SCHEDULE_HOUR\}\}/g, String(vars.hour)) .replace(/\{\{SCHEDULE_MINUTE\}\}/g, String(vars.minute)) .replace(/\{\{SCHEDULE_DAY_OF_WEEK\}\}/g, String(vars.dayOfWeek)); } function readTemplate(name) { return readFileSync(join(TEMPLATES_DIR, name), 'utf8'); } // ---------- MCP / WSL detection ---------- function checkMcpServer() { const claudeJson = join(homedir(), '.claude.json'); if (!existsSync(claudeJson)) return null; try { const data = JSON.parse(readFileSync(claudeJson, 'utf8')); const mcp = data && data.mcpServers ? data.mcpServers : {}; return Boolean(mcp['microsoft-learn']); } catch { return null; } } function isWsl() { if (process.platform !== 'linux') return false; try { const v = readFileSync('/proc/version', 'utf8'); return /Microsoft|WSL/i.test(v); } catch { return false; } } // ---------- Per-target install ---------- function installMacos(args, vars) { const filled = fillTemplate( readTemplate(`${LAUNCHD_LABEL}.plist`), vars, ); if (args.printOnly) { process.stdout.write(filled); return; } const dest = join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`); mkdirSync(dirname(dest), { recursive: true }); mkdirSync(vars.logDir, { recursive: true }); writeFileSync(dest, filled, 'utf8'); // launchctl bootstrap gui/ (with load -w fallback on error). if (typeof process.getuid === 'function') { const uid = process.getuid(); const r = spawnSync('launchctl', ['bootstrap', `gui/${uid}`, dest], { encoding: 'utf8', }); if (r.status !== 0) { console.error(`launchctl bootstrap returned ${r.status}: ${r.stderr.trim()}`); console.error('Falling back to: launchctl load -w'); const r2 = spawnSync('launchctl', ['load', '-w', dest], { encoding: 'utf8' }); if (r2.status !== 0) { throw new Error(`launchctl load -w failed: ${r2.stderr.trim()}`); } } } console.log(`✓ Installed ${dest}`); console.log('Next steps:'); console.log(` - Verify: launchctl list | grep ${LAUNCHD_LABEL}`); console.log(` - Logs: ${vars.logFile}`); } function installLinux(args, vars) { const serviceFilled = fillTemplate(readTemplate(`${SYSTEMD_UNIT}.service`), vars); const timerFilled = fillTemplate(readTemplate(`${SYSTEMD_UNIT}.timer`), vars); if (args.printOnly) { process.stdout.write(`# === ${SYSTEMD_UNIT}.service ===\n`); process.stdout.write(serviceFilled); process.stdout.write(`\n# === ${SYSTEMD_UNIT}.timer ===\n`); process.stdout.write(timerFilled); return; } // Pre-check: is systemd present? (skip if --target linux on non-linux host — // user is likely cross-rendering by mistake; refuse to actually install.) if (process.platform !== 'linux') { throw new Error( `cannot install systemd units on host platform "${process.platform}" — ` + `use --print-only to render templates for cross-OS inspection`, ); } const sys = spawnSync('systemctl', ['is-system-running'], { encoding: 'utf8' }); if (sys.status === null || sys.error) { throw new Error('systemctl not available — systemd is not running on this system'); } if (isWsl()) { console.warn('WARNING: detected WSL — systemd --user may need manual setup.'); } const userDir = join(homedir(), '.config', 'systemd', 'user'); mkdirSync(userDir, { recursive: true }); mkdirSync(vars.logDir, { recursive: true }); const servicePath = join(userDir, `${SYSTEMD_UNIT}.service`); const timerPath = join(userDir, `${SYSTEMD_UNIT}.timer`); writeFileSync(servicePath, serviceFilled, 'utf8'); writeFileSync(timerPath, timerFilled, 'utf8'); spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' }); const en = spawnSync( 'systemctl', ['--user', 'enable', '--now', `${SYSTEMD_UNIT}.timer`], { stdio: 'inherit' }, ); if (en.status !== 0) { throw new Error(`systemctl --user enable --now ${SYSTEMD_UNIT}.timer failed`); } console.log(`✓ Installed ${servicePath}`); console.log(`✓ Installed ${timerPath}`); console.log('Next steps:'); console.log(` - Verify: systemctl --user list-timers | grep ${SYSTEMD_UNIT}`); console.log(` - Optional: sudo loginctl enable-linger $USER (autostart without login)`); console.log(` - Logs: ${vars.logFile}`); } function installWindows(args, vars) { const filled = fillTemplate(readTemplate(`${SYSTEMD_UNIT}.ps1`), vars); if (args.printOnly) { process.stdout.write(filled); return; } if (process.platform !== 'win32') { throw new Error( `cannot register Windows scheduled task on host platform "${process.platform}" — ` + `use --print-only to render the .ps1 for cross-OS inspection`, ); } console.warn( 'NOTE: Windows install path is BETA — not validated against a real Windows machine.', ); // Materialize the .ps1 in the cache dir, then invoke it. mkdirSync(vars.cacheDir, { recursive: true }); mkdirSync(vars.logDir, { recursive: true }); const ps1Path = join(vars.cacheDir, 'install-kb-cron.ps1'); writeFileSync(ps1Path, filled, 'utf8'); const r = spawnSync( 'powershell', ['-ExecutionPolicy', 'Bypass', '-File', ps1Path], { stdio: 'inherit' }, ); if (r.status !== 0) { throw new Error(`powershell install failed (status=${r.status})`); } console.log(`✓ Registered Windows task '${WIN_TASK_NAME}'`); console.log('Next steps:'); console.log(` - Verify: schtasks /Query /TN ${WIN_TASK_NAME}`); console.log(` - Logs: ${vars.logFile}`); } // ---------- Per-target uninstall ---------- function uninstallMacos(host) { const dest = join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`); if (!existsSync(dest)) { console.log(`(nothing to remove at ${dest})`); return; } if (host === 'macos' && typeof process.getuid === 'function') { const uid = process.getuid(); spawnSync('launchctl', ['bootout', `gui/${uid}`, dest], { encoding: 'utf8' }); spawnSync('launchctl', ['unload', '-w', dest], { encoding: 'utf8' }); } unlinkSync(dest); console.log(`✓ Removed ${dest}`); } function uninstallLinux(host) { const userDir = join(homedir(), '.config', 'systemd', 'user'); const servicePath = join(userDir, `${SYSTEMD_UNIT}.service`); const timerPath = join(userDir, `${SYSTEMD_UNIT}.timer`); const anyExists = existsSync(servicePath) || existsSync(timerPath); if (!anyExists) { console.log(`(nothing to remove at ${userDir})`); return; } if (host === 'linux') { spawnSync( 'systemctl', ['--user', 'disable', '--now', `${SYSTEMD_UNIT}.timer`], { encoding: 'utf8' }, ); } for (const p of [servicePath, timerPath]) { if (existsSync(p)) { unlinkSync(p); console.log(`✓ Removed ${p}`); } } if (host === 'linux') { spawnSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf8' }); } } function uninstallWindows(host) { if (host === 'windows') { const r = spawnSync( 'schtasks', ['/Delete', '/TN', WIN_TASK_NAME, '/F'], { encoding: 'utf8' }, ); if (r.status === 0) { console.log(`✓ Removed Windows task '${WIN_TASK_NAME}'`); } else { console.log(`(nothing to remove — task '${WIN_TASK_NAME}' not registered)`); } } else { console.log(`(nothing to remove — schtasks unavailable on host '${host}')`); } } function purgeAppFiles(target) { const paths = resolveAppPaths(target); const backupDir = join(PLUGIN_ROOT, '.kb-backup'); let removed = 0; for (const d of [paths.logDir, paths.cacheDir, backupDir]) { if (existsSync(d)) { rmSync(d, { recursive: true, force: true }); console.log(`✓ Purged ${d}`); removed++; } } if (removed === 0) { console.log('(nothing to purge)'); } } // ---------- Main ---------- function main() { const args = parseArgs(process.argv.slice(2)); if (!args.target) args.target = detectHostTarget(); if (!args.target || !VALID_TARGETS.has(args.target)) { console.error( `unsupported or invalid --target "${args.target}" — must be one of: macos, linux, windows`, ); process.exit(2); } // Resolve binaries. const nodeBin = args.nodeBin || process.execPath; // claudeBin is only consumed by the templates that reference it; current // template set assumes 'claude' is on PATH inside the cron environment, so // the override is currently a no-op pass-through. Accept the flag so future // template revisions can pick it up without a CLI break. void args.claudeBin; // Schedule parsing. let sched; try { sched = parseSchedule(args.schedule); } catch (err) { console.error(`ERROR: ${err.message}`); process.exit(2); } const paths = resolveAppPaths(args.target); const vars = { nodeBin, pluginRoot: PLUGIN_ROOT, logFile: paths.logFile, logDir: paths.logDir, cacheDir: paths.cacheDir, minute: sched.minute, hour: sched.hour, dayOfWeek: sched.dayOfWeek, }; // Uninstall path. if (args.uninstall) { const host = detectHostTarget(); if (args.target === 'macos') uninstallMacos(host); if (args.target === 'linux') uninstallLinux(host); if (args.target === 'windows') uninstallWindows(host); if (args.purge) purgeAppFiles(args.target); return; } // MCP soft-warn (install path only). if (!args.printOnly) { const mcp = checkMcpServer(); if (mcp === false) { console.warn( 'WARNING: ~/.claude.json has no `microsoft-learn` MCP server entry. ' + 'KB-updates will run but the agent will lack live Microsoft Learn access.', ); } else if (mcp === null) { console.warn('WARNING: could not read ~/.claude.json — skipping MCP server check.'); } } if (args.target === 'macos') installMacos(args, vars); if (args.target === 'linux') installLinux(args, vars); if (args.target === 'windows') installWindows(args, vars); } try { main(); } catch (err) { console.error(`ERROR: ${err.message}`); process.exit(1); }