#!/usr/bin/env node import fs from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const scriptSource = await fs.readFile(__filename, "utf8"); async function main() { const args = parseArgs(process.argv.slice(2)); const workspaceRoot = path.resolve(args.root ?? path.join(__dirname, "..")); const workflowPath = path.join(workspaceRoot, ".ai-workflow", "workflow.md"); const manifestPath = path.join(workspaceRoot, ".ai-workflow", "manifest.json"); const workflow = await fs.readFile(workflowPath, "utf8"); const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); validateManifest(manifest); const outputs = buildOutputs({ manifest, workflow, scriptSource, }); if (args.mode === "check") { const drifted = await findDriftedOutputs(workspaceRoot, outputs); if (drifted.length > 0) { console.error("AI workflow outputs are out of sync:"); for (const drift of drifted) { console.error(`- ${drift}`); } process.exit(1); } console.log("AI workflow outputs are in sync."); return; } const changed = await writeOutputs(workspaceRoot, outputs); if (changed.length === 0) { console.log("AI workflow outputs are already up to date."); return; } console.log("Updated AI workflow outputs:"); for (const changedPath of changed) { console.log(`- ${changedPath}`); } } function parseArgs(argv) { const args = { mode: null, root: null, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--write") { args.mode = "write"; continue; } if (arg === "--check") { args.mode = "check"; continue; } if (arg === "--root") { args.root = argv[index + 1]; index += 1; continue; } throw new Error(`Unsupported argument: ${arg}`); } if (!args.mode) { throw new Error("Expected --write or --check."); } return args; } function validateManifest(manifest) { const requiredKeys = ["projects", "assistants", "commands", "generated_outputs", "sync_targets"]; for (const key of requiredKeys) { if (!(key in manifest)) { throw new Error(`Manifest is missing required key: ${key}`); } } const seenPaths = new Set(); for (const output of manifest.generated_outputs) { if (!output.path || !output.template) { throw new Error("Each generated output must define path and template."); } if (seenPaths.has(output.path)) { throw new Error(`Duplicate generated output path: ${output.path}`); } seenPaths.add(output.path); } } function buildOutputs(context) { const outputs = []; for (const output of context.manifest.generated_outputs) { outputs.push({ path: normalizeRelativePath(output.path), content: renderTemplate(output, context), }); } return outputs; } function renderTemplate(output, context) { const templateMap = { codex_workspace_environment: () => renderWorkspaceCodexEnvironment(context.manifest), codex_project_environment: () => renderProjectCodexEnvironment(context.manifest, output.project), aiassistant_backend_tests_rule: () => renderAiAssistantRule(context.manifest.projects["backend-php"].generated_content.aiassistant_tests_lines), aiassistant_backend_routes_rule: () => renderAiAssistantRule(context.manifest.projects["backend-php"].generated_content.aiassistant_routes_lines), aiassistant_frontend_tests_rule: () => renderAiAssistantRule(context.manifest.projects["front-end-vue"].generated_content.aiassistant_tests_lines), junie_backend_guidelines: () => renderJunieGuidelines(context.manifest.projects["backend-php"].generated_content.junie_lines), junie_frontend_guidelines: () => renderJunieGuidelines(context.manifest.projects["front-end-vue"].generated_content.junie_lines), copilot_dispatcher_workflow: () => renderCopilotWorkflow(context.manifest.copilot), workflow_snapshot: () => renderWorkflowSnapshot(context.workflow, output.project), project_snapshot_manifest: () => renderProjectSnapshotManifest(context.manifest, output.project), project_sync_script: () => context.scriptSource, }; const renderer = templateMap[output.template]; if (!renderer) { throw new Error(`Unsupported template: ${output.template}`); } return ensureTrailingNewline(renderer()); } function renderWorkspaceCodexEnvironment(manifest) { return renderCodexEnvironment({ name: manifest.workspace.name, setup: manifest.workspace.codex_environment.setup, actions: manifest.workspace.codex_environment.actions, manifest, workspaceScoped: true, }); } function renderProjectCodexEnvironment(manifest, projectKey) { const project = manifest.projects[projectKey]; return renderCodexEnvironment({ name: project.codex_environment.name, setup: project.commands.setup, actions: project.codex_environment.actions, manifest, projectKey, workspaceScoped: false, }); } function renderCodexEnvironment({ name, setup, actions, manifest, projectKey = null, workspaceScoped }) { const lines = [ "# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY", "version = 1", `name = "${name}"`, "", ]; appendTomlCommandBlock(lines, "setup", setup); for (const action of actions) { lines.push(""); lines.push("[[actions]]"); lines.push(`name = "${escapeTomlString(action.name)}"`); lines.push(`icon = "${escapeTomlString(action.icon)}"`); lines.push(`command = ${renderTomlMultiline(resolveActionCommand(action, manifest, projectKey, workspaceScoped))}`); if (action.platform) { lines.push(`platform = "${escapeTomlString(action.platform)}"`); } } return lines.join("\n"); } function appendTomlCommandBlock(lines, key, commandSpec = {}) { lines.push(`[${key}]`); lines.push(`script = ${renderTomlMultiline(commandSpec.default ?? "")}`); if (commandSpec.win32 !== undefined) { lines.push(""); lines.push(`[${key}.win32]`); lines.push(`script = ${renderTomlMultiline(commandSpec.win32)}`); } } function resolveActionCommand(action, manifest, projectKey, workspaceScoped) { if (action.literal_command) { return action.literal_command.default ?? ""; } if (!action.command_id) { throw new Error(`Action ${action.name} is missing command_id or literal_command.`); } const resolvedProjectKey = action.project ?? projectKey; if (!resolvedProjectKey) { throw new Error(`Action ${action.name} does not resolve to a project.`); } const project = manifest.projects[resolvedProjectKey]; const command = project.commands[action.command_id]?.default ?? ""; if (!workspaceScoped) { return command; } return joinCommands([`cd ${project.relative_root}`, command]); } function renderAiAssistantRule(lines) { return [ "---", "apply: always", "---", "", "", "", ...lines, ].join("\n"); } function renderJunieGuidelines(lines) { return [ "", "", ...lines, ].join("\n"); } function renderCopilotWorkflow(copilot) { const body = copilot.body_lines .map((line) => line.replaceAll("{{ACTOR}}", "$ACTOR").replaceAll("{{TASK}}", "$TASK")) .join("\n"); return [ `name: ${copilot.workflow_name}`, "on:", " workflow_dispatch:", " inputs:", " task:", ` description: '${copilot.input_description}'`, " required: true", " type: string", "", "jobs:", " assign-task:", " runs-on: ubuntu-latest", " permissions:", " issues: write", " steps:", " - name: Checkout repository", " uses: actions/checkout@v4", "", " - name: Create GitHub Issue for Copilot", " env:", " GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}", " TASK: ${{ github.event.inputs.task }}", " ACTOR: ${{ github.actor }}", " run: |", " BODY=\"$(cat < ` ${line}`), " EOF", " )\"", " gh issue create \\", ` --title "${copilot.title_prefix} $TASK" \\`, " --body \"$BODY\" \\", ` --label "${copilot.issue_label}"`, ].join("\n"); } function renderWorkflowSnapshot(workflow, projectKey) { return [ ``, "", workflow.trimEnd(), ].join("\n"); } function renderProjectSnapshotManifest(manifest, projectKey) { const snapshotManifest = buildProjectSnapshotManifest(manifest, projectKey); return `${JSON.stringify(snapshotManifest, null, 2)}\n`; } function buildProjectSnapshotManifest(manifest, projectKey) { const project = JSON.parse(JSON.stringify(manifest.projects[projectKey])); const prefix = `${manifest.projects[projectKey].relative_root}/`; project.relative_root = "."; const snapshotOutputs = manifest.generated_outputs .filter((output) => output.project === projectKey) .filter((output) => output.template !== "workflow_snapshot") .filter((output) => output.template !== "project_snapshot_manifest") .filter((output) => output.template !== "project_sync_script") .map((output) => ({ ...output, path: normalizeRelativePath(output.path.slice(prefix.length)), })); const snapshotManifest = { "version": manifest.version, "snapshot_of": projectKey, "projects": { [projectKey]: project, }, "assistants": manifest.assistants, "commands": manifest.commands, "generated_outputs": snapshotOutputs, "sync_targets": { [projectKey]: { "source_root": ".", "destination": manifest.sync_targets[projectKey]?.destination ?? "", "supported_metadata_dirs": manifest.sync_targets[projectKey]?.supported_metadata_dirs ?? [], }, }, }; if (projectKey === "backend-php" && manifest.copilot) { snapshotManifest.copilot = manifest.copilot; } if (projectKey === "front-end-vue" && manifest.unsupported) { snapshotManifest.unsupported = manifest.unsupported; } return snapshotManifest; } async function findDriftedOutputs(workspaceRoot, outputs) { const drifted = []; for (const output of outputs) { const absolutePath = path.join(workspaceRoot, output.path); const currentContent = await readIfExists(absolutePath); if (currentContent !== output.content) { drifted.push(output.path); } } return drifted; } async function writeOutputs(workspaceRoot, outputs) { const changed = []; for (const output of outputs) { const absolutePath = path.join(workspaceRoot, output.path); const currentContent = await readIfExists(absolutePath); if (currentContent === output.content) { continue; } await fs.mkdir(path.dirname(absolutePath), { recursive: true }); await fs.writeFile(absolutePath, output.content, "utf8"); changed.push(output.path); } return changed; } async function readIfExists(filePath) { try { return await fs.readFile(filePath, "utf8"); } catch (error) { if (error && error.code === "ENOENT") { return null; } throw error; } } function joinCommands(commands) { return commands.filter((command) => command && command.trim() !== "").join("\n"); } function renderTomlMultiline(value) { return `'''\n${value ?? ""}\n'''`; } function escapeTomlString(value) { return String(value).replaceAll("\\", "\\\\").replaceAll("\"", "\\\""); } function normalizeRelativePath(filePath) { return filePath.replaceAll("\\", "/"); } function ensureTrailingNewline(value) { return value.endsWith("\n") ? value : `${value}\n`; } main().catch((error) => { console.error(error.message); process.exit(1); });