Step 11 of v1.12.0 plan (.claude/projects/2026-05-04-kb-update-fork-and-own/plan.md).
scripts/install-kb-cron.mjs lives at the scripts/ root (not inside
scripts/kb-update/) because it is a plugin-wide install tool, not part of
the KB-update pipeline itself. 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:
macOS - launchctl bootstrap gui/<uid> <plist> (load -w fallback)
Linux - systemctl --user daemon-reload && enable --now <timer>
Windows - powershell -ExecutionPolicy Bypass -File <ps1> (beta)
Flags: --print-only, --target macos|linux|windows, --uninstall, --purge,
--node-bin, --claude-bin, --schedule "M H * * D" (default: Wed 04:23).
UID resolution for launchctl is guarded by process.getuid() POSIX-only
(undefined on Windows). MCP server presence in ~/.claude.json is
warning-only per brief Spørsmål 7. WSL detected via /proc/version.
Cross-OS rendering supported via --print-only --target <other>; install
on a non-host target rejects with explicit error.
11 subprocess + filesystem-snapshot tests in
tests/kb-update/test-install-cron.test.mjs verify --print-only produces
filled templates with no unsubstituted {{...}} placeholders, --print-only
writes nothing under HOME, --uninstall is idempotent on an empty HOME,
--schedule substitutes correctly, and invalid flags reject with non-zero
exit. Tests never invoke launchctl/systemctl/Register-ScheduledTask
against real schedulers.
Tests: 110/110 pass (was 99 before this step).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
501 lines
17 KiB
JavaScript
Executable file
501 lines
17 KiB
JavaScript
Executable file
#!/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/<uid> <path> (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 <path>
|
|
// (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 <path>] [--claude-bin <path>]
|
|
// [--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 <os> 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> Path to node binary (default: process.execPath)
|
|
--claude-bin <path> 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/<uid> <path> (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);
|
|
}
|