From 30d7a2024cff7449682db06f1b386b43f3c12a69 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 5 May 2026 11:26:54 +0200 Subject: [PATCH] feat(ms-ai-architect): add install-kb-cron standalone helper for cross-OS cron registration [skip-docs] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ (load -w fallback) Linux - systemctl --user daemon-reload && enable --now Windows - powershell -ExecutionPolicy Bypass -File (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 ; 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 --- .../scripts/install-kb-cron.mjs | 501 ++++++++++++++++++ .../kb-update/test-install-cron.test.mjs | 207 ++++++++ 2 files changed, 708 insertions(+) create mode 100755 plugins/ms-ai-architect/scripts/install-kb-cron.mjs create mode 100644 plugins/ms-ai-architect/tests/kb-update/test-install-cron.test.mjs diff --git a/plugins/ms-ai-architect/scripts/install-kb-cron.mjs b/plugins/ms-ai-architect/scripts/install-kb-cron.mjs new file mode 100755 index 0000000..ea9bfce --- /dev/null +++ b/plugins/ms-ai-architect/scripts/install-kb-cron.mjs @@ -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/ (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); +} diff --git a/plugins/ms-ai-architect/tests/kb-update/test-install-cron.test.mjs b/plugins/ms-ai-architect/tests/kb-update/test-install-cron.test.mjs new file mode 100644 index 0000000..2103a77 --- /dev/null +++ b/plugins/ms-ai-architect/tests/kb-update/test-install-cron.test.mjs @@ -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, /Label<\/key>/); + assert.match(r.stdout, /StartCalendarInterval<\/key>/); + assert.match(r.stdout, /3<\/integer>/, 'default day-of-week=3 (Wednesday)'); + assert.match(r.stdout, /4<\/integer>/, 'default hour=4'); + assert.match(r.stdout, /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, /15<\/integer>/, 'minute=15'); + assert.match(r.stdout, /7<\/integer>/, 'hour=7'); + assert.match(r.stdout, /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 }); + } +});