import { readFile, readdir } from "node:fs/promises"; import { join } from "node:path"; import { MODES, PRIVACY_CLASSES, type Mode, type PrivacyClass } from "../types.js"; import { guardedCommand } from "../security/permissions.js"; export const PLAYBOOKS_DIRECTORY = join(".ultra", "playbooks"); const ALLOWED_KEYS = ["name", "argument-hint", "topology", "acceptance", "privacy", "paths"] as const; export interface Playbook { name: string; argumentHint: string; topology: Mode; acceptance: string; privacy: PrivacyClass; paths: string[]; } function frontMatter(text: string): Record { const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text.trim()); if (!match) throw new Error("UltraPi playbook requires YAML front matter"); const fields: Record = {}; for (const line of match[1]!.split(/\r?\n/)) { if (!line.trim() || line.trimStart().startsWith("#")) continue; const separator = line.indexOf(":"); if (separator < 0) throw new Error(`UltraPi playbook has an unparsable line: ${line.trim()}`); fields[line.slice(0, separator).trim()] = line.slice(separator + 1).trim().replace(/\s+#.*$/, ""); } return fields; } function list(value: string | undefined): string[] { if (!value) return []; const inner = /^\[(.*)\]$/.exec(value.trim()); if (!inner) throw new Error("UltraPi playbook paths must be a bracketed list"); return inner[1]!.split(",").map((item) => item.trim().replace(/^["']|["']$/g, "")).filter(Boolean); } export function parsePlaybook(text: string): Playbook { const fields = frontMatter(text); for (const key of Object.keys(fields)) { if (!(ALLOWED_KEYS as readonly string[]).includes(key)) throw new Error(`UltraPi playbook field ${key} is not supported`); } const name = fields.name ?? ""; const topology = fields.topology ?? "auto"; const privacy = fields.privacy ?? "restricted"; if (!/^[a-z][a-z0-9-]{0,63}$/.test(name)) throw new Error("UltraPi playbook name must be a short slug"); if (!(MODES as readonly string[]).includes(topology)) throw new Error(`UltraPi playbook topology ${topology} is not supported`); if (!(PRIVACY_CLASSES as readonly string[]).includes(privacy)) throw new Error(`UltraPi playbook privacy ${privacy} is not supported`); if (!fields.acceptance) throw new Error("UltraPi playbook requires an acceptance command; a playbook that cannot be verified is not a playbook"); return { name, argumentHint: fields["argument-hint"] ?? "", topology: topology as Mode, acceptance: fields.acceptance, privacy: privacy as PrivacyClass, paths: list(fields.paths) }; } export function substituteArguments(template: string, args: readonly string[]): string { return template.replace(/\$([1-9])/g, (_match, index: string) => args[Number(index) - 1] ?? ""); } export function playbookAcceptance(playbook: Playbook, args: readonly string[]): string { const command = substituteArguments(playbook.acceptance, args).trim(); if (!command) throw new Error("UltraPi playbook acceptance command is empty after substitution"); if (guardedCommand(command) === "block") throw new Error("UltraPi playbook acceptance command is blocked"); return command; } export function playbookBody(text: string): string { return text.trim().replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim(); } export async function loadPlaybooks(cwd: string): Promise { let entries: string[]; try { entries = (await readdir(join(cwd, PLAYBOOKS_DIRECTORY))).filter((entry) => entry.endsWith(".md")).sort(); } catch (error) { if (["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) return []; throw error; } const playbooks: Playbook[] = []; for (const entry of entries) playbooks.push(parsePlaybook(await readFile(join(cwd, PLAYBOOKS_DIRECTORY, entry), "utf8"))); return playbooks; } export async function findPlaybook(cwd: string, name: string): Promise<{ playbook: Playbook; body: string } | undefined> { const playbooks = await loadPlaybooks(cwd); const playbook = playbooks.find((entry) => entry.name === name); if (!playbook) return undefined; const body = playbookBody(await readFile(join(cwd, PLAYBOOKS_DIRECTORY, `${name}.md`), "utf8").catch(() => "")); return { playbook, body }; }