#!/usr/bin/env node const { execFileSync } = require("child_process"); const fs = require("fs"); const path = require("path"); const ROOT_DIR = path.resolve(__dirname, ".."); const WORKSPACE_DIR = path.resolve(ROOT_DIR, ".."); const WORKFLOWS_DIR = process.env.MIKE_WORKFLOWS_DIR ? path.resolve(process.env.MIKE_WORKFLOWS_DIR) : path.join(WORKSPACE_DIR, "mike-workflows"); const WORKFLOW_COLLECTIONS = [ { directory: "assistant-workflows", type: "assistant" }, { directory: "tabular-review-workflows", type: "tabular" }, ]; const LANDING_OUT = path.join(ROOT_DIR, "landing/app/generated-workflows.ts"); const WORKFLOW_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; function readText(filePath) { return fs.readFileSync(filePath, "utf8"); } function readJson(filePath) { try { return JSON.parse(readText(filePath)); } catch (error) { throw new Error( `${relative(filePath)} is not valid JSON: ${error.message}`, ); } } function relative(filePath) { return path.relative(WORKSPACE_DIR, filePath); } function fail(message) { throw new Error(message); } // The commit of the mike-workflows checkout the generated files were built // from. Stamped into both outputs so a reviewer (and the CI drift check) can // re-run the generator against the exact same source tree. function resolveSourceCommit() { let commit; try { commit = execFileSync("git", ["-C", WORKFLOWS_DIR, "rev-parse", "HEAD"], { encoding: "utf8", }).trim(); } catch (error) { fail( `Could not resolve the ${relative(WORKFLOWS_DIR)} HEAD commit: ${error.message}`, ); } if (!/^[0-9a-f]{40}$/.test(commit)) { fail( `Unexpected git rev-parse output for ${relative(WORKFLOWS_DIR)}: ${commit}`, ); } return commit; } function parseScalar(value, label) { const trimmed = value.trim(); if (trimmed === "null") return null; if (trimmed === "true") return true; if (trimmed === "false") return false; if (/^-?\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10); if ( (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")) ) { return trimmed.slice(1, -1); } if (trimmed.startsWith("[") || trimmed.startsWith("{")) { try { return JSON.parse(trimmed); } catch (error) { fail(`${label} is not valid inline JSON: ${error.message}`); } } return trimmed; } function parseSimpleYaml(source, label) { const lines = source.replace(/\r\n/g, "\n").split("\n"); const result = {}; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!line.trim() || line.trim().startsWith("#")) continue; if (line.startsWith(" ")) { fail(`${label}:${i + 1} has unsupported indentation`); } const match = line.match(/^(\$?[A-Za-z_][A-Za-z0-9_-]*):(.*)$/); if (!match) fail(`${label}:${i + 1} is not valid frontmatter`); const key = match[1]; const rawValue = match[2].trim(); if (rawValue) { if ([">", ">-", "|", "|-"].includes(rawValue)) { const parts = []; i++; for (; i < lines.length; i++) { const child = lines[i]; if (!child.trim()) { parts.push(""); continue; } if (!child.startsWith(" ")) { i--; break; } parts.push(child.slice(2)); } result[key] = rawValue.startsWith("|") ? parts.join("\n").trimEnd() : parts.join(" ").replace(/\s+/g, " ").trim(); continue; } result[key] = parseScalar(rawValue, `${label}.${key}`); continue; } const scalarItems = []; const objectItems = []; const properties = {}; let mode = null; i++; for (; i < lines.length; i++) { const child = lines[i]; if (!child.trim()) continue; if (!child.startsWith(" ")) { i--; break; } const listMatch = child.match(/^ -(?:\s+(.*))?$/); if (listMatch) { const itemText = listMatch[1]?.trim() ?? ""; if (itemText.includes(":")) { mode ??= "objects"; if (mode !== "objects") { fail(`${label}.${key} mixes scalar and object list items`); } const object = {}; if (itemText) { const itemMatch = itemText.match( /^([A-Za-z_][A-Za-z0-9_-]*):(.*)$/, ); if (!itemMatch) { fail(`${label}:${i + 1} is not a valid object list item`); } object[itemMatch[1]] = parseScalar( itemMatch[2], `${label}.${key}.${itemMatch[1]}`, ); } objectItems.push(object); continue; } mode ??= "scalars"; if (mode !== "scalars") { fail(`${label}.${key} mixes object and scalar list items`); } scalarItems.push(parseScalar(itemText, `${label}.${key}`)); continue; } const childPropMatch = child.match(/^ ([A-Za-z_][A-Za-z0-9_-]*):(.*)$/); if (childPropMatch) { mode ??= "properties"; if (mode !== "properties") { fail(`${label}.${key} mixes mapping and list values`); } properties[childPropMatch[1]] = parseScalar( childPropMatch[2], `${label}.${key}.${childPropMatch[1]}`, ); continue; } const propMatch = child.match(/^ ([A-Za-z_][A-Za-z0-9_-]*):(.*)$/); if (!propMatch || mode !== "objects" || objectItems.length === 0) { fail(`${label}:${i + 1} has unsupported frontmatter structure`); } objectItems[objectItems.length - 1][propMatch[1]] = parseScalar( propMatch[2], `${label}.${key}.${propMatch[1]}`, ); } result[key] = mode === "objects" ? objectItems : mode === "properties" ? properties : scalarItems; } return result; } function readSkillFile(filePath) { const text = readText(filePath).replace(/\r\n/g, "\n"); if (!text.startsWith("---\n")) { fail(`${relative(filePath)} must start with YAML frontmatter`); } const close = text.indexOf("\n---", 4); if (close === -1) { fail(`${relative(filePath)} is missing closing YAML frontmatter marker`); } const afterClose = text.slice(close + 4); if (afterClose && !afterClose.startsWith("\n")) { fail(`${relative(filePath)} has invalid frontmatter closing marker`); } return { metadata: parseSimpleYaml(text.slice(4, close), relative(filePath)), body: afterClose.replace(/^\n/, "").trimEnd(), fullText: text.trimEnd(), }; } function parseTableColumnsYaml(filePath) { const lines = readText(filePath).replace(/\r\n/g, "\n").split("\n"); const result = { columns_config: [] }; let i = 0; while (i < lines.length) { const line = lines[i]; if (!line.trim() || line.trim().startsWith("#")) { i++; continue; } const schemaMatch = line.match(/^\$schema:\s*(.+)$/); if (schemaMatch) { result.$schema = parseScalar( schemaMatch[1], `${relative(filePath)}.$schema`, ); i++; continue; } if (line !== "columns:") { fail(`${relative(filePath)}:${i + 1} is not valid table columns YAML`); } i++; break; } let current = null; for (; i < lines.length; i++) { const line = lines[i]; if (!line.trim()) continue; const itemMatch = line.match(/^ - index:\s*(.+)$/); if (itemMatch) { current = { index: parseScalar( itemMatch[1], `${relative(filePath)}.columns_config.index`, ), }; result.columns_config.push(current); continue; } if (!current) { fail(`${relative(filePath)}:${i + 1} column entry must start with index`); } const propMatch = line.match(/^ ([A-Za-z_][A-Za-z0-9_-]*):(.*)$/); if (!propMatch) { fail(`${relative(filePath)}:${i + 1} is not a valid column property`); } const key = propMatch[1]; const rawValue = propMatch[2].trim(); if (key === "tags" && rawValue === "") { const tags = []; i++; for (; i < lines.length; i++) { const tagMatch = lines[i].match(/^ -\s*(.+)$/); if (!tagMatch) { i--; break; } tags.push(parseScalar(tagMatch[1], `${relative(filePath)}.${key}`)); } current.tags = tags; continue; } if ( rawValue === ">-" || rawValue === ">" || rawValue === "|-" || rawValue === "|" ) { const parts = []; i++; for (; i < lines.length; i++) { if (!lines[i].startsWith(" ")) { i--; break; } parts.push(lines[i].slice(6)); } current[key] = rawValue.startsWith("|") ? parts.join("\n") : parts.join(" ").replace(/\s+/g, " ").trim(); continue; } current[key] = parseScalar(rawValue, `${relative(filePath)}.${key}`); } return result; } function assertString(value, label) { if (typeof value !== "string" || value.trim() === "") { fail(`${label} must be a non-empty string`); } } function assertOptionalString(value, label) { if (value === undefined || value === null) return; if (typeof value !== "string") fail(`${label} must be a string`); } function assertOptionalStringArray(value, label) { if (value === undefined || value === null) return; if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { fail(`${label} must be an array of strings`); } } function normalizeContributors(value, label) { if (!Array.isArray(value) || value.length === 0) { fail(`${label} must be a non-empty array`); } return value.map((contributor, index) => { const contributorLabel = `${label}[${index}]`; if ( !contributor || typeof contributor !== "object" || Array.isArray(contributor) ) { fail(`${contributorLabel} must be an object`); } assertString(contributor.name, `${contributorLabel}.name`); assertOptionalString( contributor.organisation, `${contributorLabel}.organisation`, ); assertOptionalString(contributor.role, `${contributorLabel}.role`); assertOptionalString(contributor.linkedin, `${contributorLabel}.linkedin`); return { name: contributor.name.trim(), organisation: contributor.organisation?.trim() || null, role: contributor.role?.trim() || null, linkedin: contributor.linkedin?.trim() || null, }; }); } function assertColumnConfig(columns, label) { if (!Array.isArray(columns) || columns.length === 0) { fail(`${label}.columns_config must be a non-empty array`); } columns.forEach((column, index) => { const columnLabel = `${label}.columns_config[${index}]`; if (!column || typeof column !== "object" || Array.isArray(column)) { fail(`${columnLabel} must be an object`); } if (!Number.isInteger(column.index)) { fail(`${columnLabel}.index must be an integer`); } assertString(column.name, `${columnLabel}.name`); assertString(column.prompt, `${columnLabel}.prompt`); assertOptionalString(column.format, `${columnLabel}.format`); assertOptionalStringArray(column.tags, `${columnLabel}.tags`); }); } function readPackFile(category, packPath) { const label = relative(packPath); const pack = parseSimpleYaml(readText(packPath), label); assertString(pack.id, `${label}.id`); assertString(pack.title, `${label}.title`); assertString(pack.description, `${label}.description`); assertString(pack.version, `${label}.version`); if (!Array.isArray(pack.workflows) || pack.workflows.length === 0) { fail(`${label}.workflows must be a non-empty list`); } pack.workflows.forEach((workflowName, index) => { assertString(workflowName, `${label}.workflows[${index}]`); }); return { key: `${category}:${pack.id}`, title: pack.title, description: pack.description, version: pack.version, workflow_names: pack.workflows, }; } function readWorkflow(category, workflowDir, pack) { const slug = path.basename(workflowDir); const metadataPath = path.join(workflowDir, "metadata.json"); if (fs.existsSync(metadataPath)) { fail( `${relative(metadataPath)} is no longer supported; use SKILL.md frontmatter`, ); } const skillPath = path.join(workflowDir, "SKILL.md"); if (!fs.existsSync(skillPath)) { fail(`${relative(skillPath)} is required`); } const { metadata: frontmatter, body, fullText: sourceSkillMd, } = readSkillFile(skillPath); let skillMd = body.trimStart(); const label = `${relative(skillPath)} frontmatter`; const metadata = frontmatter.metadata; const id = `builtin-${slug}`; const assetsDir = path.join(workflowDir, "assets"); const assets = fs.existsSync(assetsDir) ? fs .readdirSync(assetsDir, { withFileTypes: true }) .filter((entry) => entry.isFile() && !entry.name.startsWith(".")) .sort((a, b) => a.name.localeCompare(b.name, "en")) .map((entry) => { const assetPath = path.join(assetsDir, entry.name); return { filename: entry.name, file_type: path.extname(entry.name).slice(1).toLowerCase() || "bin", size_bytes: fs.statSync(assetPath).size, content_base64: fs.readFileSync(assetPath).toString("base64"), }; }) : []; assertString(frontmatter.name, `${label}.name`); if (frontmatter.name !== slug) { fail(`${label}.name must match the folder name "${slug}"`); } if (!WORKFLOW_NAME_PATTERN.test(frontmatter.name)) { fail( `${label}.name must contain only lowercase letters, numbers, and hyphens`, ); } assertString(frontmatter.description, `${label}.description`); assertString(frontmatter.license, `${label}.license`); if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { fail(`${label}.metadata must be a mapping`); } assertString(metadata.author, `${label}.metadata.author`); assertString(metadata.language, `${label}.language`); assertString(metadata.version, `${label}.version`); assertString( metadata["mike-display-name"], `${label}.metadata.mike-display-name`, ); if (metadata["mike-type"] !== category) { fail(`${label}.metadata.mike-type must be "${category}"`); } // metadata.mike-availability is deprecated: backend ingestion derives the // default/add-on split from its deployment policy, so the flag is accepted // for backwards compatibility but never emitted. Warn (don't fail) on // unexpected values so existing content keeps building. const availability = metadata["mike-availability"]; if ( availability !== undefined && !["system", "add-on"].includes(availability) ) { console.warn( `Warning: ${label}.metadata.mike-availability has unexpected value ${JSON.stringify(availability)}; the key is deprecated and ignored`, ); } assertString(metadata.practice, `${label}.metadata.practice`); assertString(metadata.jurisdictions, `${label}.metadata.jurisdictions`); const normalizedMetadata = { name: frontmatter.name, title: metadata["mike-display-name"], description: frontmatter.description, type: metadata["mike-type"], contributors: [ { name: metadata.author.trim(), organisation: null, role: null, linkedin: null, }, ], language: metadata.language, version: metadata.version, practice: metadata.practice, jurisdictions: metadata.jurisdictions .split(",") .map((item) => item.trim()) .filter(Boolean), }; if (skillMd && !skillMd.startsWith("# ")) { skillMd = `# ${normalizedMetadata.title}\n\n${skillMd}`; } if (category === "assistant") { if (!skillMd.trim()) { fail( `${relative(skillPath)} must include instructions after frontmatter`, ); } const tableColumnsPath = path.join(workflowDir, "table-columns.yaml"); if (fs.existsSync(tableColumnsPath)) { fail( `${relative(tableColumnsPath)} is only supported for tabular workflows`, ); } return { id, metadata: normalizedMetadata, skill_md: skillMd, source_skill_md: sourceSkillMd, columns_config: null, assets, pack, }; } const tableColumnsPath = path.join(workflowDir, "table-columns.yaml"); if (!fs.existsSync(tableColumnsPath)) { fail(`${relative(tableColumnsPath)} is required for tabular workflows`); } const tableConfig = parseTableColumnsYaml(tableColumnsPath); const tableConfigLabel = relative(tableColumnsPath); const expectedSchemaPath = path.join( WORKFLOWS_DIR, "workflow-schema/table-columns.schema.yaml", ); const actualSchemaPath = path.resolve(workflowDir, tableConfig.$schema ?? ""); if (actualSchemaPath !== expectedSchemaPath) { fail( `${tableConfigLabel}.$schema must point to workflow-schema/table-columns.schema.yaml`, ); } assertColumnConfig(tableConfig.columns_config, tableConfigLabel); return { id, metadata: normalizedMetadata, skill_md: skillMd || null, source_skill_md: sourceSkillMd, columns_config: tableConfig.columns_config, assets, pack, }; } function loadWorkflows() { const workflows = []; const seenIds = new Set(); for (const collection of WORKFLOW_COLLECTIONS) { const collectionDir = path.join(WORKFLOWS_DIR, collection.directory); if (!fs.existsSync(collectionDir)) continue; const workflowDirs = fs .readdirSync(collectionDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) .flatMap((entry) => { const entryDir = path.join(collectionDir, entry.name); if (fs.existsSync(path.join(entryDir, "SKILL.md"))) { return [{ directory: entryDir, pack: null }]; } if (!fs.existsSync(path.join(entryDir, "pack.yaml"))) return []; const pack = readPackFile( collection.type, path.join(entryDir, "pack.yaml"), ); const childDirectories = fs .readdirSync(entryDir, { withFileTypes: true }) .filter( (child) => child.isDirectory() && fs.existsSync(path.join(entryDir, child.name, "SKILL.md")), ) .map((child) => path.join(entryDir, child.name)); const discoveredNames = new Set( childDirectories.map((directory) => path.basename(directory)), ); for (const workflowName of pack.workflow_names) { if (!discoveredNames.has(workflowName)) { fail( `${relative(path.join(entryDir, "pack.yaml"))} lists missing workflow '${workflowName}'`, ); } } // The reverse direction matters too: a workflow directory that // pack.yaml does not list would otherwise silently ship as part of // the pack. const listedNames = new Set(pack.workflow_names); for (const discoveredName of discoveredNames) { if (!listedNames.has(discoveredName)) { fail( `${relative(path.join(entryDir, "pack.yaml"))} does not list discovered workflow '${discoveredName}'`, ); } } return childDirectories.map((directory) => ({ directory, pack })); }) // Pin the collation locale: a bare localeCompare follows the build // machine's ICU locale, which can reorder the output between machines. .sort((a, b) => a.directory.localeCompare(b.directory, "en")); for (const workflowDir of workflowDirs) { const workflow = readWorkflow( collection.type, workflowDir.directory, workflowDir.pack, ); if (seenIds.has(workflow.id)) { fail(`Duplicate workflow id: ${workflow.id}`); } seenIds.add(workflow.id); workflows.push(workflow); } } return workflows.sort((a, b) => a.id.localeCompare(b.id, "en")); } function formatTs(value) { return JSON.stringify(value, null, 4); } function writeGeneratedFile(workflows, sourceCommit) { const landingWorkflows = workflows.map((workflow) => ({ id: workflow.id, metadata: workflow.metadata, skill_md: workflow.source_skill_md, columnCount: workflow.columns_config?.length ?? 0, columns: workflow.columns_config ?? [], })); const landingText = `// This file is generated by scripts/build-workflows.js. Do not edit it directly.\n// Source: Open-Legal-Products/mike-workflows @ ${sourceCommit}\nimport type { LandingWorkflow } from "./workflow-browser";\n\nexport const LANDING_WORKFLOWS: LandingWorkflow[] = ${formatTs(landingWorkflows)};\n`; if (fs.existsSync(path.dirname(LANDING_OUT))) { fs.writeFileSync(LANDING_OUT, landingText); return true; } return false; } function main() { if (!fs.existsSync(WORKFLOWS_DIR)) { fail(`Workflow source directory not found: ${relative(WORKFLOWS_DIR)}`); } for (const collection of WORKFLOW_COLLECTIONS) { const collectionDir = path.join(WORKFLOWS_DIR, collection.directory); if (!fs.existsSync(collectionDir)) { fail(`Workflow collection not found: ${relative(collectionDir)}`); } } const workflows = loadWorkflows(); if (workflows.length === 0) { fail("No workflows found"); } const sourceCommit = resolveSourceCommit(); const generated = writeGeneratedFile(workflows, sourceCommit); console.log( `${generated ? "Generated" : "Validated"} ${workflows.length} landing workflows from mike-workflows @ ${sourceCommit}.`, ); } try { main(); } catch (error) { console.error(error.message); process.exit(1); }