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>
This commit is contained in:
parent
b0231fdef7
commit
30d7a2024c
2 changed files with 708 additions and 0 deletions
501
plugins/ms-ai-architect/scripts/install-kb-cron.mjs
Executable file
501
plugins/ms-ai-architect/scripts/install-kb-cron.mjs
Executable file
|
|
@ -0,0 +1,501 @@
|
|||
#!/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);
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
// tests/kb-update/test-install-cron.test.mjs
|
||||
// Subprocess + filesystem-snapshot tests for scripts/install-kb-cron.mjs
|
||||
// (Step 11). Exercises --print-only across targets and verifies idempotent
|
||||
// --uninstall. Never invokes launchctl/systemctl/Register-ScheduledTask
|
||||
// against real schedulers; --print-only short-circuits before any
|
||||
// side-effecting call, and --uninstall is exercised against an empty HOME
|
||||
// where the install file simply does not exist.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { tmpdir, 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 SCRIPT = join(__dirname, '..', '..', 'scripts', 'install-kb-cron.mjs');
|
||||
|
||||
function mkSandbox() {
|
||||
return mkdtempSync(join(tmpdir(), 'install-kb-cron-test-'));
|
||||
}
|
||||
|
||||
function runInstall(args, env = {}) {
|
||||
return spawnSync('node', [SCRIPT, ...args], {
|
||||
env: { PATH: process.env.PATH, ...env },
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
function snapshotDir(dir) {
|
||||
const out = [];
|
||||
function walk(d) {
|
||||
if (!existsSync(d)) return;
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(d, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const p = join(d, entry.name);
|
||||
out.push(p);
|
||||
if (entry.isDirectory()) walk(p);
|
||||
}
|
||||
}
|
||||
walk(dir);
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
function hostTarget() {
|
||||
const p = osPlatform();
|
||||
if (p === 'darwin') return 'macos';
|
||||
if (p === 'linux') return 'linux';
|
||||
if (p === 'win32') return 'windows';
|
||||
return null;
|
||||
}
|
||||
|
||||
test('--print-only --target macos: substituted plist with no unsubstituted placeholders', () => {
|
||||
const home = mkSandbox();
|
||||
try {
|
||||
const r = runInstall(['--print-only', '--target', 'macos'], { HOME: home });
|
||||
assert.equal(r.status, 0, `stderr: ${r.stderr}\nstdout: ${r.stdout}`);
|
||||
assert.match(r.stdout, /<key>Label<\/key>/);
|
||||
assert.match(r.stdout, /<key>StartCalendarInterval<\/key>/);
|
||||
assert.match(r.stdout, /<integer>3<\/integer>/, 'default day-of-week=3 (Wednesday)');
|
||||
assert.match(r.stdout, /<integer>4<\/integer>/, 'default hour=4');
|
||||
assert.match(r.stdout, /<integer>23<\/integer>/, 'default minute=23');
|
||||
assert.doesNotMatch(r.stdout, /\{\{[A-Z_]+\}\}/, 'no unsubstituted {{...}} placeholders');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('--print-only --target linux: filled service+timer with [Unit] and OnCalendar=Wed', () => {
|
||||
const home = mkSandbox();
|
||||
try {
|
||||
const r = runInstall(['--print-only', '--target', 'linux'], { HOME: home });
|
||||
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
||||
assert.match(r.stdout, /\[Unit\]/);
|
||||
assert.match(r.stdout, /\[Service\]/);
|
||||
assert.match(r.stdout, /\[Timer\]/);
|
||||
assert.match(r.stdout, /OnCalendar=Wed/);
|
||||
assert.match(r.stdout, /ExecStart=/);
|
||||
assert.doesNotMatch(r.stdout, /\{\{[A-Z_]+\}\}/, 'no unsubstituted {{...}} placeholders');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('--print-only --target windows: Register-ScheduledTask + InteractiveToken', () => {
|
||||
const home = mkSandbox();
|
||||
try {
|
||||
const r = runInstall(['--print-only', '--target', 'windows'], { HOME: home });
|
||||
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
||||
assert.match(r.stdout, /Register-ScheduledTask/);
|
||||
assert.match(r.stdout, /InteractiveToken/);
|
||||
assert.match(r.stdout, /-DaysOfWeek\s+Wednesday/);
|
||||
assert.doesNotMatch(r.stdout, /\{\{[A-Z_]+\}\}/, 'no unsubstituted {{...}} placeholders');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('--print-only writes no files (HOME snapshot before/after equal)', () => {
|
||||
const home = mkSandbox();
|
||||
try {
|
||||
const before = snapshotDir(home);
|
||||
const r = runInstall(['--print-only', '--target', 'macos'], { HOME: home });
|
||||
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
||||
const after = snapshotDir(home);
|
||||
assert.deepEqual(after, before, 'HOME must not be touched in --print-only mode');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('--print-only --target linux writes no files in HOME', () => {
|
||||
const home = mkSandbox();
|
||||
try {
|
||||
const before = snapshotDir(home);
|
||||
const r = runInstall(['--print-only', '--target', 'linux'], { HOME: home });
|
||||
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
||||
const after = snapshotDir(home);
|
||||
assert.deepEqual(after, before, 'HOME must not be touched in --print-only mode (linux target)');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('--uninstall is idempotent (exit 0 with nothing installed)', () => {
|
||||
const home = mkSandbox();
|
||||
try {
|
||||
const target = hostTarget() || 'macos';
|
||||
const r = runInstall(['--uninstall', '--target', target], { HOME: home });
|
||||
assert.equal(r.status, 0, `stderr: ${r.stderr}\nstdout: ${r.stdout}`);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('--uninstall --target macos on empty HOME: idempotent (no plist, no launchctl call)', () => {
|
||||
const home = mkSandbox();
|
||||
try {
|
||||
const r = runInstall(['--uninstall', '--target', 'macos'], { HOME: home });
|
||||
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
||||
assert.match((r.stdout || '') + (r.stderr || ''), /nothing to remove|not installed/i);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('--schedule with custom cron expr substitutes correctly into plist', () => {
|
||||
const home = mkSandbox();
|
||||
try {
|
||||
const r = runInstall(
|
||||
['--print-only', '--target', 'macos', '--schedule', '15 7 * * 5'],
|
||||
{ HOME: home },
|
||||
);
|
||||
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
||||
assert.match(r.stdout, /<integer>15<\/integer>/, 'minute=15');
|
||||
assert.match(r.stdout, /<integer>7<\/integer>/, 'hour=7');
|
||||
assert.match(r.stdout, /<integer>5<\/integer>/, 'day-of-week=5 (Friday)');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid --target rejects with non-zero exit', () => {
|
||||
const home = mkSandbox();
|
||||
try {
|
||||
const r = runInstall(['--print-only', '--target', 'bogus'], { HOME: home });
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match((r.stderr || '') + (r.stdout || ''), /target|invalid|unsupported/i);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid --schedule rejects with non-zero exit', () => {
|
||||
const home = mkSandbox();
|
||||
try {
|
||||
const r = runInstall(
|
||||
['--print-only', '--target', 'macos', '--schedule', 'not a cron'],
|
||||
{ HOME: home },
|
||||
);
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match((r.stderr || '') + (r.stdout || ''), /schedule|cron|invalid/i);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('--node-bin override appears in substituted plist', () => {
|
||||
const home = mkSandbox();
|
||||
try {
|
||||
const r = runInstall(
|
||||
['--print-only', '--target', 'macos', '--node-bin', '/opt/custom/bin/node'],
|
||||
{ HOME: home },
|
||||
);
|
||||
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
||||
assert.match(r.stdout, /\/opt\/custom\/bin\/node/);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue