@plainconceptsplatform/workflows 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/action-validation.test.d.ts +1 -0
  2. package/dist/action-validation.test.js +87 -0
  3. package/dist/catalog-installation.d.ts +30 -0
  4. package/dist/catalog-installation.js +422 -0
  5. package/dist/catalog-installation.test.d.ts +1 -0
  6. package/dist/catalog-installation.test.js +485 -0
  7. package/dist/catalog-listing.d.ts +13 -0
  8. package/dist/catalog-listing.js +70 -0
  9. package/dist/catalog-listing.test.d.ts +1 -0
  10. package/dist/catalog-listing.test.js +150 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +218 -0
  13. package/dist/index.test.d.ts +1 -0
  14. package/dist/index.test.js +273 -0
  15. package/dist/repository-inspection.d.ts +23 -0
  16. package/dist/repository-inspection.js +113 -0
  17. package/dist/repository-inspection.test.d.ts +1 -0
  18. package/dist/repository-inspection.test.js +77 -0
  19. package/dist/route-processing.d.ts +6 -0
  20. package/dist/route-processing.js +149 -0
  21. package/dist/route-processing.test.d.ts +1 -0
  22. package/dist/route-processing.test.js +283 -0
  23. package/dist/stack-defaults.d.ts +11 -0
  24. package/dist/stack-defaults.js +104 -0
  25. package/dist/stack-defaults.test.d.ts +1 -0
  26. package/dist/stack-defaults.test.js +266 -0
  27. package/dist/tui.d.ts +25 -0
  28. package/dist/tui.js +287 -0
  29. package/dist/tui.test.d.ts +1 -0
  30. package/dist/tui.test.js +249 -0
  31. package/dist/workflow-catalog.d.ts +25 -0
  32. package/dist/workflow-catalog.js +55 -0
  33. package/dist/workflow-catalog.test.d.ts +1 -0
  34. package/dist/workflow-catalog.test.js +29 -0
  35. package/loops/actions/add-issue-labels/action.yml +2 -2
  36. package/loops/actions/apply-agent-bundle/apply-bundle.sh +14 -1
  37. package/loops/actions/classify-route/action.yml +7 -0
  38. package/loops/actions/classify-route/classify-route.sh +20 -5
  39. package/loops/actions/cleanup-artifacts/action.yml +38 -12
  40. package/loops/actions/identify-gate-subject/action.yml +3 -1
  41. package/loops/actions/remove-issue-labels/action.yml +4 -2
  42. package/loops/actions/validate-refine-output/validate-refine-output.sh +18 -2
  43. package/loops/actions/verify-refine-output/verify-refine-output.sh +20 -0
  44. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +300 -16
  45. package/loops/scripts/compile-agent-workflows.mjs +96 -1
  46. package/loops/scripts/merge-changelog.mjs +76 -0
  47. package/loops/templates/agentics/actionlint.yaml +13 -0
  48. package/loops/templates/agentics/agentics-checks.yml +203 -86
  49. package/loops/templates/ci/app-ci-dotnet-next.yml +64 -2
  50. package/loops/templates/ci/app-ci-node-monorepo.yml +52 -0
  51. package/loops/templates/opencode/opencode.ci.json +2 -2
  52. package/loops/templates/opencode/opencode.ci.json.md +4 -1
  53. package/loops/workflows/agent-apply-review.md +5 -1
  54. package/loops/workflows/agent-implement.md +105 -3
  55. package/loops/workflows/agent-merge-gate.md +139 -25
  56. package/loops/workflows/agent-refine.md +26 -2
  57. package/loops/workflows/work-router.yml +227 -51
  58. package/package.json +1 -1
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+ import { parse } from "yaml";
6
+ async function collectActionYmlFiles(actionsDir) {
7
+ const entries = await readdir(actionsDir, { withFileTypes: true });
8
+ const results = [];
9
+ for (const entry of entries) {
10
+ if (entry.isDirectory()) {
11
+ for (const sub of await readdir(join(actionsDir, entry.name), { withFileTypes: true })) {
12
+ if (sub.isFile() && sub.name === "action.yml") {
13
+ results.push(join(actionsDir, entry.name, sub.name));
14
+ }
15
+ }
16
+ }
17
+ }
18
+ return results;
19
+ }
20
+ async function loadManifests() {
21
+ // catalogSourcePath() resolves against the published package layout, where loops/ ships
22
+ // next to the compiled module. In this repository loops/ lives at the root, two levels
23
+ // above this test, so resolve it from the source tree instead.
24
+ const actionsDir = join(fileURLToPath(new URL("../../loops", import.meta.url)), "actions");
25
+ const files = await collectActionYmlFiles(actionsDir);
26
+ return Promise.all(files.map(async (file) => {
27
+ const content = await readFile(file, "utf8");
28
+ return { file, content, manifest: parse(content) };
29
+ }));
30
+ }
31
+ describe("action.yml manifest validation", () => {
32
+ it("finds action.yml files under loops/actions/", async () => {
33
+ const manifests = await loadManifests();
34
+ expect(manifests.length).toBeGreaterThanOrEqual(1);
35
+ });
36
+ it("every action.yml parses as valid YAML", async () => {
37
+ for (const { file, content } of await loadManifests()) {
38
+ expect(() => parse(content), `${file}: failed to parse as YAML`).not.toThrow();
39
+ }
40
+ });
41
+ it("every action.yml has runs.using === composite", async () => {
42
+ for (const { file, manifest } of await loadManifests()) {
43
+ expect(manifest.runs?.using, `${file}: runs.using is not "composite"`).toBe("composite");
44
+ }
45
+ });
46
+ it("every action.yml has at least one step in runs.steps", async () => {
47
+ for (const { file, manifest } of await loadManifests()) {
48
+ expect(manifest.runs?.steps, `${file}: runs.steps is undefined`).toBeDefined();
49
+ expect(manifest.runs.steps.length, `${file}: runs.steps is empty — file may be truncated`).toBeGreaterThanOrEqual(1);
50
+ }
51
+ });
52
+ it("every step with shell: also has run:", async () => {
53
+ for (const { file, manifest } of await loadManifests()) {
54
+ const steps = manifest.runs?.steps ?? [];
55
+ for (const [index, step] of steps.entries()) {
56
+ const label = step.name ?? step.id ?? `step ${index}`;
57
+ if (step.shell !== undefined) {
58
+ expect(step.run, `${file}: step "${label}" has shell: but no run:`).toBeDefined();
59
+ expect(typeof step.run, `${file}: step "${label}" run is not a string`).toBe("string");
60
+ expect(step.run.trim().length, `${file}: step "${label}" run is empty`).toBeGreaterThan(0);
61
+ }
62
+ }
63
+ }
64
+ });
65
+ it("every step with env: also has run: or uses:", async () => {
66
+ for (const { file, manifest } of await loadManifests()) {
67
+ const steps = manifest.runs?.steps ?? [];
68
+ for (const [index, step] of steps.entries()) {
69
+ const label = step.name ?? step.id ?? `step ${index}`;
70
+ if (step.env !== undefined) {
71
+ expect(step.run !== undefined || step.uses !== undefined, `${file}: step "${label}" has env: but no run: or uses:`).toBe(true);
72
+ }
73
+ }
74
+ }
75
+ });
76
+ it("no action.yml is truncated (last step has run: or uses: with non-empty value)", async () => {
77
+ for (const { file, manifest } of await loadManifests()) {
78
+ const steps = manifest.runs?.steps ?? [];
79
+ expect(steps.length, `${file}: no steps found — file may be truncated`).toBeGreaterThanOrEqual(1);
80
+ const lastStep = steps[steps.length - 1];
81
+ const hasTerminal = (lastStep.run !== undefined && lastStep.run.trim().length > 0) ||
82
+ (lastStep.uses !== undefined && lastStep.uses.trim().length > 0) ||
83
+ (lastStep.with !== undefined);
84
+ expect(hasTerminal, `${file}: last step appears truncated`).toBe(true);
85
+ }
86
+ });
87
+ });
@@ -0,0 +1,30 @@
1
+ import { type RouteName, type TemplateName } from "./workflow-catalog.js";
2
+ import type { RepositoryInspection } from "./repository-inspection.js";
3
+ export interface CatalogInstallResult {
4
+ readonly installed: readonly string[];
5
+ readonly conflicts: readonly string[];
6
+ }
7
+ export interface CatalogInstallOptions {
8
+ readonly force?: boolean;
9
+ readonly sourcePath?: string;
10
+ readonly selectedRoutes?: readonly RouteName[];
11
+ readonly inspection?: RepositoryInspection;
12
+ readonly compile?: (repositoryPath: string) => Promise<void>;
13
+ }
14
+ interface CatalogFile {
15
+ readonly source: string;
16
+ readonly target: string;
17
+ readonly managed: boolean;
18
+ }
19
+ export declare function mandatoryFileSpecs(sourcePath: string): CatalogFile[];
20
+ export declare function catalogSourcePath(modulePath?: string): string;
21
+ export declare function installCatalog(repositoryPath: string, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
22
+ export declare function installTemplate(repositoryPath: string, template: TemplateName, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
23
+ export declare function installMandatoryFiles(repositoryPath: string, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
24
+ export declare function installedRoutes(repositoryPath: string): Promise<RouteName[]>;
25
+ export declare function removeRouteFiles(repositoryPath: string, routes: readonly RouteName[]): Promise<string[]>;
26
+ export declare function isTemplateName(value: string): value is TemplateName;
27
+ export declare function ensurePreCommitHook(repositoryPath: string): Promise<void>;
28
+ export declare function runCompileIfAvailable(repositoryPath: string): Promise<void>;
29
+ export declare function exists(path: string): Promise<boolean>;
30
+ export {};
@@ -0,0 +1,422 @@
1
+ import { execFile } from "node:child_process";
2
+ import { access, copyFile, cp, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
+ import { constants } from "node:fs";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { promisify } from "node:util";
7
+ import { parse as parseYaml } from "yaml";
8
+ import { catalogTemplates, mandatoryFiles, routeNames, templateNames, workflowRoutes } from "./workflow-catalog.js";
9
+ import { processRoutes, excludedWorkerFiles } from "./route-processing.js";
10
+ import { generateOpencodeCi, generateOpencodeConfig, generateStackDefaults, injectStackEnv } from "./stack-defaults.js";
11
+ const execFileAsync = promisify(execFile);
12
+ const sourceMappings = [
13
+ ["actions", ".github/actions"],
14
+ ["workflows", ".github/workflows"],
15
+ ["scripts", "scripts"],
16
+ ];
17
+ export function mandatoryFileSpecs(sourcePath) {
18
+ return mandatoryFiles.map((spec) => ({
19
+ source: join(sourcePath, spec.source),
20
+ target: spec.target,
21
+ managed: true,
22
+ }));
23
+ }
24
+ export function catalogSourcePath(modulePath = fileURLToPath(import.meta.url)) {
25
+ return resolve(dirname(modulePath), "..", "loops");
26
+ }
27
+ export async function installCatalog(repositoryPath, options = {}) {
28
+ const sourcePath = options.sourcePath ?? catalogSourcePath();
29
+ const selectedRoutes = options.selectedRoutes ?? routeNames;
30
+ const allFiles = [...await catalogFiles(sourcePath), ...mandatoryFileSpecs(sourcePath)];
31
+ const deduplicated = allFiles.filter((file, index) => allFiles.findIndex((f) => f.target === file.target) === index).sort((left, right) => left.target.localeCompare(right.target));
32
+ const excluded = excludedWorkerFiles(selectedRoutes);
33
+ const filtered = deduplicated.filter((file) => {
34
+ const fileName = file.target.split("/").pop() ?? "";
35
+ return !excluded.has(fileName);
36
+ });
37
+ const fileContents = new Map();
38
+ for (const file of filtered) {
39
+ fileContents.set(file.target, await readFile(file.source, "utf8"));
40
+ }
41
+ let processedContents = processRoutes(fileContents, selectedRoutes);
42
+ if (options.inspection !== undefined) {
43
+ const defaults = generateStackDefaults(options.inspection);
44
+ processedContents = injectStackIntoWorkers(processedContents, defaults);
45
+ processedContents = transformOpencodeFiles(processedContents, options.inspection);
46
+ }
47
+ processedContents = await preserveConsumerWorkerEnv(repositoryPath, processedContents);
48
+ const updates = [...processedContents.entries()]
49
+ .filter(([target]) => filtered.find((file) => file.target === target)?.managed ?? true)
50
+ .map(([target, content]) => ({ target, content }));
51
+ const conflicts = await conflictingTargets(repositoryPath, updates);
52
+ if (conflicts.length > 0 && !options.force)
53
+ return { installed: [], conflicts };
54
+ const stagedLocks = await validateStagedCatalog(repositoryPath, updates, options.compile);
55
+ await applyTransaction(repositoryPath, [...updates, ...stagedLocks, await preCommitHookUpdate(repositoryPath)]);
56
+ return { installed: [...processedContents.keys()].sort(), conflicts };
57
+ }
58
+ function injectStackIntoWorkers(files, defaults) {
59
+ const result = new Map(files);
60
+ for (const [key, content] of result) {
61
+ if (key.startsWith(".github/workflows/agent-") && key.endsWith(".md")) {
62
+ result.set(key, injectStackEnv(content, defaults));
63
+ }
64
+ }
65
+ return result;
66
+ }
67
+ async function preserveConsumerWorkerEnv(repositoryPath, files) {
68
+ const result = new Map(files);
69
+ for (const [target, content] of result) {
70
+ if (!target.startsWith(".github/workflows/agent-") || !target.endsWith(".md"))
71
+ continue;
72
+ const existingPath = join(repositoryPath, target);
73
+ if (!await exists(existingPath))
74
+ continue;
75
+ result.set(target, mergeWorkerEnv(content, await readFile(existingPath, "utf8")));
76
+ }
77
+ return result;
78
+ }
79
+ function mergeWorkerEnv(packageContent, consumerContent) {
80
+ const consumerEnv = workerEnvValues(consumerContent);
81
+ let result = packageContent.replace(/^ ([A-Z][A-Z0-9_]*): .+$/gm, (line, key) => consumerEnv.has(key) ? ` ${key}: ${consumerEnv.get(key)}` : line);
82
+ const endpoint = engineEndpoint(consumerContent);
83
+ if (endpoint !== undefined) {
84
+ result = result.replace(/^ OPENAI_BASE_URL: .+$/m, ` OPENAI_BASE_URL: ${endpoint}`);
85
+ }
86
+ return preserveRunnerPool(result, consumerContent);
87
+ }
88
+ // Which self-hosted pool a worker's agent jobs run on. gh-aw cannot share runs-on through
89
+ // an import, so every worker names it, and it belongs with the env values rather than with
90
+ // the prompt: a forced update that rewrote it would move a repository's agents onto another
91
+ // pool, and one of ours has two machines in it. ubuntu-latest is excluded, being GitHub's
92
+ // own runner, used by the deterministic jobs everywhere and chosen by nobody.
93
+ function runnerPools(content) {
94
+ const found = new Set();
95
+ for (const match of content.matchAll(/^\s*runs-on(?:-slim)?: (\S+)\s*$/gm)) {
96
+ if (match[1] !== "ubuntu-latest")
97
+ found.add(match[1]);
98
+ }
99
+ return [...found];
100
+ }
101
+ function preserveRunnerPool(packageContent, consumerContent) {
102
+ const mine = runnerPools(consumerContent);
103
+ const theirs = runnerPools(packageContent);
104
+ // Only an unambiguous swap. A consumer naming several pools has drifted rather than
105
+ // decided, and guessing which it meant is worse than leaving the package's.
106
+ if (mine.length !== 1 || theirs.length !== 1 || mine[0] === theirs[0])
107
+ return packageContent;
108
+ return packageContent.replace(/^(\s*runs-on(?:-slim)?: )(\S+)(\s*)$/gm, (line, prefix, pool, tail) => pool === "ubuntu-latest" ? line : `${prefix}${mine[0]}${tail}`);
109
+ }
110
+ function workerEnvValues(content) {
111
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/m.exec(content)?.[1];
112
+ const envBlock = frontmatter === undefined ? undefined : /^env:\r?\n((?: .*\r?\n?)*)/m.exec(frontmatter)?.[1];
113
+ const values = new Map();
114
+ if (envBlock === undefined)
115
+ return values;
116
+ for (const line of envBlock.split(/\r?\n/)) {
117
+ const match = /^ ([A-Z][A-Z0-9_]*): (.+)$/.exec(line);
118
+ if (match !== null)
119
+ values.set(match[1], match[2]);
120
+ }
121
+ return values;
122
+ }
123
+ function engineEndpoint(content) {
124
+ return /^ OPENAI_BASE_URL: (.+)$/m.exec(content)?.[1];
125
+ }
126
+ function transformOpencodeFiles(files, inspection) {
127
+ const result = new Map(files);
128
+ for (const [key, content] of result) {
129
+ if (key.endsWith("opencode-ci.md")) {
130
+ result.set(key, generateOpencodeCi(content, inspection));
131
+ }
132
+ else if (key === "opencode.ci.json") {
133
+ result.set(key, generateOpencodeConfig(content, inspection));
134
+ }
135
+ }
136
+ return result;
137
+ }
138
+ // Templates that need a companion config beside the workflow file. actionlint only
139
+ // knows GitHub-hosted runner labels, so every workflow naming a self-hosted label is an
140
+ // error without this file; it belongs wherever the lint workflow that reads it lives.
141
+ const templateCompanions = {
142
+ "agentics-checks": [{ source: "templates/agentics/actionlint.yaml", target: ".github/actionlint.yaml" }],
143
+ };
144
+ export async function installTemplate(repositoryPath, template, options = {}) {
145
+ const sourcePath = options.sourcePath ?? catalogSourcePath();
146
+ const meta = catalogTemplateMeta(template);
147
+ const source = join(sourcePath, "templates", meta.directory, meta.file);
148
+ const target = meta.target;
149
+ const companions = templateCompanions[template] ?? [];
150
+ const destinations = [
151
+ { source, target, companion: false },
152
+ ...companions.map((companion) => ({
153
+ source: join(sourcePath, companion.source),
154
+ target: companion.target,
155
+ companion: true,
156
+ })),
157
+ ];
158
+ const conflicts = (await Promise.all(destinations.map(async (entry) => await exists(join(repositoryPath, entry.target)) && await exists(entry.source) && !(await filesMatch(entry.source, join(repositoryPath, entry.target)))
159
+ ? entry.target
160
+ : undefined))).filter((file) => file !== undefined);
161
+ if (conflicts.length > 0 && !options.force)
162
+ return { installed: [], conflicts };
163
+ for (const entry of destinations) {
164
+ if (!(await exists(entry.source)))
165
+ continue; // companion files may not exist for every template version
166
+ const destination = join(repositoryPath, entry.target);
167
+ await mkdir(dirname(destination), { recursive: true });
168
+ await copyFile(entry.source, destination);
169
+ }
170
+ if (options.inspection !== undefined && template === "opencode.ci.json") {
171
+ const baseContent = await readFile(source, "utf8");
172
+ const transformed = generateOpencodeConfig(baseContent, options.inspection);
173
+ await writeFile(join(repositoryPath, target), transformed, "utf8");
174
+ }
175
+ try {
176
+ await runCompileIfAvailable(repositoryPath);
177
+ }
178
+ catch {
179
+ // compile failure is non-fatal
180
+ }
181
+ return { installed: (await Promise.all(destinations.map(async (entry) => await exists(entry.source) ? entry.target : undefined))).filter((file) => file !== undefined), conflicts };
182
+ }
183
+ export async function installMandatoryFiles(repositoryPath, options = {}) {
184
+ const sourcePath = options.sourcePath ?? catalogSourcePath();
185
+ const files = mandatoryFileSpecs(sourcePath).sort((left, right) => left.target.localeCompare(right.target));
186
+ const conflicts = (await Promise.all(files.map(async (file) => {
187
+ const destination = join(repositoryPath, file.target);
188
+ return await exists(destination) && !(await filesMatch(file.source, destination)) ? file.target : undefined;
189
+ }))).filter((file) => file !== undefined);
190
+ if (conflicts.length > 0 && !options.force)
191
+ return { installed: [], conflicts };
192
+ const updates = await Promise.all(files.map(async (file) => ({ target: file.target, content: await readFile(file.source, "utf8") })));
193
+ await applyTransaction(repositoryPath, [...updates, await preCommitHookUpdate(repositoryPath)]);
194
+ return { installed: files.map((file) => file.target), conflicts };
195
+ }
196
+ export async function installedRoutes(repositoryPath) {
197
+ const found = await Promise.all(workflowRoutes.map(async (route) => (await exists(join(repositoryPath, ".github", "workflows", route.worker))) ? route.name : undefined));
198
+ return found.filter((name) => name !== undefined);
199
+ }
200
+ export async function removeRouteFiles(repositoryPath, routes) {
201
+ const workerByRoute = new Map(workflowRoutes.map((route) => [route.name, route.worker]));
202
+ const removed = [];
203
+ for (const route of routes) {
204
+ const worker = workerByRoute.get(route);
205
+ if (worker === undefined)
206
+ continue;
207
+ const lock = worker.replace(/\.md$/, ".lock.yml");
208
+ for (const file of [worker, lock]) {
209
+ const destination = join(repositoryPath, ".github", "workflows", file);
210
+ if (await exists(destination)) {
211
+ await rm(destination, { force: true });
212
+ removed.push(`.github/workflows/${file}`);
213
+ }
214
+ }
215
+ }
216
+ return removed.sort();
217
+ }
218
+ export function isTemplateName(value) {
219
+ return templateNames.includes(value);
220
+ }
221
+ export async function ensurePreCommitHook(repositoryPath) {
222
+ const update = await preCommitHookUpdate(repositoryPath);
223
+ await applyTransaction(repositoryPath, [update]);
224
+ }
225
+ async function preCommitHookUpdate(repositoryPath) {
226
+ const target = ".husky/pre-commit";
227
+ const hookPath = join(repositoryPath, target);
228
+ const compileLine = "node scripts/compile-agent-workflows.mjs";
229
+ const stageLine = "git add -- .github/workflows/*.lock.yml";
230
+ const actionLockLine = "[ ! -f .github/actions/actions-lock.json ] || git add -- .github/actions/actions-lock.json";
231
+ const managedLines = `if git diff --cached --name-only -- .github | grep -q .; then\n ${compileLine}\n ${stageLine}\n ${actionLockLine}\nfi\n`;
232
+ if (!await exists(hookPath)) {
233
+ return { target, content: managedLines };
234
+ }
235
+ const content = (await readFile(hookPath, "utf8"))
236
+ .replace("pnpm exec if git diff --cached --name-only -- .github | grep -q .; then", "if git diff --cached --name-only -- .github | grep -q .; then");
237
+ if (content.includes("compile-agent-workflows")) {
238
+ const legacyLines = `${compileLine}\n${stageLine}\n${actionLockLine}\n`;
239
+ if (content.includes(managedLines))
240
+ return { target, content };
241
+ if (content.includes(legacyLines))
242
+ return { target, content: content.replace(legacyLines, managedLines) };
243
+ const suffix = content.endsWith("\n") || content === "" ? "" : "\n";
244
+ return { target, content: `${content}${suffix}${managedLines}` };
245
+ }
246
+ return { target, content: content.endsWith("\n") || content === ""
247
+ ? `${content}${managedLines}`
248
+ : `${content}\n${managedLines}` };
249
+ }
250
+ export async function runCompileIfAvailable(repositoryPath) {
251
+ const script = join(repositoryPath, "scripts", "compile-agent-workflows.mjs");
252
+ if (await exists(script)) {
253
+ await execFileAsync("node", [script, "--force"], { cwd: repositoryPath });
254
+ }
255
+ }
256
+ async function conflictingTargets(repositoryPath, updates) {
257
+ const conflicts = (await Promise.all(updates.map(async ({ target, content }) => {
258
+ const destination = join(repositoryPath, target);
259
+ if (!await exists(destination))
260
+ return undefined;
261
+ return (await readFile(destination, "utf8")) === content ? undefined : target;
262
+ }))).filter((target) => target !== undefined);
263
+ return [...new Set(conflicts)].sort();
264
+ }
265
+ async function validateStagedCatalog(repositoryPath, updates, compileOverride) {
266
+ const temporaryRoot = join(repositoryPath, ".opencode", ".tmp");
267
+ await mkdir(temporaryRoot, { recursive: true });
268
+ const stagingPath = await mkdtemp(join(temporaryRoot, "workflows-"));
269
+ try {
270
+ await copyCompilationInputs(repositoryPath, stagingPath);
271
+ await writeUpdates(stagingPath, updates);
272
+ await initializeStagingRepository(stagingPath);
273
+ const compiler = compileOverride ?? await packageCompiler(stagingPath);
274
+ if (compiler === undefined)
275
+ return [];
276
+ await compiler(stagingPath);
277
+ return await generatedFiles(stagingPath);
278
+ }
279
+ finally {
280
+ await rm(stagingPath, { force: true, recursive: true });
281
+ }
282
+ }
283
+ async function copyCompilationInputs(repositoryPath, stagingPath) {
284
+ const githubPath = join(repositoryPath, ".github");
285
+ if (await exists(githubPath))
286
+ await cp(githubPath, join(stagingPath, ".github"), { recursive: true });
287
+ }
288
+ async function initializeStagingRepository(stagingPath) {
289
+ await execFileAsync("git", ["init", "--quiet"], { cwd: stagingPath, windowsHide: true });
290
+ }
291
+ async function packageCompiler(repositoryPath) {
292
+ const scriptPath = join(repositoryPath, "scripts", "compile-agent-workflows.mjs");
293
+ if (!await exists(scriptPath))
294
+ return undefined;
295
+ const content = await readFile(scriptPath, "utf8");
296
+ return content.includes("gh aw compile") ? runCompileIfAvailable : undefined;
297
+ }
298
+ async function generatedFiles(repositoryPath) {
299
+ const workflowPath = join(repositoryPath, ".github", "workflows");
300
+ const updates = [];
301
+ if (await exists(workflowPath)) {
302
+ for (const file of await filesIn(workflowPath)) {
303
+ if (!file.endsWith(".lock.yml"))
304
+ continue;
305
+ updates.push({ target: `.github/workflows/${file.replaceAll("\\", "/")}`, content: await readFile(join(workflowPath, file), "utf8") });
306
+ }
307
+ }
308
+ const actionsLock = join(repositoryPath, ".github", "actions", "actions-lock.json");
309
+ if (await exists(actionsLock)) {
310
+ updates.push({ target: ".github/actions/actions-lock.json", content: await readFile(actionsLock, "utf8") });
311
+ }
312
+ return updates.sort((left, right) => left.target.localeCompare(right.target));
313
+ }
314
+ function validateActionManifests(updates) {
315
+ for (const { target, content } of updates) {
316
+ if (!target.endsWith("action.yml"))
317
+ continue;
318
+ let manifest;
319
+ try {
320
+ manifest = parseYaml(content);
321
+ }
322
+ catch {
323
+ throw new Error(`${target}: invalid YAML — failed to parse action manifest`);
324
+ }
325
+ if (manifest?.runs?.using !== "composite")
326
+ continue;
327
+ const steps = manifest.runs?.steps;
328
+ if (!Array.isArray(steps) || steps.length === 0) {
329
+ throw new Error(`${target}: composite action has no steps — file may be truncated`);
330
+ }
331
+ for (const [index, step] of steps.entries()) {
332
+ const label = step.name ?? step.id ?? `step ${index}`;
333
+ if (step.shell !== undefined && (typeof step.run !== "string" || step.run.trim() === "")) {
334
+ throw new Error(`${target}: step "${label}" has shell: but no run: — manifest is invalid or truncated`);
335
+ }
336
+ }
337
+ }
338
+ }
339
+ async function applyTransaction(repositoryPath, updates) {
340
+ const uniqueUpdates = [...new Map(updates.map((update) => [update.target, update])).values()];
341
+ const rollback = await Promise.all(uniqueUpdates.map(async ({ target }) => {
342
+ const path = join(repositoryPath, target);
343
+ return { target, existed: await exists(path), content: await exists(path) ? await readFile(path, "utf8") : undefined };
344
+ }));
345
+ try {
346
+ await writeUpdates(repositoryPath, uniqueUpdates);
347
+ validateActionManifests(uniqueUpdates);
348
+ }
349
+ catch (error) {
350
+ await Promise.all(rollback.map(async ({ target, existed, content }) => {
351
+ if (existed && content !== undefined) {
352
+ await writeUpdates(repositoryPath, [{ target, content }]);
353
+ }
354
+ else {
355
+ await rm(join(repositoryPath, target), { force: true });
356
+ }
357
+ }));
358
+ throw error;
359
+ }
360
+ }
361
+ async function writeUpdates(repositoryPath, updates) {
362
+ await Promise.all(updates.map(async ({ target, content }) => {
363
+ const destination = join(repositoryPath, target);
364
+ await mkdir(dirname(destination), { recursive: true });
365
+ await writeFile(destination, content, "utf8");
366
+ }));
367
+ }
368
+ function catalogTemplateMeta(template) {
369
+ const entry = catalogTemplates.find((item) => item.name === template);
370
+ if (entry === undefined)
371
+ throw new Error(`Unknown template: ${template}`);
372
+ const directory = template.startsWith("opencode") ? "opencode" : template.startsWith("app-ci-") ? "ci" : template === "github-release" ? "release" : template === "visual-evidence" ? "visual-evidence" : template === "bug-report" || template === "feature-request" ? "issues" : "agentics";
373
+ const isWorkflow = entry.file.endsWith(".yml");
374
+ const inferredTarget = template === "app-ci-dotnet-next"
375
+ ? ".github/workflows/app-ci.yml"
376
+ : isWorkflow ? `.github/workflows/${entry.file}` : entry.file;
377
+ const target = entry.target ?? inferredTarget;
378
+ return { directory, file: entry.file, target };
379
+ }
380
+ async function catalogFiles(sourcePath) {
381
+ const files = [];
382
+ for (const [sourceDirectory, targetDirectory] of sourceMappings) {
383
+ for (const file of await filesIn(join(sourcePath, sourceDirectory))) {
384
+ if (isGeneratedFile(file))
385
+ continue;
386
+ files.push({
387
+ source: join(sourcePath, sourceDirectory, file),
388
+ target: `${targetDirectory}/${file.replaceAll("\\", "/")}`,
389
+ managed: true,
390
+ });
391
+ }
392
+ }
393
+ return files.sort((left, right) => left.target.localeCompare(right.target));
394
+ }
395
+ function isGeneratedFile(file) {
396
+ const normalized = file.replaceAll("\\", "/");
397
+ return normalized.endsWith(".lock.yml") || normalized.endsWith("actions-lock.json");
398
+ }
399
+ async function filesIn(path) {
400
+ const entries = await readdir(path, { recursive: true, withFileTypes: true });
401
+ return entries
402
+ .filter((entry) => entry.isFile())
403
+ .map((entry) => entry.parentPath === undefined ? entry.name : relative(path, join(entry.parentPath, entry.name)));
404
+ }
405
+ async function filesMatch(source, destination) {
406
+ try {
407
+ const [sourceContent, destinationContent] = await Promise.all([readFile(source), readFile(destination)]);
408
+ return sourceContent.equals(destinationContent);
409
+ }
410
+ catch {
411
+ return false;
412
+ }
413
+ }
414
+ export async function exists(path) {
415
+ try {
416
+ await access(path, constants.F_OK);
417
+ return true;
418
+ }
419
+ catch {
420
+ return false;
421
+ }
422
+ }
@@ -0,0 +1 @@
1
+ export {};