@plainconceptsplatform/workflows 0.5.1 → 0.16.0
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.
- package/README.md +105 -88
- package/dist/catalog-installation.d.ts +67 -0
- package/dist/catalog-installation.js +485 -0
- package/dist/catalog-listing.d.ts +13 -0
- package/dist/catalog-listing.js +70 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +273 -0
- package/dist/package-baseline.d.ts +25 -0
- package/dist/package-baseline.js +138 -0
- package/dist/repository-inspection.d.ts +23 -0
- package/dist/repository-inspection.js +113 -0
- package/dist/route-processing.d.ts +4 -0
- package/dist/route-processing.js +80 -0
- package/dist/stack-defaults.d.ts +11 -0
- package/dist/stack-defaults.js +110 -0
- package/dist/tui.d.ts +25 -0
- package/dist/tui.js +271 -0
- package/dist/worker-env.d.ts +46 -0
- package/dist/worker-env.js +179 -0
- package/dist/workflow-catalog.d.ts +27 -0
- package/dist/workflow-catalog.js +57 -0
- package/loops/actions/add-issue-labels/action.yml +22 -2
- package/loops/actions/apply-agent-bundle/apply-bundle.sh +14 -1
- package/loops/actions/audit-close/action.yml +180 -128
- package/loops/actions/classify-route/action.yml +7 -0
- package/loops/actions/classify-route/classify-route.sh +28 -7
- package/loops/actions/cleanup-artifacts/action.yml +38 -12
- package/loops/actions/housekeeping/action.yml +251 -0
- package/loops/actions/identify-gate-subject/action.yml +3 -1
- package/loops/actions/merge-agent-pr/action.yml +13 -0
- package/loops/actions/remove-issue-labels/action.yml +4 -2
- package/loops/actions/report-workflow-errors/action.yml +385 -0
- package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +13 -1
- package/loops/actions/validate-refine-output/validate-refine-output.sh +18 -2
- package/loops/actions/validate-triage-output/action.yml +1 -1
- package/loops/actions/validate-triage-output/validate-triage-output.sh +9 -5
- package/loops/actions/verify-composite-actions/verify-composite-actions.sh +53 -0
- package/loops/actions/verify-refine-output/verify-refine-output.sh +20 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +1017 -56
- package/loops/scripts/compile-agent-workflows.mjs +96 -1
- package/loops/scripts/merge-changelog.mjs +76 -0
- package/loops/templates/agentics/actionlint.yaml +13 -0
- package/loops/templates/agentics/agentics-checks.yml +203 -86
- package/loops/templates/agentics/agentics-error-report.yml +97 -0
- package/loops/templates/ci/app-ci-dotnet-next.yml +64 -2
- package/loops/templates/ci/app-ci-node-monorepo.yml +52 -0
- package/loops/templates/opencode/opencode.ci.json +3 -3
- package/loops/templates/opencode/opencode.ci.json.md +4 -1
- package/loops/workflows/agent-apply-review.md +452 -465
- package/loops/workflows/agent-audit.md +201 -213
- package/loops/workflows/agent-implement.md +616 -538
- package/loops/workflows/agent-merge-gate.md +830 -730
- package/loops/workflows/agent-refine.md +599 -609
- package/loops/workflows/agent-release.md +244 -258
- package/loops/workflows/agent-triage.md +476 -447
- package/loops/workflows/authorize-bot-work.yml +26 -6
- package/loops/workflows/work-router.yml +1185 -862
- package/package.json +9 -8
- package/loops/actions/stale-recovery/action.yml +0 -288
- package/loops/actions/update-changelog/action.yml +0 -113
|
@@ -0,0 +1,485 @@
|
|
|
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
|
+
import { mergeRouter, mergeWorker } from "./worker-env.js";
|
|
12
|
+
import { fetchBaseline, hasOwnershipHeader, installedVersion, packageVersion, stampVersion } from "./package-baseline.js";
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
const sourceMappings = [
|
|
15
|
+
["actions", ".github/actions"],
|
|
16
|
+
["workflows", ".github/workflows"],
|
|
17
|
+
["scripts", "scripts"],
|
|
18
|
+
];
|
|
19
|
+
export function mandatoryFileSpecs(sourcePath) {
|
|
20
|
+
return mandatoryFiles.map((spec) => ({
|
|
21
|
+
source: join(sourcePath, spec.source),
|
|
22
|
+
target: spec.target,
|
|
23
|
+
managed: true,
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
export function catalogSourcePath(modulePath = fileURLToPath(import.meta.url)) {
|
|
27
|
+
return resolve(dirname(modulePath), "..", "loops");
|
|
28
|
+
}
|
|
29
|
+
const isWorker = (target) => target.startsWith(".github/workflows/agent-") && target.endsWith(".md");
|
|
30
|
+
const isRouter = (target) => target === ".github/workflows/work-router.yml";
|
|
31
|
+
const carriesHeader = (target) => /\.(ya?ml|md|sh|mjs|cjs)$/.test(target);
|
|
32
|
+
const normalizeEol = (text) => text.replaceAll("\r\n", "\n");
|
|
33
|
+
export async function installCatalog(repositoryPath, options = {}) {
|
|
34
|
+
const version = options.packageVersion ?? await packageVersion();
|
|
35
|
+
const sourcePath = options.sourcePath ?? catalogSourcePath();
|
|
36
|
+
const selectedRoutes = options.selectedRoutes ?? routeNames;
|
|
37
|
+
const allFiles = [...await catalogFiles(sourcePath), ...mandatoryFileSpecs(sourcePath)];
|
|
38
|
+
const deduplicated = allFiles.filter((file, index) => allFiles.findIndex((f) => f.target === file.target) === index).sort((left, right) => left.target.localeCompare(right.target));
|
|
39
|
+
const excluded = excludedWorkerFiles(selectedRoutes);
|
|
40
|
+
const filtered = deduplicated.filter((file) => !excluded.has(file.target.split("/").pop() ?? ""));
|
|
41
|
+
const packageContents = new Map();
|
|
42
|
+
for (const file of filtered) {
|
|
43
|
+
packageContents.set(file.target, normalizeEol(await readFile(file.source, "utf8")));
|
|
44
|
+
}
|
|
45
|
+
let processed = processRoutes(packageContents, selectedRoutes);
|
|
46
|
+
// Read and write LF, always. Every one of these files ends up on a Linux runner, the package
|
|
47
|
+
// and every consumer declare `* text=auto eol=lf`, and a shell script with CRLF fails on its
|
|
48
|
+
// shebang. Preserving whatever a file happened to have instead only carried legacy CRLF
|
|
49
|
+
// forward, which git then reported as needing normalisation on every later commit.
|
|
50
|
+
const existing = new Map();
|
|
51
|
+
for (const target of processed.keys()) {
|
|
52
|
+
const path = join(repositoryPath, target);
|
|
53
|
+
if (!await exists(path))
|
|
54
|
+
continue;
|
|
55
|
+
existing.set(target, normalizeEol(await readFile(path, "utf8")));
|
|
56
|
+
}
|
|
57
|
+
if (options.inspection !== undefined) {
|
|
58
|
+
processed = applyStackDefaults(processed, options.inspection, existing);
|
|
59
|
+
}
|
|
60
|
+
const fetcher = options.baseline ?? fetchBaseline;
|
|
61
|
+
const baselineDirectories = new Map();
|
|
62
|
+
const baselineFor = (installed) => {
|
|
63
|
+
let pending = baselineDirectories.get(installed);
|
|
64
|
+
if (pending === undefined) {
|
|
65
|
+
pending = fetcher(installed);
|
|
66
|
+
baselineDirectories.set(installed, pending);
|
|
67
|
+
}
|
|
68
|
+
return pending;
|
|
69
|
+
};
|
|
70
|
+
const changes = [];
|
|
71
|
+
const updates = [];
|
|
72
|
+
const installedVersions = new Set();
|
|
73
|
+
for (const [target, packageText] of processed) {
|
|
74
|
+
const current = existing.get(target);
|
|
75
|
+
let text = packageText;
|
|
76
|
+
let change = { target, status: "added" };
|
|
77
|
+
if (current !== undefined) {
|
|
78
|
+
const recorded = carriesHeader(target) ? installedVersion(current) : undefined;
|
|
79
|
+
if (recorded !== undefined)
|
|
80
|
+
installedVersions.add(recorded);
|
|
81
|
+
if (isWorker(target) || isRouter(target)) {
|
|
82
|
+
const baselineText = recorded === undefined ? undefined : await baselineSource(await baselineFor(recorded), target);
|
|
83
|
+
const merge = isRouter(target) ? mergeRouter : mergeWorker;
|
|
84
|
+
const merged = merge(packageText, current, baselineText);
|
|
85
|
+
text = merged.content;
|
|
86
|
+
change = {
|
|
87
|
+
target,
|
|
88
|
+
status: "updated",
|
|
89
|
+
...(recorded === undefined ? {} : { installedVersion: recorded }),
|
|
90
|
+
...(merged.report.keptEnv.length > 0 ? { keptEnv: merged.report.keptEnv } : {}),
|
|
91
|
+
...(merged.report.updatedDefaults.length > 0 ? { updatedDefaults: merged.report.updatedDefaults } : {}),
|
|
92
|
+
...(merged.report.consumerOnlyEnv.length > 0 ? { consumerOnlyEnv: merged.report.consumerOnlyEnv } : {}),
|
|
93
|
+
...(merged.report.droppedEnv.length > 0 ? { droppedEnv: merged.report.droppedEnv } : {}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
change = { target, status: "updated", ...(recorded === undefined ? {} : { installedVersion: recorded }) };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
text = stampVersion(text, version);
|
|
101
|
+
if (current !== undefined && text === current) {
|
|
102
|
+
change = { ...change, status: "unchanged" };
|
|
103
|
+
}
|
|
104
|
+
else if (current !== undefined && carriesHeader(target) && !hasOwnershipHeader(current) && !options.force) {
|
|
105
|
+
// Removing the ownership header is how a consumer takes a file over.
|
|
106
|
+
change = { target, status: "skipped", reason: "consumer-owned: the ownership header was removed; pass --force to reclaim it" };
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
updates.push({ target, content: text });
|
|
110
|
+
}
|
|
111
|
+
changes.push(change);
|
|
112
|
+
}
|
|
113
|
+
const removals = await orphanedManagedFiles(repositoryPath, new Set(processed.keys()));
|
|
114
|
+
for (const target of removals)
|
|
115
|
+
changes.push({ target, status: "removed" });
|
|
116
|
+
const baselines = [];
|
|
117
|
+
for (const [requested, pending] of baselineDirectories) {
|
|
118
|
+
baselines.push({ version: requested, status: (await pending) === undefined ? "unavailable" : "used" });
|
|
119
|
+
}
|
|
120
|
+
const result = {
|
|
121
|
+
installed: [...processed.keys()].sort(),
|
|
122
|
+
conflicts: [],
|
|
123
|
+
changes,
|
|
124
|
+
packageVersion: version,
|
|
125
|
+
installedVersions: [...installedVersions].sort(),
|
|
126
|
+
baselines,
|
|
127
|
+
upToDate: updates.length === 0 && removals.length === 0,
|
|
128
|
+
dryRun: options.dryRun ?? false,
|
|
129
|
+
};
|
|
130
|
+
if (options.dryRun)
|
|
131
|
+
return result;
|
|
132
|
+
// Deletions go first: a pruned action must be gone before the compile reads the tree, or a
|
|
133
|
+
// worker still referencing it would compile against a file that is about to disappear.
|
|
134
|
+
await removeFiles(repositoryPath, removals);
|
|
135
|
+
if (updates.length > 0) {
|
|
136
|
+
const stagedLocks = await validateStagedCatalog(repositoryPath, updates, options.compile);
|
|
137
|
+
await applyTransaction(repositoryPath, [...updates, ...stagedLocks, await preCommitHookUpdate(repositoryPath)]);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
await applyTransaction(repositoryPath, [await preCommitHookUpdate(repositoryPath)]);
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
// Stack defaults are derived from the repository, not chosen by a person, so the derived files
|
|
145
|
+
// (the shared CI setup and the OpenCode config) get them on every run and stay stable. A worker
|
|
146
|
+
// gets its VERIFY_COMMANDS default once, when it is first installed: after that the value is the
|
|
147
|
+
// consumer's, and the env merge keeps it.
|
|
148
|
+
function applyStackDefaults(files, inspection, existing) {
|
|
149
|
+
const defaults = generateStackDefaults(inspection);
|
|
150
|
+
const result = new Map(files);
|
|
151
|
+
for (const [target, content] of result) {
|
|
152
|
+
if (isWorker(target) && !existing.has(target)) {
|
|
153
|
+
result.set(target, injectStackEnv(content, defaults));
|
|
154
|
+
}
|
|
155
|
+
else if (target.endsWith("opencode-ci.md")) {
|
|
156
|
+
result.set(target, generateOpencodeCi(content, inspection));
|
|
157
|
+
}
|
|
158
|
+
else if (target === "opencode.ci.json") {
|
|
159
|
+
result.set(target, generateOpencodeConfig(content, inspection));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
// Directories that belong wholly to the package. A file here that carries our ownership header
|
|
165
|
+
// and is no longer in the package was deleted upstream, and without this it would sit in every
|
|
166
|
+
// consumer forever: that is how `stale-recovery` and `update-changelog` outlived the code that
|
|
167
|
+
// called them. Deliberately not `.github/workflows/`, where a worker's absence means the route
|
|
168
|
+
// is not installed rather than gone, and never anything without a header, which is a fork.
|
|
169
|
+
const pruneRoots = [".github/actions", ".github/workflows/shared"];
|
|
170
|
+
async function orphanedManagedFiles(repositoryPath, keep) {
|
|
171
|
+
const orphans = [];
|
|
172
|
+
for (const root of pruneRoots) {
|
|
173
|
+
const directory = join(repositoryPath, root);
|
|
174
|
+
if (!await exists(directory))
|
|
175
|
+
continue;
|
|
176
|
+
for (const file of await filesIn(directory)) {
|
|
177
|
+
const target = `${root}/${file.replaceAll("\\", "/")}`;
|
|
178
|
+
if (keep.has(target))
|
|
179
|
+
continue;
|
|
180
|
+
if (!carriesHeader(target) && !target.endsWith(".cjs") && !target.endsWith(".js"))
|
|
181
|
+
continue;
|
|
182
|
+
const content = await readFile(join(directory, file), "utf8");
|
|
183
|
+
if (hasOwnershipHeader(content))
|
|
184
|
+
orphans.push(target);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return orphans.sort();
|
|
188
|
+
}
|
|
189
|
+
async function removeFiles(repositoryPath, targets) {
|
|
190
|
+
for (const target of targets) {
|
|
191
|
+
await rm(join(repositoryPath, target), { force: true });
|
|
192
|
+
// An action is a directory with one manifest in it; leaving the empty shell behind is litter.
|
|
193
|
+
const directory = dirname(join(repositoryPath, target));
|
|
194
|
+
try {
|
|
195
|
+
if ((await readdir(directory)).length === 0)
|
|
196
|
+
await rm(directory, { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// the directory is gone or not empty, either of which is fine
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async function baselineSource(loops, target) {
|
|
204
|
+
if (loops === undefined)
|
|
205
|
+
return undefined;
|
|
206
|
+
const path = join(loops, "workflows", target.slice(".github/workflows/".length));
|
|
207
|
+
if (!await exists(path))
|
|
208
|
+
return undefined;
|
|
209
|
+
return normalizeEol(await readFile(path, "utf8"));
|
|
210
|
+
}
|
|
211
|
+
// Templates that need a companion config beside the workflow file. actionlint only
|
|
212
|
+
// knows GitHub-hosted runner labels, so every workflow naming a self-hosted label is an
|
|
213
|
+
// error without this file; it belongs wherever the lint workflow that reads it lives.
|
|
214
|
+
const templateCompanions = {
|
|
215
|
+
"agentics-checks": [{ source: "templates/agentics/actionlint.yaml", target: ".github/actionlint.yaml" }],
|
|
216
|
+
};
|
|
217
|
+
export async function installTemplate(repositoryPath, template, options = {}) {
|
|
218
|
+
const sourcePath = options.sourcePath ?? catalogSourcePath();
|
|
219
|
+
const meta = catalogTemplateMeta(template);
|
|
220
|
+
const source = join(sourcePath, "templates", meta.directory, meta.file);
|
|
221
|
+
const target = meta.target;
|
|
222
|
+
const companions = templateCompanions[template] ?? [];
|
|
223
|
+
const destinations = [
|
|
224
|
+
{ source, target, companion: false },
|
|
225
|
+
...companions.map((companion) => ({
|
|
226
|
+
source: join(sourcePath, companion.source),
|
|
227
|
+
target: companion.target,
|
|
228
|
+
companion: true,
|
|
229
|
+
})),
|
|
230
|
+
];
|
|
231
|
+
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)))
|
|
232
|
+
? entry.target
|
|
233
|
+
: undefined))).filter((file) => file !== undefined);
|
|
234
|
+
if (conflicts.length > 0 && !options.force)
|
|
235
|
+
return { installed: [], conflicts };
|
|
236
|
+
for (const entry of destinations) {
|
|
237
|
+
if (!(await exists(entry.source)))
|
|
238
|
+
continue; // companion files may not exist for every template version
|
|
239
|
+
const destination = join(repositoryPath, entry.target);
|
|
240
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
241
|
+
await copyFile(entry.source, destination);
|
|
242
|
+
}
|
|
243
|
+
if (options.inspection !== undefined && template === "opencode.ci.json") {
|
|
244
|
+
const baseContent = await readFile(source, "utf8");
|
|
245
|
+
const transformed = generateOpencodeConfig(baseContent, options.inspection);
|
|
246
|
+
await writeFile(join(repositoryPath, target), transformed, "utf8");
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
await runCompileIfAvailable(repositoryPath);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// compile failure is non-fatal
|
|
253
|
+
}
|
|
254
|
+
return { installed: (await Promise.all(destinations.map(async (entry) => await exists(entry.source) ? entry.target : undefined))).filter((file) => file !== undefined), conflicts };
|
|
255
|
+
}
|
|
256
|
+
export async function installedRoutes(repositoryPath) {
|
|
257
|
+
const found = await Promise.all(workflowRoutes.map(async (route) => (await exists(join(repositoryPath, ".github", "workflows", route.worker))) ? route.name : undefined));
|
|
258
|
+
return found.filter((name) => name !== undefined);
|
|
259
|
+
}
|
|
260
|
+
export async function removeRouteFiles(repositoryPath, routes) {
|
|
261
|
+
const workerByRoute = new Map(workflowRoutes.map((route) => [route.name, route.worker]));
|
|
262
|
+
const removed = [];
|
|
263
|
+
for (const route of routes) {
|
|
264
|
+
const worker = workerByRoute.get(route);
|
|
265
|
+
if (worker === undefined)
|
|
266
|
+
continue;
|
|
267
|
+
const lock = worker.replace(/\.md$/, ".lock.yml");
|
|
268
|
+
for (const file of [worker, lock]) {
|
|
269
|
+
const destination = join(repositoryPath, ".github", "workflows", file);
|
|
270
|
+
if (await exists(destination)) {
|
|
271
|
+
await rm(destination, { force: true });
|
|
272
|
+
removed.push(`.github/workflows/${file}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return removed.sort();
|
|
277
|
+
}
|
|
278
|
+
export function isTemplateName(value) {
|
|
279
|
+
return templateNames.includes(value);
|
|
280
|
+
}
|
|
281
|
+
export async function ensurePreCommitHook(repositoryPath) {
|
|
282
|
+
const update = await preCommitHookUpdate(repositoryPath);
|
|
283
|
+
await applyTransaction(repositoryPath, [update]);
|
|
284
|
+
}
|
|
285
|
+
async function preCommitHookUpdate(repositoryPath) {
|
|
286
|
+
const target = ".husky/pre-commit";
|
|
287
|
+
const hookPath = join(repositoryPath, target);
|
|
288
|
+
const compileLine = "node scripts/compile-agent-workflows.mjs";
|
|
289
|
+
const stageLine = "git add -- .github/workflows/*.lock.yml";
|
|
290
|
+
const actionLockLine = "[ ! -f .github/actions/actions-lock.json ] || git add -- .github/actions/actions-lock.json";
|
|
291
|
+
const managedLines = `if git diff --cached --name-only -- .github | grep -q .; then\n ${compileLine}\n ${stageLine}\n ${actionLockLine}\nfi\n`;
|
|
292
|
+
if (!await exists(hookPath)) {
|
|
293
|
+
return { target, content: managedLines };
|
|
294
|
+
}
|
|
295
|
+
const content = (await readFile(hookPath, "utf8"))
|
|
296
|
+
.replace("pnpm exec if git diff --cached --name-only -- .github | grep -q .; then", "if git diff --cached --name-only -- .github | grep -q .; then");
|
|
297
|
+
if (content.includes("compile-agent-workflows")) {
|
|
298
|
+
const legacyLines = `${compileLine}\n${stageLine}\n${actionLockLine}\n`;
|
|
299
|
+
if (content.includes(managedLines))
|
|
300
|
+
return { target, content };
|
|
301
|
+
if (content.includes(legacyLines))
|
|
302
|
+
return { target, content: content.replace(legacyLines, managedLines) };
|
|
303
|
+
const suffix = content.endsWith("\n") || content === "" ? "" : "\n";
|
|
304
|
+
return { target, content: `${content}${suffix}${managedLines}` };
|
|
305
|
+
}
|
|
306
|
+
return { target, content: content.endsWith("\n") || content === ""
|
|
307
|
+
? `${content}${managedLines}`
|
|
308
|
+
: `${content}\n${managedLines}` };
|
|
309
|
+
}
|
|
310
|
+
export async function runCompileIfAvailable(repositoryPath) {
|
|
311
|
+
const script = join(repositoryPath, "scripts", "compile-agent-workflows.mjs");
|
|
312
|
+
if (await exists(script)) {
|
|
313
|
+
await execFileAsync("node", [script, "--force"], { cwd: repositoryPath });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
async function validateStagedCatalog(repositoryPath, updates, compileOverride) {
|
|
317
|
+
const temporaryRoot = join(repositoryPath, ".opencode", ".tmp");
|
|
318
|
+
await mkdir(temporaryRoot, { recursive: true });
|
|
319
|
+
const stagingPath = await mkdtemp(join(temporaryRoot, "workflows-"));
|
|
320
|
+
try {
|
|
321
|
+
await copyCompilationInputs(repositoryPath, stagingPath);
|
|
322
|
+
await writeUpdates(stagingPath, updates);
|
|
323
|
+
await initializeStagingRepository(stagingPath);
|
|
324
|
+
const compiler = compileOverride ?? await packageCompiler(stagingPath);
|
|
325
|
+
if (compiler === undefined)
|
|
326
|
+
return [];
|
|
327
|
+
await compiler(stagingPath);
|
|
328
|
+
return await generatedFiles(stagingPath);
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
await rm(stagingPath, { force: true, recursive: true });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// The compile needs the workflows and the compile script. An update that changes neither still
|
|
335
|
+
// recompiles against the consumer's current copies, so both come along.
|
|
336
|
+
async function copyCompilationInputs(repositoryPath, stagingPath) {
|
|
337
|
+
const githubPath = join(repositoryPath, ".github");
|
|
338
|
+
if (await exists(githubPath))
|
|
339
|
+
await cp(githubPath, join(stagingPath, ".github"), { recursive: true });
|
|
340
|
+
const script = join(repositoryPath, "scripts", "compile-agent-workflows.mjs");
|
|
341
|
+
if (await exists(script)) {
|
|
342
|
+
await mkdir(join(stagingPath, "scripts"), { recursive: true });
|
|
343
|
+
await copyFile(script, join(stagingPath, "scripts", "compile-agent-workflows.mjs"));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async function initializeStagingRepository(stagingPath) {
|
|
347
|
+
await execFileAsync("git", ["init", "--quiet"], { cwd: stagingPath, windowsHide: true });
|
|
348
|
+
}
|
|
349
|
+
async function packageCompiler(repositoryPath) {
|
|
350
|
+
const scriptPath = join(repositoryPath, "scripts", "compile-agent-workflows.mjs");
|
|
351
|
+
if (!await exists(scriptPath))
|
|
352
|
+
return undefined;
|
|
353
|
+
const content = await readFile(scriptPath, "utf8");
|
|
354
|
+
return content.includes("gh aw compile") ? runCompileIfAvailable : undefined;
|
|
355
|
+
}
|
|
356
|
+
async function generatedFiles(repositoryPath) {
|
|
357
|
+
const workflowPath = join(repositoryPath, ".github", "workflows");
|
|
358
|
+
const updates = [];
|
|
359
|
+
if (await exists(workflowPath)) {
|
|
360
|
+
for (const file of await filesIn(workflowPath)) {
|
|
361
|
+
if (!file.endsWith(".lock.yml"))
|
|
362
|
+
continue;
|
|
363
|
+
updates.push({ target: `.github/workflows/${file.replaceAll("\\", "/")}`, content: await readFile(join(workflowPath, file), "utf8") });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const actionsLock = join(repositoryPath, ".github", "actions", "actions-lock.json");
|
|
367
|
+
if (await exists(actionsLock)) {
|
|
368
|
+
updates.push({ target: ".github/actions/actions-lock.json", content: await readFile(actionsLock, "utf8") });
|
|
369
|
+
}
|
|
370
|
+
return updates.sort((left, right) => left.target.localeCompare(right.target));
|
|
371
|
+
}
|
|
372
|
+
function validateActionManifests(updates) {
|
|
373
|
+
for (const { target, content } of updates) {
|
|
374
|
+
if (!target.endsWith("action.yml"))
|
|
375
|
+
continue;
|
|
376
|
+
let manifest;
|
|
377
|
+
try {
|
|
378
|
+
manifest = parseYaml(content);
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
throw new Error(`${target}: invalid YAML — failed to parse action manifest`);
|
|
382
|
+
}
|
|
383
|
+
if (manifest?.runs?.using !== "composite")
|
|
384
|
+
continue;
|
|
385
|
+
const steps = manifest.runs?.steps;
|
|
386
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
387
|
+
throw new Error(`${target}: composite action has no steps — file may be truncated`);
|
|
388
|
+
}
|
|
389
|
+
for (const [index, step] of steps.entries()) {
|
|
390
|
+
const label = step.name ?? step.id ?? `step ${index}`;
|
|
391
|
+
if (step.shell !== undefined && (typeof step.run !== "string" || step.run.trim() === "")) {
|
|
392
|
+
throw new Error(`${target}: step "${label}" has shell: but no run: — manifest is invalid or truncated`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async function applyTransaction(repositoryPath, updates) {
|
|
398
|
+
const uniqueUpdates = [...new Map(updates.map((update) => [update.target, update])).values()];
|
|
399
|
+
const rollback = await Promise.all(uniqueUpdates.map(async ({ target }) => {
|
|
400
|
+
const path = join(repositoryPath, target);
|
|
401
|
+
return { target, existed: await exists(path), content: await exists(path) ? await readFile(path, "utf8") : undefined };
|
|
402
|
+
}));
|
|
403
|
+
try {
|
|
404
|
+
await writeUpdates(repositoryPath, uniqueUpdates);
|
|
405
|
+
validateActionManifests(uniqueUpdates);
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
await Promise.all(rollback.map(async ({ target, existed, content }) => {
|
|
409
|
+
if (existed && content !== undefined) {
|
|
410
|
+
await writeUpdates(repositoryPath, [{ target, content }]);
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
await rm(join(repositoryPath, target), { force: true });
|
|
414
|
+
}
|
|
415
|
+
}));
|
|
416
|
+
throw error;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
async function writeUpdates(repositoryPath, updates) {
|
|
420
|
+
await Promise.all(updates.map(async ({ target, content }) => {
|
|
421
|
+
const destination = join(repositoryPath, target);
|
|
422
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
423
|
+
await writeFile(destination, content, "utf8");
|
|
424
|
+
}));
|
|
425
|
+
}
|
|
426
|
+
function catalogTemplateMeta(template) {
|
|
427
|
+
const entry = catalogTemplates.find((item) => item.name === template);
|
|
428
|
+
if (entry === undefined)
|
|
429
|
+
throw new Error(`Unknown template: ${template}`);
|
|
430
|
+
// The directory is a field on the entry now. It used to be inferred from the name by a chain
|
|
431
|
+
// of ternaries ending in "agentics", so a new template in any other directory installed the
|
|
432
|
+
// wrong file or none at all, and the default hid it.
|
|
433
|
+
const directory = entry.directory ?? (template.startsWith("opencode") ? "opencode" : template.startsWith("app-ci-") ? "ci" : template === "github-release" ? "release" : template === "bug-report" || template === "feature-request" ? "issues" : "agentics");
|
|
434
|
+
const isWorkflow = entry.file.endsWith(".yml");
|
|
435
|
+
const inferredTarget = template === "app-ci-dotnet-next"
|
|
436
|
+
? ".github/workflows/app-ci.yml"
|
|
437
|
+
: isWorkflow ? `.github/workflows/${entry.file}` : entry.file;
|
|
438
|
+
const target = entry.target ?? inferredTarget;
|
|
439
|
+
return { directory, file: entry.file, target };
|
|
440
|
+
}
|
|
441
|
+
async function catalogFiles(sourcePath) {
|
|
442
|
+
const files = [];
|
|
443
|
+
for (const [sourceDirectory, targetDirectory] of sourceMappings) {
|
|
444
|
+
if (!await exists(join(sourcePath, sourceDirectory)))
|
|
445
|
+
continue;
|
|
446
|
+
for (const file of await filesIn(join(sourcePath, sourceDirectory))) {
|
|
447
|
+
if (isGeneratedFile(file))
|
|
448
|
+
continue;
|
|
449
|
+
files.push({
|
|
450
|
+
source: join(sourcePath, sourceDirectory, file),
|
|
451
|
+
target: `${targetDirectory}/${file.replaceAll("\\", "/")}`,
|
|
452
|
+
managed: true,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return files.sort((left, right) => left.target.localeCompare(right.target));
|
|
457
|
+
}
|
|
458
|
+
function isGeneratedFile(file) {
|
|
459
|
+
const normalized = file.replaceAll("\\", "/");
|
|
460
|
+
return normalized.endsWith(".lock.yml") || normalized.endsWith("actions-lock.json");
|
|
461
|
+
}
|
|
462
|
+
async function filesIn(path) {
|
|
463
|
+
const entries = await readdir(path, { recursive: true, withFileTypes: true });
|
|
464
|
+
return entries
|
|
465
|
+
.filter((entry) => entry.isFile())
|
|
466
|
+
.map((entry) => entry.parentPath === undefined ? entry.name : relative(path, join(entry.parentPath, entry.name)));
|
|
467
|
+
}
|
|
468
|
+
async function filesMatch(source, destination) {
|
|
469
|
+
try {
|
|
470
|
+
const [sourceContent, destinationContent] = await Promise.all([readFile(source), readFile(destination)]);
|
|
471
|
+
return sourceContent.equals(destinationContent);
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
export async function exists(path) {
|
|
478
|
+
try {
|
|
479
|
+
await access(path, constants.F_OK);
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface CatalogEntry {
|
|
2
|
+
readonly kind: "route" | "template";
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly description: string;
|
|
5
|
+
readonly file: string;
|
|
6
|
+
readonly installed: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface ListOptions {
|
|
9
|
+
readonly installedPath?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function listCatalog(options?: ListOptions): Promise<readonly CatalogEntry[]>;
|
|
12
|
+
export declare function searchCatalog(entries: readonly CatalogEntry[], query: string): readonly CatalogEntry[];
|
|
13
|
+
export declare function formatCatalog(entries: readonly CatalogEntry[]): string;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { access, constants } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { catalogTemplates, workflowRoutes } from "./workflow-catalog.js";
|
|
4
|
+
export async function listCatalog(options = {}) {
|
|
5
|
+
const basePath = options.installedPath ?? process.cwd();
|
|
6
|
+
const routes = await Promise.all(workflowRoutes.map(async (route) => ({
|
|
7
|
+
kind: "route",
|
|
8
|
+
name: route.name,
|
|
9
|
+
description: route.description,
|
|
10
|
+
file: route.worker,
|
|
11
|
+
installed: await isFileInstalled(basePath, route.worker),
|
|
12
|
+
})));
|
|
13
|
+
const templates = await Promise.all(catalogTemplates.map(async (template) => ({
|
|
14
|
+
kind: "template",
|
|
15
|
+
name: template.name,
|
|
16
|
+
description: template.description,
|
|
17
|
+
file: template.file,
|
|
18
|
+
installed: await isFileInstalled(basePath, template.file, template.target),
|
|
19
|
+
})));
|
|
20
|
+
return [...routes, ...templates];
|
|
21
|
+
}
|
|
22
|
+
export function searchCatalog(entries, query) {
|
|
23
|
+
const normalized = query.toLowerCase().trim();
|
|
24
|
+
if (normalized === "")
|
|
25
|
+
return [];
|
|
26
|
+
return entries.filter((entry) => entry.name.toLowerCase().includes(normalized) ||
|
|
27
|
+
entry.description.toLowerCase().includes(normalized));
|
|
28
|
+
}
|
|
29
|
+
export function formatCatalog(entries) {
|
|
30
|
+
if (entries.length === 0)
|
|
31
|
+
return "No workflows matched the search query.";
|
|
32
|
+
const routes = entries.filter((entry) => entry.kind === "route");
|
|
33
|
+
const templates = entries.filter((entry) => entry.kind === "template");
|
|
34
|
+
const lines = [];
|
|
35
|
+
if (routes.length > 0) {
|
|
36
|
+
lines.push("Workflows:");
|
|
37
|
+
for (const entry of routes) {
|
|
38
|
+
lines.push(formatEntry(entry));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (templates.length > 0) {
|
|
42
|
+
if (lines.length > 0)
|
|
43
|
+
lines.push("");
|
|
44
|
+
lines.push("Templates:");
|
|
45
|
+
for (const entry of templates) {
|
|
46
|
+
lines.push(formatEntry(entry));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return lines.join("\n");
|
|
50
|
+
}
|
|
51
|
+
function formatEntry(entry) {
|
|
52
|
+
const mark = entry.installed ? "[x]" : "[ ]";
|
|
53
|
+
return ` ${mark} ${entry.name} — ${entry.description}`;
|
|
54
|
+
}
|
|
55
|
+
async function isFileInstalled(basePath, workerOrTemplateFile, explicitTarget) {
|
|
56
|
+
const installedPath = templateInstallPath(basePath, workerOrTemplateFile, explicitTarget);
|
|
57
|
+
try {
|
|
58
|
+
await access(installedPath, constants.F_OK);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function templateInstallPath(basePath, file, explicitTarget) {
|
|
66
|
+
if (explicitTarget !== undefined)
|
|
67
|
+
return join(basePath, ...explicitTarget.split("/"));
|
|
68
|
+
const isRootTemplate = file.endsWith(".json");
|
|
69
|
+
return isRootTemplate ? join(basePath, file) : join(basePath, ".github", "workflows", file);
|
|
70
|
+
}
|
package/dist/index.d.ts
ADDED