@plainconceptsplatform/workflows 0.6.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 +39 -2
- package/dist/catalog-installation.js +172 -109
- package/dist/index.js +128 -73
- package/dist/package-baseline.d.ts +25 -0
- package/dist/package-baseline.js +138 -0
- package/dist/route-processing.d.ts +0 -2
- package/dist/route-processing.js +22 -91
- package/dist/stack-defaults.js +18 -12
- package/dist/tui.js +27 -43
- package/dist/worker-env.d.ts +46 -0
- package/dist/worker-env.js +179 -0
- package/dist/workflow-catalog.d.ts +4 -2
- package/dist/workflow-catalog.js +5 -3
- package/loops/actions/add-issue-labels/action.yml +20 -0
- package/loops/actions/audit-close/action.yml +180 -128
- package/loops/actions/classify-route/classify-route.sh +8 -2
- package/loops/actions/housekeeping/action.yml +251 -0
- package/loops/actions/merge-agent-pr/action.yml +13 -0
- 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-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-route-matrix/verify-route-matrix.sh +847 -170
- package/loops/templates/agentics/agentics-error-report.yml +97 -0
- package/loops/templates/opencode/opencode.ci.json +1 -1
- package/loops/workflows/agent-apply-review.md +452 -469
- package/loops/workflows/agent-audit.md +201 -213
- package/loops/workflows/agent-implement.md +616 -640
- package/loops/workflows/agent-merge-gate.md +830 -844
- package/loops/workflows/agent-refine.md +599 -633
- 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 -1038
- package/package.json +9 -8
- package/dist/action-validation.test.d.ts +0 -1
- package/dist/action-validation.test.js +0 -87
- package/dist/catalog-installation.test.d.ts +0 -1
- package/dist/catalog-installation.test.js +0 -485
- package/dist/catalog-listing.test.d.ts +0 -1
- package/dist/catalog-listing.test.js +0 -150
- package/dist/index.test.d.ts +0 -1
- package/dist/index.test.js +0 -273
- package/dist/repository-inspection.test.d.ts +0 -1
- package/dist/repository-inspection.test.js +0 -77
- package/dist/route-processing.test.d.ts +0 -1
- package/dist/route-processing.test.js +0 -283
- package/dist/stack-defaults.test.d.ts +0 -1
- package/dist/stack-defaults.test.js +0 -266
- package/dist/tui.test.d.ts +0 -1
- package/dist/tui.test.js +0 -249
- package/dist/workflow-catalog.test.d.ts +0 -1
- package/dist/workflow-catalog.test.js +0 -29
- package/loops/actions/stale-recovery/action.yml +0 -288
- package/loops/actions/update-changelog/action.yml +0 -113
package/dist/index.js
CHANGED
|
@@ -2,58 +2,68 @@
|
|
|
2
2
|
import { inspectRepository, parseVisibility, resolveVisibility } from "./repository-inspection.js";
|
|
3
3
|
import { installCatalog, installedRoutes, installTemplate, isTemplateName, removeRouteFiles } from "./catalog-installation.js";
|
|
4
4
|
import { formatCatalog, listCatalog, searchCatalog } from "./catalog-listing.js";
|
|
5
|
+
import { packageVersion } from "./package-baseline.js";
|
|
5
6
|
import { routeNames, templateNames } from "./workflow-catalog.js";
|
|
6
7
|
import { runInteractive } from "./tui.js";
|
|
7
8
|
import { resolve } from "node:path";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
const HELP_TEXT = `Workflows CLI — install and
|
|
10
|
-
|
|
11
|
-
Run with no arguments to launch the interactive TUI, the primary way to select and install
|
|
12
|
-
workflows and templates:
|
|
13
|
-
|
|
14
|
-
npx @plainconceptsplatform/workflows
|
|
15
|
-
|
|
16
|
-
Advanced (non-interactive) commands:
|
|
17
|
-
|
|
18
|
-
Usage: workflows <command> [options]
|
|
19
|
-
|
|
20
|
-
Commands:
|
|
21
|
-
(default) Launch the interactive TUI for selecting and installing items.
|
|
22
|
-
init Inspect the repository and report its stack and visibility.
|
|
23
|
-
add [routes] [--template <name>]
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
status Print repository inspection as JSON.
|
|
27
|
-
list List all available workflows and templates with install status.
|
|
28
|
-
search <query> Filter workflows and templates by name or description.
|
|
29
|
-
|
|
30
|
-
Route names (positional arguments to add):
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
add
|
|
34
|
-
shared imports,
|
|
35
|
-
|
|
36
|
-
add
|
|
37
|
-
add
|
|
38
|
-
add refine
|
|
39
|
-
remove
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
--force
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
10
|
+
const HELP_TEXT = `Workflows CLI — install and update Plain Concepts Platform agentic workflows.
|
|
11
|
+
|
|
12
|
+
Run with no arguments to launch the interactive TUI, the primary way to select and install
|
|
13
|
+
workflows and templates:
|
|
14
|
+
|
|
15
|
+
npx @plainconceptsplatform/workflows
|
|
16
|
+
|
|
17
|
+
Advanced (non-interactive) commands:
|
|
18
|
+
|
|
19
|
+
Usage: workflows <command> [options]
|
|
20
|
+
|
|
21
|
+
Commands:
|
|
22
|
+
(default) Launch the interactive TUI for selecting and installing items.
|
|
23
|
+
init Inspect the repository and report its stack and visibility.
|
|
24
|
+
add [routes] [--template <name>] Install route workers and refresh every installed package file.
|
|
25
|
+
update Alias for add: refresh what is installed to this package version.
|
|
26
|
+
remove <routes> Uninstall route workers and regenerate the router without them.
|
|
27
|
+
status Print repository inspection as JSON.
|
|
28
|
+
list List all available workflows and templates with install status.
|
|
29
|
+
search <query> Filter workflows and templates by name or description.
|
|
30
|
+
|
|
31
|
+
Route names (positional arguments to add and remove):
|
|
32
|
+
${routeNames.join(", ")}
|
|
33
|
+
|
|
34
|
+
add Refresh installed files. In an empty repository: actions, router,
|
|
35
|
+
classifier, matrix, shared imports, opencode.ci.json and the compile
|
|
36
|
+
script, with no workers.
|
|
37
|
+
add implement refine Install those workers on top of the ones already installed.
|
|
38
|
+
add --template agentics-checks Install the named template only.
|
|
39
|
+
add refine --template agentics-checks Routes plus mandatory files plus the named template.
|
|
40
|
+
remove audit Uninstall the audit worker and drop it from the router.
|
|
41
|
+
|
|
42
|
+
What an update does to a package-managed file:
|
|
43
|
+
The file is replaced with this version's, and its ownership header records the version.
|
|
44
|
+
In a worker (agent-*.md) the env: block at the top is yours: your values are kept, keys the
|
|
45
|
+
package added arrive with their defaults, keys only you defined stay. When the header records
|
|
46
|
+
the version you installed from, that release is fetched from npm and a value you never changed
|
|
47
|
+
follows the package when its default changes. The agent runner pool and the engine gateway URL
|
|
48
|
+
are kept as well. Everything else in the file is the package's.
|
|
49
|
+
A file whose ownership header was removed is yours and is left alone unless --force is passed.
|
|
50
|
+
Templates are yours after installation and are replaced only with --force.
|
|
51
|
+
|
|
52
|
+
Options:
|
|
53
|
+
--dry-run Print what add or update would change, write nothing.
|
|
54
|
+
--force Also overwrite consumer-owned files and changed templates.
|
|
55
|
+
--baseline <path> Use this loops/ directory as the merge baseline instead of
|
|
56
|
+
fetching the installed version from npm. For rolling out a
|
|
57
|
+
version that is not published yet: without a baseline the
|
|
58
|
+
merge is two-way and keeps every consumer value, including
|
|
59
|
+
the package defaults the consumer never chose.
|
|
60
|
+
--template <name> Install a standalone template alongside or instead of routes.
|
|
61
|
+
Templates: ${templateNames.join(", ")}.
|
|
62
|
+
--visibility public|private Override repository visibility (init only).
|
|
63
|
+
--version Print the package version.
|
|
64
|
+
-h, --help Show this help text.
|
|
65
|
+
|
|
66
|
+
Installed workflows are marked [x] when the corresponding .github/workflows/agent-*.md
|
|
57
67
|
file exists relative to the current directory.`;
|
|
58
68
|
export async function run(arguments_, repositoryPath = process.cwd()) {
|
|
59
69
|
const [command, ...options] = arguments_;
|
|
@@ -61,6 +71,10 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
|
|
|
61
71
|
console.log(HELP_TEXT);
|
|
62
72
|
return 0;
|
|
63
73
|
}
|
|
74
|
+
if (command === "--version" || command === "-v") {
|
|
75
|
+
console.log(await packageVersion());
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
64
78
|
if (command === undefined) {
|
|
65
79
|
const force = options.includes("--force");
|
|
66
80
|
return runInteractive(repositoryPath, { force });
|
|
@@ -97,32 +111,42 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
|
|
|
97
111
|
if (parsed.kind === "invalid")
|
|
98
112
|
return fail(parsed.message);
|
|
99
113
|
const inspection = await inspectRepository(repositoryPath);
|
|
100
|
-
const routes = parsed
|
|
101
|
-
const
|
|
102
|
-
const force = parsed.force;
|
|
114
|
+
const { routes, template, force, dryRun } = parsed;
|
|
115
|
+
const baseline = localBaseline(parsed.baseline);
|
|
103
116
|
const allConflicts = [];
|
|
104
117
|
const allInstalled = [];
|
|
105
|
-
|
|
118
|
+
let catalog;
|
|
119
|
+
const templatesPlanned = [];
|
|
120
|
+
// The installed set is always part of the target set: adding a route never drops another,
|
|
121
|
+
// and a plain update refreshes exactly what is there.
|
|
122
|
+
if (routes.length > 0 || template === undefined) {
|
|
106
123
|
const selectedRoutes = unionRoutes(routes, await installedRoutes(repositoryPath));
|
|
107
|
-
|
|
108
|
-
allConflicts.push(...
|
|
109
|
-
allInstalled.push(...
|
|
110
|
-
}
|
|
111
|
-
else if (template === undefined) {
|
|
112
|
-
const result = await installCatalog(repositoryPath, { force, selectedRoutes: [], inspection });
|
|
113
|
-
allConflicts.push(...result.conflicts);
|
|
114
|
-
allInstalled.push(...result.installed);
|
|
124
|
+
catalog = await installCatalog(repositoryPath, { force, dryRun, selectedRoutes, inspection, baseline });
|
|
125
|
+
allConflicts.push(...catalog.conflicts);
|
|
126
|
+
allInstalled.push(...catalog.installed);
|
|
115
127
|
}
|
|
116
128
|
if (template !== undefined) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
129
|
+
if (dryRun) {
|
|
130
|
+
templatesPlanned.push(template);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
const result = await installTemplate(repositoryPath, template, { force, inspection });
|
|
134
|
+
allConflicts.push(...result.conflicts);
|
|
135
|
+
allInstalled.push(...result.installed);
|
|
136
|
+
}
|
|
120
137
|
}
|
|
121
138
|
if (allConflicts.length > 0 && !force) {
|
|
122
|
-
console.error(`
|
|
139
|
+
console.error(`Template conflicts found. Re-run with --force to overwrite consumer-owned copies:\n${allConflicts.join("\n")}`);
|
|
123
140
|
return 1;
|
|
124
141
|
}
|
|
125
|
-
console.log(JSON.stringify({
|
|
142
|
+
console.log(JSON.stringify({
|
|
143
|
+
command,
|
|
144
|
+
dryRun,
|
|
145
|
+
...(catalog === undefined ? {} : summarize(catalog)),
|
|
146
|
+
...(templatesPlanned.length > 0 ? { templatesPlanned } : {}),
|
|
147
|
+
installed: allInstalled.sort(),
|
|
148
|
+
conflicts: allConflicts,
|
|
149
|
+
}, null, 2));
|
|
126
150
|
return 0;
|
|
127
151
|
}
|
|
128
152
|
if (command === "remove") {
|
|
@@ -136,17 +160,34 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
|
|
|
136
160
|
const inspection = await inspectRepository(repositoryPath);
|
|
137
161
|
const installed = await installedRoutes(repositoryPath);
|
|
138
162
|
const desiredRoutes = installed.filter((route) => !parsed.routes.includes(route));
|
|
139
|
-
const result = await installCatalog(repositoryPath, { force: parsed.force, selectedRoutes: desiredRoutes, inspection });
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
const removed = await removeRouteFiles(repositoryPath, parsed.routes);
|
|
145
|
-
console.log(JSON.stringify({ command, installed: [...result.installed].sort(), removed, conflicts: result.conflicts }, null, 2));
|
|
163
|
+
const result = await installCatalog(repositoryPath, { force: parsed.force, dryRun: parsed.dryRun, selectedRoutes: desiredRoutes, inspection, baseline: localBaseline(parsed.baseline) });
|
|
164
|
+
const removed = parsed.dryRun
|
|
165
|
+
? parsed.routes.filter((route) => installed.includes(route)).map((route) => `.github/workflows/agent-${route}.md`)
|
|
166
|
+
: await removeRouteFiles(repositoryPath, parsed.routes);
|
|
167
|
+
console.log(JSON.stringify({ command, dryRun: parsed.dryRun, ...summarize(result), installed: [...result.installed].sort(), removed, conflicts: result.conflicts }, null, 2));
|
|
146
168
|
return 0;
|
|
147
169
|
}
|
|
148
170
|
return fail(`Unknown command: ${command}`);
|
|
149
171
|
}
|
|
172
|
+
function summarize(result) {
|
|
173
|
+
return {
|
|
174
|
+
packageVersion: result.packageVersion,
|
|
175
|
+
installedVersions: result.installedVersions,
|
|
176
|
+
upToDate: result.upToDate,
|
|
177
|
+
baselines: result.baselines,
|
|
178
|
+
changes: result.changes.filter((change) => change.status !== "unchanged"),
|
|
179
|
+
unchanged: result.changes.filter((change) => change.status === "unchanged").length,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
// A fixed baseline for every recorded version, instead of asking npm. Rolling out a version that
|
|
183
|
+
// is not published yet leaves the merge two-way, which keeps every consumer value, including the
|
|
184
|
+
// package defaults a consumer never chose and would rather have replaced.
|
|
185
|
+
function localBaseline(path) {
|
|
186
|
+
if (path === undefined)
|
|
187
|
+
return undefined;
|
|
188
|
+
const resolved = resolve(path);
|
|
189
|
+
return async () => resolved;
|
|
190
|
+
}
|
|
150
191
|
function unionRoutes(requested, installed) {
|
|
151
192
|
const result = [...requested];
|
|
152
193
|
for (const route of installed) {
|
|
@@ -168,6 +209,8 @@ function parseAddOptions(options) {
|
|
|
168
209
|
let template;
|
|
169
210
|
let templateSeen = false;
|
|
170
211
|
let force = false;
|
|
212
|
+
let dryRun = false;
|
|
213
|
+
let baseline;
|
|
171
214
|
let i = 0;
|
|
172
215
|
while (i < options.length) {
|
|
173
216
|
const token = options[i];
|
|
@@ -176,6 +219,18 @@ function parseAddOptions(options) {
|
|
|
176
219
|
i++;
|
|
177
220
|
continue;
|
|
178
221
|
}
|
|
222
|
+
if (token === "--dry-run") {
|
|
223
|
+
dryRun = true;
|
|
224
|
+
i++;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (token === "--baseline") {
|
|
228
|
+
if (i + 1 >= options.length)
|
|
229
|
+
return invalid("--baseline requires a path.");
|
|
230
|
+
baseline = options[i + 1];
|
|
231
|
+
i += 2;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
179
234
|
if (token === "--template") {
|
|
180
235
|
if (templateSeen)
|
|
181
236
|
return invalid("--template can only be specified once.");
|
|
@@ -202,7 +257,7 @@ function parseAddOptions(options) {
|
|
|
202
257
|
}
|
|
203
258
|
return invalid(`Unknown route: ${token}. Valid routes: ${routeNames.join(", ")}.`);
|
|
204
259
|
}
|
|
205
|
-
return { kind: "ok", routes, template, force };
|
|
260
|
+
return { kind: "ok", routes, template, force, dryRun, baseline };
|
|
206
261
|
}
|
|
207
262
|
function invalid(message) {
|
|
208
263
|
return { kind: "invalid", message };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare const PACKAGE_NAME = "@plainconceptsplatform/workflows";
|
|
2
|
+
export declare function packageVersion(): Promise<string>;
|
|
3
|
+
export declare function isValidVersion(version: string): boolean;
|
|
4
|
+
export declare function hasOwnershipHeader(content: string): boolean;
|
|
5
|
+
/** The version recorded in a file's ownership header, or undefined for an unstamped file. */
|
|
6
|
+
export declare function installedVersion(content: string): string | undefined;
|
|
7
|
+
/** Write the version into the ownership header. Re-stamping replaces the previous version. */
|
|
8
|
+
export declare function stampVersion(content: string, version: string): string;
|
|
9
|
+
export type BaselineFetcher = (version: string) => Promise<string | undefined>;
|
|
10
|
+
export interface FetchBaselineOptions {
|
|
11
|
+
/** Where fetched releases are kept between runs. Defaults to the OS temp directory. */
|
|
12
|
+
readonly cacheRoot?: string;
|
|
13
|
+
/** Runs a shell command. Injected by tests. */
|
|
14
|
+
readonly run?: (command: string) => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The `loops/` directory of a published release, or undefined when it cannot be had
|
|
18
|
+
* (offline, unpublished version). The caller falls back to a two-way merge.
|
|
19
|
+
*/
|
|
20
|
+
export declare function fetchBaseline(version: string, options?: FetchBaselineOptions): Promise<string | undefined>;
|
|
21
|
+
/**
|
|
22
|
+
* Unpack a gzipped ustar/pax archive (what `npm pack` produces) into a directory. Regular files
|
|
23
|
+
* and directories only; anything that would escape the destination is skipped.
|
|
24
|
+
*/
|
|
25
|
+
export declare function extractTarball(archive: Buffer, destination: string): void;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Every installed file carries the package version in its ownership header, stamped at
|
|
2
|
+
// install time. That version is the baseline an update merges against: the CLI fetches that
|
|
3
|
+
// exact release from npm and reads the file as it was when the consumer installed it.
|
|
4
|
+
import { exec as execCallback } from "node:child_process";
|
|
5
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { access, mkdir, readdir, readFile } from "node:fs/promises";
|
|
7
|
+
import { constants } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
import { gunzipSync } from "node:zlib";
|
|
13
|
+
const exec = promisify(execCallback);
|
|
14
|
+
export const PACKAGE_NAME = "@plainconceptsplatform/workflows";
|
|
15
|
+
const HEADER = `Managed by ${PACKAGE_NAME}`;
|
|
16
|
+
const VERSION = "\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?";
|
|
17
|
+
const STAMPED = new RegExp(`${escapeRegex(HEADER)}@(${VERSION})\\.`);
|
|
18
|
+
const UNSTAMPED = new RegExp(`${escapeRegex(HEADER)}\\.`);
|
|
19
|
+
export async function packageVersion() {
|
|
20
|
+
const manifest = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
21
|
+
const { version } = JSON.parse(await readFile(manifest, "utf8"));
|
|
22
|
+
return version;
|
|
23
|
+
}
|
|
24
|
+
export function isValidVersion(version) {
|
|
25
|
+
return new RegExp(`^${VERSION}$`).test(version);
|
|
26
|
+
}
|
|
27
|
+
export function hasOwnershipHeader(content) {
|
|
28
|
+
return content.includes(HEADER);
|
|
29
|
+
}
|
|
30
|
+
/** The version recorded in a file's ownership header, or undefined for an unstamped file. */
|
|
31
|
+
export function installedVersion(content) {
|
|
32
|
+
return STAMPED.exec(content)?.[1];
|
|
33
|
+
}
|
|
34
|
+
/** Write the version into the ownership header. Re-stamping replaces the previous version. */
|
|
35
|
+
export function stampVersion(content, version) {
|
|
36
|
+
if (STAMPED.test(content))
|
|
37
|
+
return content.replace(STAMPED, `${HEADER}@${version}.`);
|
|
38
|
+
return content.replace(UNSTAMPED, `${HEADER}@${version}.`);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The `loops/` directory of a published release, or undefined when it cannot be had
|
|
42
|
+
* (offline, unpublished version). The caller falls back to a two-way merge.
|
|
43
|
+
*/
|
|
44
|
+
export async function fetchBaseline(version, options = {}) {
|
|
45
|
+
if (!isValidVersion(version))
|
|
46
|
+
return undefined;
|
|
47
|
+
const root = options.cacheRoot ?? join(tmpdir(), "plainconceptsplatform-workflows");
|
|
48
|
+
const directory = join(root, version);
|
|
49
|
+
const loops = join(directory, "package", "loops");
|
|
50
|
+
if (await exists(loops))
|
|
51
|
+
return loops;
|
|
52
|
+
const run = options.run ?? (async (command) => { await exec(command, { windowsHide: true }); });
|
|
53
|
+
try {
|
|
54
|
+
await mkdir(directory, { recursive: true });
|
|
55
|
+
// npm honours the user's registry configuration, which a direct download would not.
|
|
56
|
+
let tarball = await findTarball(directory);
|
|
57
|
+
if (tarball === undefined) {
|
|
58
|
+
await run(`npm pack "${PACKAGE_NAME}@${version}" --pack-destination "${directory}" --silent`);
|
|
59
|
+
tarball = await findTarball(directory);
|
|
60
|
+
}
|
|
61
|
+
if (tarball === undefined)
|
|
62
|
+
return undefined;
|
|
63
|
+
// Extracted here rather than by a `tar` binary: the one on PATH may be GNU tar under
|
|
64
|
+
// MSYS, which reads `C:\...` as a remote host, or missing altogether.
|
|
65
|
+
extractTarball(await readFile(join(directory, tarball)), directory);
|
|
66
|
+
return (await exists(loops)) ? loops : undefined;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function findTarball(directory) {
|
|
73
|
+
return (await readdir(directory)).find((file) => file.endsWith(".tgz"));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Unpack a gzipped ustar/pax archive (what `npm pack` produces) into a directory. Regular files
|
|
77
|
+
* and directories only; anything that would escape the destination is skipped.
|
|
78
|
+
*/
|
|
79
|
+
export function extractTarball(archive, destination) {
|
|
80
|
+
const data = gunzipSync(archive);
|
|
81
|
+
const field = (block, start, length) => {
|
|
82
|
+
const raw = block.subarray(start, start + length);
|
|
83
|
+
const end = raw.indexOf(0);
|
|
84
|
+
return raw.subarray(0, end === -1 ? raw.length : end).toString("utf8");
|
|
85
|
+
};
|
|
86
|
+
let offset = 0;
|
|
87
|
+
let longName;
|
|
88
|
+
while (offset + 512 <= data.length) {
|
|
89
|
+
const header = data.subarray(offset, offset + 512);
|
|
90
|
+
offset += 512;
|
|
91
|
+
if (header.every((byte) => byte === 0))
|
|
92
|
+
break;
|
|
93
|
+
const size = Number.parseInt(field(header, 124, 12).trim() || "0", 8);
|
|
94
|
+
const type = field(header, 156, 1) || "0";
|
|
95
|
+
const body = data.subarray(offset, offset + size);
|
|
96
|
+
offset += Math.ceil(size / 512) * 512;
|
|
97
|
+
if (type === "x" || type === "g") {
|
|
98
|
+
// pax extended header: records of the form "<length> key=value\n"; `path` names the next entry.
|
|
99
|
+
for (const record of body.toString("utf8").split("\n")) {
|
|
100
|
+
const match = /^\d+ path=(.*)$/.exec(record);
|
|
101
|
+
if (match !== null && type === "x")
|
|
102
|
+
longName = match[1];
|
|
103
|
+
}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (type === "L") {
|
|
107
|
+
longName = body.toString("utf8").replace(/\0+$/, "");
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const prefix = field(header, 345, 155);
|
|
111
|
+
const shortName = field(header, 0, 100);
|
|
112
|
+
const name = longName ?? (prefix === "" ? shortName : `${prefix}/${shortName}`);
|
|
113
|
+
longName = undefined;
|
|
114
|
+
const parts = name.split("/").filter((part) => part !== "" && part !== ".");
|
|
115
|
+
if (parts.length === 0 || parts.includes(".."))
|
|
116
|
+
continue;
|
|
117
|
+
const target = join(destination, ...parts);
|
|
118
|
+
if (type === "5") {
|
|
119
|
+
mkdirSync(target, { recursive: true });
|
|
120
|
+
}
|
|
121
|
+
else if (type === "0") {
|
|
122
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
123
|
+
writeFileSync(target, body);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function exists(path) {
|
|
128
|
+
try {
|
|
129
|
+
await access(path, constants.F_OK);
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function escapeRegex(value) {
|
|
137
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
138
|
+
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { type RouteName } from "./workflow-catalog.js";
|
|
2
2
|
export declare function stripRouteFromRouter(yaml: string, route: RouteName): string;
|
|
3
|
-
export declare function stripRouteFromClassifier(shell: string, route: RouteName): string;
|
|
4
|
-
export declare function addRouteExclusion(matrix: string, route: RouteName): string;
|
|
5
3
|
export declare function processRoutes(files: Map<string, string>, selectedRoutes: readonly RouteName[]): Map<string, string>;
|
|
6
4
|
export declare function excludedWorkerFiles(selectedRoutes: readonly RouteName[]): Set<string>;
|
package/dist/route-processing.js
CHANGED
|
@@ -1,64 +1,26 @@
|
|
|
1
1
|
import { routeNames, workflowRoutes } from "./workflow-catalog.js";
|
|
2
|
+
// The router is the one derived file: its content is a function of the installed route set.
|
|
3
|
+
// The classifier stays the complete pure function (a route with no job is a no-op run), and the
|
|
4
|
+
// route matrix reads the installed worker files, so neither is rewritten here.
|
|
2
5
|
const routeCrons = {
|
|
3
6
|
audit: "17 1 * * 1",
|
|
4
7
|
};
|
|
8
|
+
// Deterministic router jobs that exist only to serve one worker route. They leave with it, or
|
|
9
|
+
// the route matrix finds a `route == 'implement'` guard in a router with no implement worker.
|
|
10
|
+
const routeHelperJobs = {
|
|
11
|
+
implement: ["check-implement-pr"],
|
|
12
|
+
triage: ["dispatch-triage"],
|
|
13
|
+
};
|
|
5
14
|
export function stripRouteFromRouter(yaml, route) {
|
|
6
15
|
let result = yaml;
|
|
7
16
|
const cron = routeCrons[route];
|
|
8
17
|
if (cron !== undefined) {
|
|
9
18
|
result = result.replace(new RegExp(`^ - cron: "${escapeRegex(cron)}"\\n`, "gm"), "");
|
|
10
19
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return result;
|
|
14
|
-
}
|
|
15
|
-
export function stripRouteFromClassifier(shell, route) {
|
|
16
|
-
const constName = route.replace(/-/g, "_").toUpperCase() + "_CRON";
|
|
17
|
-
let result = shell;
|
|
18
|
-
result = result.replace(new RegExp(`^readonly ${constName}="[^"]*"\\n`, "gm"), "");
|
|
19
|
-
const cron = routeCrons[route];
|
|
20
|
-
if (cron !== undefined) {
|
|
21
|
-
result = result.replace(new RegExp(`\\s*"\\$${constName}"\\) route="${escapeRegex(route)}" ;;\\n`, "g"), "");
|
|
22
|
-
}
|
|
23
|
-
result = result.replace(new RegExp(` \\| ${escapeRegex(route)}\\)`, "g"), ")");
|
|
24
|
-
result = result.replace(new RegExp(`^(\\s+)${escapeRegex(route)} \\| `, "gm"), "$1");
|
|
25
|
-
if (!result.includes(`| ${route}`) && !result.includes(`${route} |`)) {
|
|
26
|
-
result = removeAloneDispatchCase(result, route);
|
|
27
|
-
}
|
|
28
|
-
return result;
|
|
29
|
-
}
|
|
30
|
-
function removeAloneDispatchCase(shell, route) {
|
|
31
|
-
const lines = shell.split("\n");
|
|
32
|
-
const pattern = new RegExp(`^(\\s+)${escapeRegex(route)}\\)\\s*$`);
|
|
33
|
-
const result = [];
|
|
34
|
-
let i = 0;
|
|
35
|
-
while (i < lines.length) {
|
|
36
|
-
const match = lines[i].match(pattern);
|
|
37
|
-
if (match) {
|
|
38
|
-
const indent = match[1];
|
|
39
|
-
i++;
|
|
40
|
-
while (i < lines.length) {
|
|
41
|
-
if (new RegExp(`^${escapeRegex(indent)};;\\s*$`).test(lines[i])) {
|
|
42
|
-
i++;
|
|
43
|
-
break;
|
|
44
|
-
}
|
|
45
|
-
i++;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
else {
|
|
49
|
-
result.push(lines[i]);
|
|
50
|
-
i++;
|
|
51
|
-
}
|
|
20
|
+
for (const job of [`call-${route}`, ...(routeHelperJobs[route] ?? [])]) {
|
|
21
|
+
result = removeJobBlock(result, job);
|
|
52
22
|
}
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
export function addRouteExclusion(matrix, route) {
|
|
56
|
-
if (matrix.includes(`excluded route '${route}'`))
|
|
57
|
-
return matrix;
|
|
58
|
-
let result = matrix;
|
|
59
|
-
result = result.replace(new RegExp(` ${escapeRegex(route)} `, "g"), " ");
|
|
60
|
-
const exclusionBlock = `\necho "── Excluded routes ──────────────────────────────────────────────────────"\nif ! grep -q "route == '${route}'" "$ROUTER_YML"; then\n PASS=$((PASS + 1))\n echo " ${route} correctly excluded from work-router.yml"\nelse\n FAIL=$((FAIL + 1))\n echo "FAIL: excluded route '${route}' is still in work-router.yml" >&2\nfi\n`;
|
|
61
|
-
result = result.replace(/(\necho\nif \[ "\$FAIL" -eq 0 \])/, `${exclusionBlock}$1`);
|
|
23
|
+
result = result.replace(new RegExp(`^ - ${escapeRegex(route)}\\n`, "gm"), "");
|
|
62
24
|
return result;
|
|
63
25
|
}
|
|
64
26
|
export function processRoutes(files, selectedRoutes) {
|
|
@@ -66,56 +28,23 @@ export function processRoutes(files, selectedRoutes) {
|
|
|
66
28
|
if (excludedRoutes.length === 0)
|
|
67
29
|
return files;
|
|
68
30
|
const result = new Map(files);
|
|
31
|
+
const routerKey = findFileKey(result, "work-router.yml");
|
|
32
|
+
if (routerKey === undefined)
|
|
33
|
+
return result;
|
|
34
|
+
let router = result.get(routerKey);
|
|
69
35
|
for (const route of excludedRoutes) {
|
|
70
|
-
|
|
71
|
-
if (routerKey !== undefined) {
|
|
72
|
-
result.set(routerKey, stripRouteFromRouter(result.get(routerKey), route));
|
|
73
|
-
}
|
|
74
|
-
const matrixKey = findFileKey(result, "verify-route-matrix.sh");
|
|
75
|
-
if (matrixKey !== undefined) {
|
|
76
|
-
result.set(matrixKey, addRouteExclusion(result.get(matrixKey), route));
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
const matrixKey = findFileKey(result, "verify-route-matrix.sh");
|
|
80
|
-
if (matrixKey !== undefined) {
|
|
81
|
-
result.set(matrixKey, createRouteMatrix(selectedRoutes));
|
|
36
|
+
router = stripRouteFromRouter(router, route);
|
|
82
37
|
}
|
|
38
|
+
result.set(routerKey, router);
|
|
83
39
|
return result;
|
|
84
40
|
}
|
|
85
|
-
function createRouteMatrix(selectedRoutes) {
|
|
86
|
-
const selected = selectedRoutes.join(" ");
|
|
87
|
-
const excluded = routeNames.filter((route) => !selectedRoutes.includes(route)).join(" ");
|
|
88
|
-
return `#!/usr/bin/env bash
|
|
89
|
-
set -euo pipefail
|
|
90
|
-
|
|
91
|
-
HERE="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
92
|
-
ROUTER_YML="\${HERE}/../../workflows/work-router.yml"
|
|
93
|
-
CLASSIFIER="\${HERE}/../classify-route/classify-route.sh"
|
|
94
|
-
|
|
95
|
-
bash -n "$CLASSIFIER"
|
|
96
|
-
|
|
97
|
-
for route in ${selected}; do
|
|
98
|
-
grep -q "route == '$route'" "$ROUTER_YML" || {
|
|
99
|
-
echo "FAIL: selected route '$route' has no router job" >&2
|
|
100
|
-
exit 1
|
|
101
|
-
}
|
|
102
|
-
done
|
|
103
|
-
|
|
104
|
-
for route in ${excluded}; do
|
|
105
|
-
if grep -q "route == '$route'" "$ROUTER_YML"; then
|
|
106
|
-
echo "FAIL: excluded route '$route' remains in router" >&2
|
|
107
|
-
exit 1
|
|
108
|
-
fi
|
|
109
|
-
done
|
|
110
|
-
|
|
111
|
-
echo "Route matrix: selected routes valid"
|
|
112
|
-
`;
|
|
113
|
-
}
|
|
114
41
|
export function excludedWorkerFiles(selectedRoutes) {
|
|
115
42
|
return new Set(workflowRoutes
|
|
116
43
|
.filter((route) => !selectedRoutes.includes(route.name))
|
|
117
44
|
.map((route) => route.worker));
|
|
118
45
|
}
|
|
46
|
+
// Remove a top-level job (two-space indent) together with the comment lines that introduce it.
|
|
47
|
+
// A job's comment describes that job; left behind it describes nothing.
|
|
119
48
|
function removeJobBlock(yaml, jobName) {
|
|
120
49
|
const lines = yaml.split("\n");
|
|
121
50
|
const startPattern = new RegExp(`^ ${escapeRegex(jobName)}:`);
|
|
@@ -129,6 +58,8 @@ function removeJobBlock(yaml, jobName) {
|
|
|
129
58
|
}
|
|
130
59
|
}
|
|
131
60
|
else if (startPattern.test(line)) {
|
|
61
|
+
while (result.length > 0 && /^ #/.test(result[result.length - 1]))
|
|
62
|
+
result.pop();
|
|
132
63
|
skipping = true;
|
|
133
64
|
}
|
|
134
65
|
else {
|
package/dist/stack-defaults.js
CHANGED
|
@@ -18,38 +18,44 @@ export function generateStackDefaults(inspection) {
|
|
|
18
18
|
}
|
|
19
19
|
return { verifyCommands, repoRulesBase, hasDotnet, hasNodeOnly };
|
|
20
20
|
}
|
|
21
|
+
// Only a worker that prints a verification block declares VERIFY_COMMANDS. Adding the key to
|
|
22
|
+
// the others made byte-identical workers read as changed on every update, so a worker without
|
|
23
|
+
// the key is left without it.
|
|
21
24
|
export function injectStackEnv(content, defaults) {
|
|
22
|
-
|
|
23
|
-
if (result.includes("VERIFY_COMMANDS:")) {
|
|
24
|
-
result = result.replace(/ VERIFY_COMMANDS: ".*"/, ` VERIFY_COMMANDS: "${defaults.verifyCommands}"`);
|
|
25
|
-
}
|
|
26
|
-
else if (/^env:\n/m.test(result)) {
|
|
27
|
-
result = result.replace(/^env:\n/m, `env:\n VERIFY_COMMANDS: "${defaults.verifyCommands}"\n`);
|
|
28
|
-
}
|
|
29
|
-
return result;
|
|
25
|
+
return content.replace(/^ VERIFY_COMMANDS: .*$/m, ` VERIFY_COMMANDS: "${defaults.verifyCommands}"`);
|
|
30
26
|
}
|
|
31
27
|
export function generateOpencodeCi(baseContent, inspection) {
|
|
32
28
|
let result = baseContent;
|
|
29
|
+
// Idempotent on purpose: this runs on every install and update, and the shared file already
|
|
30
|
+
// carries some of these steps. A step is added only when no step of that name exists.
|
|
33
31
|
if (inspection.stackHints.solutionFiles.length > 0) {
|
|
34
32
|
const solutionPath = inspection.stackHints.solutionFiles[0].replaceAll("\\", "/");
|
|
35
|
-
|
|
33
|
+
if (!result.includes("name: Cache NuGet packages")) {
|
|
34
|
+
const nugetCache = ` - name: Cache NuGet packages
|
|
36
35
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
37
36
|
with:
|
|
38
37
|
path: ~/.nuget/packages
|
|
39
38
|
key: nuget-\${{ runner.os }}-\${{ hashFiles('**/*.slnx', '**/Directory.Packages.props') }}
|
|
40
39
|
restore-keys: nuget-\${{ runner.os }}-
|
|
41
40
|
|
|
42
|
-
|
|
41
|
+
`;
|
|
42
|
+
result = insertBeforeMarker(result, nugetCache, " - name: Install workspace dependencies");
|
|
43
|
+
}
|
|
44
|
+
if (!result.includes("name: Restore .NET dependencies")) {
|
|
45
|
+
const restore = ` - name: Restore .NET dependencies
|
|
43
46
|
run: dotnet restore ${solutionPath}
|
|
47
|
+
|
|
44
48
|
`;
|
|
45
|
-
|
|
49
|
+
result = insertBeforeMarker(result, restore, " - name: Install workspace dependencies");
|
|
50
|
+
}
|
|
46
51
|
}
|
|
47
|
-
if (inspection.stackHints.openSpec) {
|
|
52
|
+
if (inspection.stackHints.openSpec && !result.includes("name: Install OpenSpec CLI")) {
|
|
48
53
|
const openspecStep = ` - name: Install OpenSpec CLI
|
|
49
54
|
run: |
|
|
50
55
|
set -euo pipefail
|
|
51
56
|
npm install -g "@fission-ai/openspec@1.8.0"
|
|
52
57
|
openspec --version
|
|
58
|
+
|
|
53
59
|
`;
|
|
54
60
|
result = insertBeforeMarker(result, openspecStep, " - name: Install workspace dependencies");
|
|
55
61
|
}
|