Files
pleno-vue/scripts/sync-ai-workflow.mjs
T

415 lines
12 KiB
JavaScript

#!/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",
"---",
"",
"<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->",
"",
...lines,
].join("\n");
}
function renderJunieGuidelines(lines) {
return [
"<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->",
"",
...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 <<EOF",
...body.split("\n").map((line) => ` ${line}`),
" EOF",
" )\"",
" gh issue create \\",
` --title "${copilot.title_prefix} $TASK" \\`,
" --body \"$BODY\" \\",
` --label "${copilot.issue_label}"`,
].join("\n");
}
function renderWorkflowSnapshot(workflow, projectKey) {
return [
`<!-- AUTOGENERATED SNAPSHOT for ${projectKey}: refresh from the canonical workspace with \`node scripts/sync-ai-workflow.mjs --write\`. -->`,
"",
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);
});