/** * `celilo system apply-config` — headless write to systemConfig. * * Phase 2 of openspec/specs/management-as-module/spec.md: the celilo-mgmt module's * on_install hook needs a CLI surface it can shell out to that writes * the operator-chosen network/DNS/SSH config without the interactive * framing of `celilo system init`. This is that command. * * Shape: * * celilo system apply-config ... * celilo system apply-config --from-stdin # JSON map on stdin * * No prompts. No "next steps" guidance. Just writes the keys and * reports a count. Suitable for module hooks, CI workflows, and any * other automation that needs to seed systemConfig at deploy time. * * `celilo system init --accept-defaults` continues to work and now * delegates to this same `initializeSystem()` plumbing — that's the * operator-facing surface; this is the automation-facing surface. */ import { getDb } from '../../db/client'; import { initializeSystem } from '../../services/system-init'; import type { CommandResult } from '../types'; interface ParsedInput { overrides: Record; errors: string[]; } /** * Keys this command will not write, however they arrive. * * `apply-config` is the automation surface — the one a module hook can shell out * to — and networks are celilo's to define, never a module's * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md). * Closing it here rather than trusting each module is the point: an authority * that depends on every caller behaving is not an authority. * * This is deliberately the WHOLE `network.` namespace and not just `.subnet`. * A module that could set a gateway or a VLAN tag for a network it does not own * would be redefining that network by increments, and the subnet-only rule would * read as an invitation to do exactly that. * * `network.bridge` is exempt: it is a container-service concept (a Proxmox bridge * name like `vmbr0`), not addressing, and it is the one `network.` key that * carries a schema default. */ const NETWORK_KEY = /^network\./; const NETWORK_KEY_EXEMPT = new Set(['network.bridge']); export function rejectedNetworkKeys(keys: string[]): string[] { return keys.filter((key) => NETWORK_KEY.test(key) && !NETWORK_KEY_EXEMPT.has(key)); } export function networkWriteRefusal(keys: string[]): string { return [ `Refusing to write ${keys.length === 1 ? 'a network key' : 'network keys'}: ${keys.join(', ')}.`, '', "Networks are celilo's to define, not a module's. A module declares the networks it", 'needs under `requires.networks` and reads them with `$system:network..subnet`;', 'celilo supplies the value, asking the operator when it does not already hold one.', '', 'If you are the operator: `celilo system config set `.', 'If this is the management box recording its own network: `celilo system discover-network`.', ].join('\n'); } /** * Pure parser for the positional key=value arguments. Exported so the * test suite can exercise it without spinning up the CLI. * * - Keys must contain a literal `=`; bare positionals are errors. * - Empty values are allowed (`network.dmz.subnet=`) — caller decides * what to do with them (the writer skips them). * - Values containing `=` are preserved (split on FIRST `=` only). */ export function parseKeyValueArgs(args: string[]): ParsedInput { const overrides: Record = {}; const errors: string[] = []; for (const arg of args) { const eqIndex = arg.indexOf('='); if (eqIndex <= 0) { errors.push(`Expected key=value (got "${arg}"). Use --from-stdin for JSON input.`); continue; } const key = arg.slice(0, eqIndex); const value = arg.slice(eqIndex + 1); overrides[key] = value; } return { overrides, errors }; } /** * Read JSON config from stdin. Returns the parsed key→value map; the * caller validates structure. Values are coerced to strings to match * systemConfig's schema (which stores everything as TEXT). */ async function readJsonStdin(): Promise> { const chunks: Buffer[] = []; for await (const chunk of process.stdin) { chunks.push(chunk as Buffer); } const body = Buffer.concat(chunks).toString('utf-8').trim(); if (!body) return {}; const parsed = JSON.parse(body) as unknown; if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { throw new Error('--from-stdin expects a JSON object mapping config keys to values'); } const out: Record = {}; for (const [key, value] of Object.entries(parsed)) { if (value === null || value === undefined) continue; out[key] = String(value); } return out; } export async function handleSystemApplyConfig( args: string[], flags: Record = {}, ): Promise { const fromStdin = flags['from-stdin'] === true; let overrides: Record = {}; if (fromStdin) { try { overrides = await readJsonStdin(); } catch (err) { return { success: false, error: `Could not read --from-stdin: ${err instanceof Error ? err.message : String(err)}`, }; } } else { const parsed = parseKeyValueArgs(args); if (parsed.errors.length > 0) { return { success: false, error: parsed.errors.join('\n'), }; } overrides = parsed.overrides; } if (Object.keys(overrides).length === 0) { return { success: false, error: 'No config values supplied.\n\nUsage:\n celilo system apply-config ...\n celilo system apply-config --from-stdin', }; } const refused = rejectedNetworkKeys(Object.keys(overrides)); if (refused.length > 0) { return { success: false, error: networkWriteRefusal(refused) }; } const db = getDb(); try { const applied = initializeSystem(db, overrides); const writtenCount = Object.keys(applied).length; return { success: true, message: `Applied ${writtenCount} config value(s) to systemConfig.`, }; } catch (err) { return { success: false, error: `Config write failed: ${err instanceof Error ? err.message : String(err)}`, }; } }