/** * Step: system-update-cron — Install /etc/cron.d/clawdie-system-update so * the platform fetches FreeBSD security patches, audits installed packages, * and patches every thin jail every morning before the 8am operator report. * * The platform never auto-reboots. If a kernel patch lands, system-update * surfaces REBOOT_PENDING in the structured status; the operator decides. * * Schedule: 06:50 daily. Output of the run is appended to the system-update * log so the morning report can pick it up. * * Must run on the FreeBSD host as root. */ import { execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import { logger } from '../src/logger.js'; import { commandExists, getPlatform, isRoot } from './platform.js'; import { emitStatus } from './status.js'; const CRON_PATH = '/etc/cron.d/clawdie-system-update'; const WRAPPER_PATH = '/usr/local/sbin/clawdie-system-update'; const LOG_PATH = '/var/log/clawdie-system-update.log'; const DEFAULT_SCHEDULE = '50 6 * * *'; export interface CronArgs { schedule: string; projectRoot: string; npxPath: string; } export function parseArgs(args: string[]): CronArgs { let schedule = process.env.SYSTEM_UPDATE_CRON || DEFAULT_SCHEDULE; let projectRoot = process.cwd(); let npxPath = '/usr/local/bin/npx'; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--schedule') { schedule = (args[i + 1] || '').trim(); i++; continue; } if (arg === '--project-root') { projectRoot = (args[i + 1] || '').trim(); i++; continue; } if (arg === '--npx') { npxPath = (args[i + 1] || '').trim(); i++; continue; } throw new Error(`Unknown argument: ${arg}`); } if (!schedule || schedule.split(/\s+/u).filter(Boolean).length !== 5) { throw new Error(`Invalid cron schedule: '${schedule}' (need 5 fields)`); } if (!path.isAbsolute(projectRoot)) { throw new Error(`--project-root must be absolute: ${projectRoot}`); } return { schedule, projectRoot, npxPath }; } export function buildWrapper(args: CronArgs): string { return [ '#!/bin/sh', '# clawdie-system-update — managed by setup/system-update-cron.ts', '# Do not edit by hand; regenerate via npx tsx setup/index.ts --step system-update-cron', 'set -eu', `cd "${args.projectRoot}" || exit 1`, `exec "${args.npxPath}" tsx setup/system-update.ts "$@"`, '', ].join('\n'); } export function buildCronEntry(args: CronArgs): string { return [ '# Clawdie — daily system update (managed by setup/system-update-cron.ts)', '# Runs before the 08:00 operator status report so the morning summary', '# reflects the night\'s patch state. Never auto-reboots.', `${args.schedule} root ${WRAPPER_PATH} >> ${LOG_PATH} 2>&1`, '', ].join('\n'); } function writeIfChanged(filePath: string, content: string, mode: number): boolean { const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : ''; if (existing === content) return false; fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, content, { mode }); return true; } export async function run(argv: string[]): Promise { if (getPlatform() !== 'freebsd') { emitStatus('SETUP_SYSTEM_UPDATE_CRON', { STATUS: 'skipped', REASON: 'not_freebsd', }); return; } if (!isRoot()) { emitStatus('SETUP_SYSTEM_UPDATE_CRON', { STATUS: 'failed', ERROR: 'root_required', }); process.exit(1); } const args = parseArgs(argv); // Sanity-check that npx is reachable. if (!fs.existsSync(args.npxPath) && !commandExists('npx')) { emitStatus('SETUP_SYSTEM_UPDATE_CRON', { STATUS: 'failed', ERROR: `npx_not_found_at_${args.npxPath}`, }); process.exit(1); } const wrapper = buildWrapper(args); const cron = buildCronEntry(args); const wrapperChanged = writeIfChanged(WRAPPER_PATH, wrapper, 0o755); if (wrapperChanged) { logger.info({ path: WRAPPER_PATH }, 'system-update wrapper written'); } const cronChanged = writeIfChanged(CRON_PATH, cron, 0o644); if (cronChanged) { logger.info({ path: CRON_PATH }, 'system-update cron entry written'); } // Touch the log file so cron's append works on first run. if (!fs.existsSync(LOG_PATH)) { fs.writeFileSync(LOG_PATH, '', { mode: 0o640 }); try { execSync(`chown root:wheel ${LOG_PATH}`, { stdio: 'ignore' }); } catch { // best-effort } } emitStatus('SETUP_SYSTEM_UPDATE_CRON', { STATUS: 'success', SCHEDULE: args.schedule, CRON_PATH, WRAPPER_PATH, LOG_PATH, PROJECT_ROOT: args.projectRoot, WRAPPER_CHANGED: wrapperChanged ? 'yes' : 'no', CRON_CHANGED: cronChanged ? 'yes' : 'no', }); }