ktg-plugin-marketplace/plugins/ms-ai-architect/scripts/install-kb-cron.mjs
Kjell Tore Guttormsen 30d7a2024c feat(ms-ai-architect): add install-kb-cron standalone helper for cross-OS cron registration [skip-docs]
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>
2026-05-05 11:26:54 +02:00

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);
}