import fs from "node:fs"; import path from "node:path"; const ROOT = process.cwd(); const REGISTRY_PATH = path.join(ROOT, "docs", "integrations", "n8n", "registry.json"); const DOC_OUTPUT_PATH = path.join( ROOT, "docs", "integrations", "n8n", "CONTEXT.generated.md" ); const PLANNING_OUTPUT_PATH = path.join(ROOT, ".planning", "codebase", "N8N-CONTEXT.md"); const SOURCE_DIR = path.join(ROOT, "src"); const ENV_EXAMPLE_PATH = path.join(ROOT, ".env.example"); const WORKFLOW_EXPORT_DIR = path.join(ROOT, "docs", "integrations", "n8n", "exports"); const TOUCHPOINT_PATTERN = /\bN8N_[A-Z0-9_]+\b|n8n|webhook/gi; const TOUCHPOINT_LINE_PATTERN = /\bN8N_[A-Z0-9_]+\b|n8n|webhook/i; const CODE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json"]); function ensureDir(targetPath) { fs.mkdirSync(path.dirname(targetPath), { recursive: true }); } function readJsonIfExists(targetPath, fallback) { if (!fs.existsSync(targetPath)) { return fallback; } try { return JSON.parse(fs.readFileSync(targetPath, "utf8")); } catch (error) { throw new Error(`Failed to parse JSON at ${targetPath}: ${error.message}`); } } function readTextIfExists(targetPath) { if (!fs.existsSync(targetPath)) { return ""; } return fs.readFileSync(targetPath, "utf8"); } function walkFiles(dirPath) { if (!fs.existsSync(dirPath)) { return []; } const entries = fs.readdirSync(dirPath, { withFileTypes: true }); const files = []; for (const entry of entries) { const absolutePath = path.join(dirPath, entry.name); if (entry.isDirectory()) { files.push(...walkFiles(absolutePath)); continue; } if (!CODE_EXTENSIONS.has(path.extname(entry.name))) { continue; } files.push(absolutePath); } return files; } function isPrimaryTouchpoint(relativePath) { if (relativePath.includes(".test.")) { return false; } if (relativePath.startsWith("src/messages/")) { return false; } return true; } function collectEnvVars() { const content = readTextIfExists(ENV_EXAMPLE_PATH); const envVars = new Set(); for (const line of content.split(/\r?\n/)) { const match = line.match(/^([A-Z0-9_]+)=/); if (!match) { continue; } if (match[1].includes("N8N") || match[1].includes("WEBHOOK")) { envVars.add(match[1]); } } return [...envVars].sort(); } function detectTouchpoints() { const files = walkFiles(SOURCE_DIR); const touchpoints = []; for (const filePath of files) { const content = fs.readFileSync(filePath, "utf8"); const matches = [...content.matchAll(TOUCHPOINT_PATTERN)]; if (matches.length === 0) { continue; } const relativePath = path.relative(ROOT, filePath).replaceAll("\\", "/"); const lines = content.split(/\r?\n/); const highlights = []; const seenLineNumbers = new Set(); lines.forEach((line, index) => { if (!TOUCHPOINT_LINE_PATTERN.test(line)) { return; } const lineNumber = index + 1; if (seenLineNumbers.has(lineNumber)) { return; } seenLineNumbers.add(lineNumber); highlights.push({ lineNumber, text: line.trim(), }); }); touchpoints.push({ path: relativePath, matchCount: matches.length, highlights: highlights.slice(0, 5), }); } return touchpoints.sort((a, b) => a.path.localeCompare(b.path)); } function listExports() { if (!fs.existsSync(WORKFLOW_EXPORT_DIR)) { return []; } return fs .readdirSync(WORKFLOW_EXPORT_DIR, { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) .map((entry) => entry.name) .sort(); } function toList(value) { return Array.isArray(value) ? value.filter(Boolean) : []; } function formatBullets(items, emptyText = "- none") { if (items.length === 0) { return [emptyText]; } return items.map((item) => `- ${item}`); } function quoteInline(value) { return String(value).replaceAll("`", "\\`"); } function renderWorkflow(workflow) { const entrypoints = toList(workflow?.n8n?.entrypoints).map((entrypoint) => { const method = entrypoint.method ? `${entrypoint.method} ` : ""; const pathValue = entrypoint.path || "(missing path)"; return `${entrypoint.kind || "entrypoint"}: ${method}${pathValue}`.trim(); }); const consumers = toList(workflow?.repository?.consumers); const envVars = toList(workflow?.repository?.env); const schemas = toList(workflow?.repository?.schemas); const requestFields = toList(workflow?.contracts?.requestFields).map((item) => `\`${item}\``); const responseFields = toList(workflow?.contracts?.responseFields).map((item) => `\`${item}\``); const upstreams = toList(workflow?.upstreams); const downstreams = toList(workflow?.downstreams); const owners = toList(workflow?.owners); const notes = workflow?.notes ? [`- ${workflow.notes}`] : ["- none"]; const lines = [ `## ${workflow.name || workflow.id || "Unnamed workflow"}`, "", `- Status: \`${workflow.status || "unknown"}\``, `- ID: \`${quoteInline(workflow.id || "missing-id")}\``, `- Purpose: ${workflow.purpose || "missing purpose"}`, `- n8n workflow id: \`${quoteInline(workflow?.n8n?.workflowId || "not recorded")}\``, `- Export file: \`${quoteInline(workflow?.n8n?.exportFile || "not recorded")}\``, "", "### Entrypoints", ...formatBullets(entrypoints), "", "### Repository Touchpoints", ...formatBullets(consumers), "", "### Environment", ...formatBullets(envVars), "", "### Contracts", `- Request fields: ${requestFields.length > 0 ? requestFields.join(", ") : "none"}`, `- Response fields: ${responseFields.length > 0 ? responseFields.join(", ") : "none"}`, "", "### Related Systems", `- Upstreams: ${upstreams.length > 0 ? upstreams.join(", ") : "none"}`, `- Downstreams: ${downstreams.length > 0 ? downstreams.join(", ") : "none"}`, "", "### Ownership", ...formatBullets(owners), "", "### Notes", ...notes, "", ]; if (schemas.length > 0) { lines.splice(lines.indexOf("### Related Systems"), 0, "### Schemas", ...formatBullets(schemas), ""); } return lines.join("\n"); } function renderTouchpoint(touchpoint) { const lines = [ `- \`${touchpoint.path}\` (${touchpoint.matchCount} matches)`, ]; for (const highlight of touchpoint.highlights) { lines.push(` - L${highlight.lineNumber}: \`${quoteInline(highlight.text)}\``); } return lines.join("\n"); } function buildMarkdown(registry, touchpoints, envVars, exportsList) { const workflows = toList(registry?.workflows); const documentedConsumers = new Set( workflows.flatMap((workflow) => toList(workflow?.repository?.consumers)) ); const undocumentedTouchpoints = touchpoints.filter( (touchpoint) => isPrimaryTouchpoint(touchpoint.path) && !documentedConsumers.has(touchpoint.path) ); const sections = [ "# N8N Context", "", `Generated at: ${new Date().toISOString()}`, "", "This file is generated from `docs/integrations/n8n/registry.json` plus repository scanning.", "It exists so external n8n workflows become committed, reviewable context for AI agents and GSD.", "", "## Workflow Inventory", "", ]; if (workflows.length === 0) { sections.push("No workflows documented in `docs/integrations/n8n/registry.json` yet.", ""); } else { for (const workflow of workflows) { sections.push(renderWorkflow(workflow)); } } sections.push("## Exported Workflow Files", ""); sections.push(...formatBullets(exportsList.map((fileName) => `docs/integrations/n8n/exports/${fileName}`))); sections.push(""); sections.push("## Detected Repository Touchpoints", ""); if (touchpoints.length === 0) { sections.push("No n8n or webhook references detected under `src/`.", ""); } else { for (const touchpoint of touchpoints) { sections.push(renderTouchpoint(touchpoint), ""); } } sections.push("## Environment Variables", ""); sections.push(...formatBullets(envVars), ""); sections.push("## Gaps To Fill", ""); if (undocumentedTouchpoints.length === 0) { sections.push("- All detected repo touchpoints are mapped to documented workflows.", ""); } else { sections.push( "- These files mention n8n or webhook logic but are not mapped in `docs/integrations/n8n/registry.json`:" ); sections.push(...formatBullets(undocumentedTouchpoints.map((item) => item.path)), ""); } sections.push("## Maintenance Rules", ""); sections.push("- When an n8n workflow changes, update `docs/integrations/n8n/registry.json` in the same PR."); sections.push("- If possible, export the workflow JSON into `docs/integrations/n8n/exports/` and reference it from the registry."); sections.push("- Re-run `pnpm n8n:context` after every workflow, contract, or route change."); sections.push("- Treat this file as generated output; edit the registry instead of editing this file directly.", ""); return sections.join("\n"); } function writeOutput(outputPath, content) { ensureDir(outputPath); fs.writeFileSync(outputPath, content, "utf8"); } function main() { const registry = readJsonIfExists(REGISTRY_PATH, { workflows: [] }); const touchpoints = detectTouchpoints(); const envVars = collectEnvVars(); const exportsList = listExports(); const markdown = buildMarkdown(registry, touchpoints, envVars, exportsList); writeOutput(DOC_OUTPUT_PATH, markdown); if (fs.existsSync(path.dirname(PLANNING_OUTPUT_PATH))) { writeOutput(PLANNING_OUTPUT_PATH, markdown); } console.log(`Generated ${path.relative(ROOT, DOC_OUTPUT_PATH)}`); if (fs.existsSync(path.dirname(PLANNING_OUTPUT_PATH))) { console.log(`Generated ${path.relative(ROOT, PLANNING_OUTPUT_PATH)}`); } } main();