import { writeFile, mkdir } from "node:fs/promises"; import { join, dirname, isAbsolute, normalize } from "node:path"; import type { RiskTier } from "../audit/log.js"; /** * Écriture des artefacts auto-créés par CHLOVA (Phase 5). * * Le dépôt fait foi : un asset auto-créé est écrit en fichier versionné + une doc * générée depuis le gabarit AVANT d'être enregistré en need-review (voir * docs/versioning.md, docs/need-review.md). Chemins sanitizés (anti-traversée). */ export type AutoAssetType = "workflow-n8n" | "tool"; export interface AssetDraft { type: AutoAssetType; name: string; version: string; // semver riskTier: RiskTier; summary: string; /** Contenu : JSON du workflow n8n, ou définition d'outil. */ content: string; } const SEMVER = /^\d+\.\d+\.\d+$/; export function slugify(name: string): string { const slug = name .toLowerCase() .normalize("NFD") .replace(/[̀-ͯ]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); if (!slug) throw new Error("nom d'asset invalide (slug vide)"); return slug; } export function artifactRelPath(draft: AssetDraft): string { const slug = slugify(draft.name); if (draft.type === "workflow-n8n") return `workflows-n8n/${slug}.v${draft.version}.json`; return `tools/${slug}.v${draft.version}.json`; } export function docRelPath(draft: AssetDraft): string { return `docs/assets/${draft.type}-${slugify(draft.name)}.md`; } /** Génère la doc d'asset depuis le gabarit (docs/asset-template.md). */ export function renderDoc(draft: AssetDraft, artifactPath: string, createdAt: string): string { return `## ${draft.name} - **Type** : \`${draft.type}\` - **Version** : \`v${draft.version}\` - **Palier de risque** : \`${draft.riskTier}\` - **Statut (need-review)** : \`${draft.riskTier === "reversible" ? "provisoire" : "bloqué"}\` - **Créé le** : ${createdAt} (auto-extension CHLOVA) - **Fichier** : \`${artifactPath}\` ### Rôle ${draft.summary} ### Sécurité / rollback Asset auto-créé par CHLOVA, en attente de review. Palier \`${draft.riskTier}\` : ${draft.riskTier === "privileged" ? "BLOQUÉ jusqu'à validation humaine (aucun sursis)." : "PROVISOIRE, sursis de 7 jours."} Rollback : \`git revert\` du commit, ou refus via la review (/refuse). `; } function assertSafe(repoRoot: string, rel: string): void { if (isAbsolute(rel) || normalize(rel).startsWith("..")) { throw new Error(`chemin non autorisé: ${rel}`); } } /** * Écrit l'artefact + sa doc dans le dépôt. Retourne les chemins relatifs (servent * de doc_link et de cible de commit). Ne commite PAS (cf. GitCommitter). */ export async function writeArtifact( repoRoot: string, draft: AssetDraft, ): Promise<{ artifactPath: string; docPath: string }> { if (!SEMVER.test(draft.version)) throw new Error(`version semver invalide: ${draft.version}`); const artifactPath = artifactRelPath(draft); const docPath = docRelPath(draft); assertSafe(repoRoot, artifactPath); assertSafe(repoRoot, docPath); const createdAt = new Date().toISOString().slice(0, 10); const doc = renderDoc(draft, artifactPath, createdAt); for (const [rel, body] of [ [artifactPath, draft.content], [docPath, doc], ] as const) { const abs = join(repoRoot, rel); await mkdir(dirname(abs), { recursive: true }); await writeFile(abs, body, "utf8"); } return { artifactPath, docPath }; }