@plainconceptsplatform/workflows 0.2.1 → 0.3.2
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 +13 -2
- package/dist/catalog-installation.d.ts +7 -1
- package/dist/catalog-installation.js +94 -12
- package/dist/catalog-installation.test.js +39 -0
- package/dist/index.js +84 -24
- package/dist/index.test.js +122 -2
- package/dist/repository-inspection.test.js +1 -1
- package/dist/route-processing.d.ts +6 -0
- package/dist/route-processing.js +121 -0
- package/dist/route-processing.test.d.ts +1 -0
- package/dist/route-processing.test.js +340 -0
- package/dist/stack-defaults.d.ts +11 -0
- package/dist/stack-defaults.js +105 -0
- package/dist/stack-defaults.test.d.ts +1 -0
- package/dist/stack-defaults.test.js +257 -0
- package/dist/tui.js +12 -2
- package/loops/templates/ci/app-ci-dotnet-next.yml +5 -1
- package/loops/workflows/shared/platform-defaults.md +0 -2
- package/loops/workflows/work-router.yml +33 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ The primary entrypoint is the interactive TUI. Run it with no arguments:
|
|
|
10
10
|
npx @plainconceptsplatform/workflows
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
The TUI lists all routes and templates with install status. Arrow keys navigate, space toggles, Enter installs. Selecting
|
|
13
|
+
The TUI lists all routes and templates with install status. Arrow keys navigate, space toggles, Enter installs. Selecting routes installs only those route workers plus mandatory files (opencode.ci.json, compile script, shared imports, actions, router, classifier, route matrix). Selecting only templates installs just those templates.
|
|
14
14
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
@@ -35,7 +35,18 @@ pnpm exec workflows add
|
|
|
35
35
|
|
|
36
36
|
`init` inspects the repository and reports its stack and visibility. It does not create or manage repository configuration or a manifest.
|
|
37
37
|
|
|
38
|
-
`add` installs
|
|
38
|
+
`add` installs mandatory files (opencode.ci.json, compile script, shared imports, actions, router, classifier, route matrix) when called with no route arguments. Pass route names as positional arguments to install specific route workers alongside the mandatory files:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
workflows add # mandatory files only, no worker .md files
|
|
42
|
+
workflows add implement refine direct # those route workers plus mandatory files
|
|
43
|
+
workflows add --template agentics-checks # named template only (no mandatory files)
|
|
44
|
+
workflows add refine --template agentics-checks --force # routes + template + mandatory, overwriting conflicts
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Route names: refine, implement, direct, apply-review, merge-gate, audit, propose. Unknown arguments produce an error.
|
|
48
|
+
|
|
49
|
+
Use `workflows update --force` to force-overwrite managed files that differ from the package source.
|
|
39
50
|
|
|
40
51
|
Install optional standalone templates with `add --template`. Available templates are `agentics-checks`, `agentics-maintenance`, `app-ci-dotnet-next`, and `app-ci-node-monorepo`. CI templates are stack-specific copies, not a combined template. Edit their top-level `env:` values for repository paths, package names, and commands.
|
|
41
52
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type TemplateName } from "./workflow-catalog.js";
|
|
1
|
+
import { type RouteName, type TemplateName } from "./workflow-catalog.js";
|
|
2
|
+
import type { RepositoryInspection } from "./repository-inspection.js";
|
|
2
3
|
export interface CatalogInstallResult {
|
|
3
4
|
readonly installed: readonly string[];
|
|
4
5
|
readonly conflicts: readonly string[];
|
|
@@ -6,6 +7,8 @@ export interface CatalogInstallResult {
|
|
|
6
7
|
export interface CatalogInstallOptions {
|
|
7
8
|
readonly force?: boolean;
|
|
8
9
|
readonly sourcePath?: string;
|
|
10
|
+
readonly selectedRoutes?: readonly RouteName[];
|
|
11
|
+
readonly inspection?: RepositoryInspection;
|
|
9
12
|
}
|
|
10
13
|
interface CatalogFile {
|
|
11
14
|
readonly source: string;
|
|
@@ -18,4 +21,7 @@ export declare function installCatalog(repositoryPath: string, options?: Catalog
|
|
|
18
21
|
export declare function installTemplate(repositoryPath: string, template: TemplateName, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
|
|
19
22
|
export declare function installMandatoryFiles(repositoryPath: string, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
|
|
20
23
|
export declare function isTemplateName(value: string): value is TemplateName;
|
|
24
|
+
export declare function ensurePreCommitHook(repositoryPath: string): Promise<void>;
|
|
25
|
+
export declare function runCompileIfAvailable(repositoryPath: string): Promise<void>;
|
|
26
|
+
export declare function exists(path: string): Promise<boolean>;
|
|
21
27
|
export {};
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { access, copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
2
3
|
import { constants } from "node:fs";
|
|
3
4
|
import { dirname, join, relative, resolve } from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import {
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { catalogTemplates, mandatoryFiles, routeNames, templateNames } from "./workflow-catalog.js";
|
|
8
|
+
import { processRoutes, excludedWorkerFiles } from "./route-processing.js";
|
|
9
|
+
import { generateOpencodeCi, generateOpencodeConfig, generateStackDefaults, injectStackEnv } from "./stack-defaults.js";
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
6
11
|
const sourceMappings = [
|
|
7
12
|
["actions", ".github/actions"],
|
|
8
13
|
["workflows", ".github/workflows"],
|
|
@@ -20,23 +25,68 @@ export function catalogSourcePath(modulePath = fileURLToPath(import.meta.url)) {
|
|
|
20
25
|
}
|
|
21
26
|
export async function installCatalog(repositoryPath, options = {}) {
|
|
22
27
|
const sourcePath = options.sourcePath ?? catalogSourcePath();
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
28
|
+
const selectedRoutes = options.selectedRoutes ?? routeNames;
|
|
29
|
+
const allFiles = [...await catalogFiles(sourcePath), ...mandatoryFileSpecs(sourcePath)];
|
|
30
|
+
const deduplicated = allFiles.filter((file, index) => allFiles.findIndex((f) => f.target === file.target) === index).sort((left, right) => left.target.localeCompare(right.target));
|
|
31
|
+
const excluded = excludedWorkerFiles(selectedRoutes);
|
|
32
|
+
const filtered = deduplicated.filter((file) => {
|
|
33
|
+
const fileName = file.target.split("/").pop() ?? "";
|
|
34
|
+
return !excluded.has(fileName);
|
|
35
|
+
});
|
|
36
|
+
const managedFiles = filtered.filter((file) => file.managed);
|
|
26
37
|
const conflicts = (await Promise.all(managedFiles.map(async (file) => {
|
|
27
38
|
const destination = join(repositoryPath, file.target);
|
|
28
39
|
return await exists(destination) && !(await filesMatch(file.source, destination)) ? file.target : undefined;
|
|
29
40
|
}))).filter((file) => file !== undefined);
|
|
30
41
|
if (conflicts.length > 0 && !options.force)
|
|
31
42
|
return { installed: [], conflicts };
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
43
|
+
const fileContents = new Map();
|
|
44
|
+
for (const file of filtered) {
|
|
45
|
+
fileContents.set(file.target, await readFile(file.source, "utf8"));
|
|
46
|
+
}
|
|
47
|
+
let processedContents = processRoutes(fileContents, selectedRoutes);
|
|
48
|
+
if (options.inspection !== undefined) {
|
|
49
|
+
const defaults = generateStackDefaults(options.inspection);
|
|
50
|
+
processedContents = injectStackIntoWorkers(processedContents, defaults);
|
|
51
|
+
processedContents = transformOpencodeFiles(processedContents, options.inspection);
|
|
52
|
+
}
|
|
53
|
+
await Promise.all([...processedContents.entries()].map(async ([target, content]) => {
|
|
54
|
+
const destination = join(repositoryPath, target);
|
|
55
|
+
const originalFile = filtered.find((f) => f.target === target);
|
|
56
|
+
if (originalFile !== undefined && !originalFile.managed && await exists(destination))
|
|
35
57
|
return;
|
|
36
58
|
await mkdir(dirname(destination), { recursive: true });
|
|
37
|
-
await
|
|
59
|
+
await writeFile(destination, content, "utf8");
|
|
38
60
|
}));
|
|
39
|
-
|
|
61
|
+
await ensurePreCommitHook(repositoryPath);
|
|
62
|
+
try {
|
|
63
|
+
await runCompileIfAvailable(repositoryPath);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// compile failure is non-fatal
|
|
67
|
+
}
|
|
68
|
+
return { installed: [...processedContents.keys()].sort(), conflicts };
|
|
69
|
+
}
|
|
70
|
+
function injectStackIntoWorkers(files, defaults) {
|
|
71
|
+
const result = new Map(files);
|
|
72
|
+
for (const [key, content] of result) {
|
|
73
|
+
if (key.startsWith(".github/workflows/agent-") && key.endsWith(".md")) {
|
|
74
|
+
result.set(key, injectStackEnv(content, defaults));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
function transformOpencodeFiles(files, inspection) {
|
|
80
|
+
const result = new Map(files);
|
|
81
|
+
for (const [key, content] of result) {
|
|
82
|
+
if (key.endsWith("opencode-ci.md")) {
|
|
83
|
+
result.set(key, generateOpencodeCi(content, inspection));
|
|
84
|
+
}
|
|
85
|
+
else if (key === "opencode.ci.json") {
|
|
86
|
+
result.set(key, generateOpencodeConfig(content, inspection));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
40
90
|
}
|
|
41
91
|
export async function installTemplate(repositoryPath, template, options = {}) {
|
|
42
92
|
const sourcePath = options.sourcePath ?? catalogSourcePath();
|
|
@@ -49,6 +99,17 @@ export async function installTemplate(repositoryPath, template, options = {}) {
|
|
|
49
99
|
return { installed: [], conflicts };
|
|
50
100
|
await mkdir(dirname(destination), { recursive: true });
|
|
51
101
|
await copyFile(source, destination);
|
|
102
|
+
if (options.inspection !== undefined && template === "opencode.ci.json") {
|
|
103
|
+
const baseContent = await readFile(source, "utf8");
|
|
104
|
+
const transformed = generateOpencodeConfig(baseContent, options.inspection);
|
|
105
|
+
await writeFile(destination, transformed, "utf8");
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await runCompileIfAvailable(repositoryPath);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// compile failure is non-fatal
|
|
112
|
+
}
|
|
52
113
|
return { installed: [target], conflicts };
|
|
53
114
|
}
|
|
54
115
|
export async function installMandatoryFiles(repositoryPath, options = {}) {
|
|
@@ -70,13 +131,34 @@ export async function installMandatoryFiles(repositoryPath, options = {}) {
|
|
|
70
131
|
export function isTemplateName(value) {
|
|
71
132
|
return templateNames.includes(value);
|
|
72
133
|
}
|
|
134
|
+
export async function ensurePreCommitHook(repositoryPath) {
|
|
135
|
+
const hookPath = join(repositoryPath, ".husky", "pre-commit");
|
|
136
|
+
if (!await exists(hookPath))
|
|
137
|
+
return;
|
|
138
|
+
const content = await readFile(hookPath, "utf8");
|
|
139
|
+
const compileLine = "node scripts/compile-agent-workflows.mjs";
|
|
140
|
+
if (content.includes("compile-agent-workflows"))
|
|
141
|
+
return;
|
|
142
|
+
const newContent = content.endsWith("\n") || content === ""
|
|
143
|
+
? `${content}${compileLine}\n`
|
|
144
|
+
: `${content}\n${compileLine}\n`;
|
|
145
|
+
await writeFile(hookPath, newContent, "utf8");
|
|
146
|
+
}
|
|
147
|
+
export async function runCompileIfAvailable(repositoryPath) {
|
|
148
|
+
const script = join(repositoryPath, "scripts", "compile-agent-workflows.mjs");
|
|
149
|
+
if (await exists(script)) {
|
|
150
|
+
await execFileAsync("node", [script, "--force"], { cwd: repositoryPath });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
73
153
|
function catalogTemplateMeta(template) {
|
|
74
154
|
const entry = catalogTemplates.find((item) => item.name === template);
|
|
75
155
|
if (entry === undefined)
|
|
76
156
|
throw new Error(`Unknown template: ${template}`);
|
|
77
157
|
const directory = template.startsWith("opencode") ? "opencode" : template.startsWith("app-ci-") ? "ci" : "agentics";
|
|
78
158
|
const isWorkflow = entry.file.endsWith(".yml");
|
|
79
|
-
const target =
|
|
159
|
+
const target = template === "app-ci-dotnet-next"
|
|
160
|
+
? ".github/workflows/app-ci.yml"
|
|
161
|
+
: isWorkflow ? `.github/workflows/${entry.file}` : entry.file;
|
|
80
162
|
return { directory, file: entry.file, target };
|
|
81
163
|
}
|
|
82
164
|
async function catalogFiles(sourcePath) {
|
|
@@ -113,7 +195,7 @@ async function filesMatch(source, destination) {
|
|
|
113
195
|
return false;
|
|
114
196
|
}
|
|
115
197
|
}
|
|
116
|
-
async function exists(path) {
|
|
198
|
+
export async function exists(path) {
|
|
117
199
|
try {
|
|
118
200
|
await access(path, constants.F_OK);
|
|
119
201
|
return true;
|
|
@@ -15,6 +15,34 @@ describe("catalog installation", () => {
|
|
|
15
15
|
});
|
|
16
16
|
expect(catalogSourcePath(join(packageDirectory, "dist", "catalog-installation.js"))).toBe(join(packageDirectory, "loops"));
|
|
17
17
|
});
|
|
18
|
+
it("installCatalog with empty selectedRoutes installs mandatory and infrastructure files but no worker .md files", async () => {
|
|
19
|
+
const sourcePath = await createDirectory({
|
|
20
|
+
"actions/check/action.yml": "name: Check\n",
|
|
21
|
+
"workflows/agent-refine.md": "# Refine\n",
|
|
22
|
+
"workflows/agent-implement.md": "# Implement\n",
|
|
23
|
+
"workflows/agent-direct.md": "# Direct\n",
|
|
24
|
+
"workflows/agent-audit.md": "# Audit\n",
|
|
25
|
+
"workflows/agent-propose.md": "# Propose\n",
|
|
26
|
+
"workflows/agent-apply-review.md": "# Apply Review\n",
|
|
27
|
+
"workflows/agent-merge-gate.md": "# Merge Gate\n",
|
|
28
|
+
"workflows/shared/defaults.md": "defaults\n",
|
|
29
|
+
"workflows/work-router.yml": "name: Router\n",
|
|
30
|
+
"scripts/compile-agent-workflows.mjs": "compile\n",
|
|
31
|
+
"templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
|
|
32
|
+
});
|
|
33
|
+
const repositoryPath = await createDirectory({});
|
|
34
|
+
const result = await installCatalog(repositoryPath, { sourcePath, selectedRoutes: [] });
|
|
35
|
+
expect(result.installed).toContain("opencode.ci.json");
|
|
36
|
+
expect(result.installed).toContain("scripts/compile-agent-workflows.mjs");
|
|
37
|
+
expect(result.installed).toContain(".github/actions/check/action.yml");
|
|
38
|
+
expect(result.installed.some((f) => f.endsWith("shared/defaults.md"))).toBe(true);
|
|
39
|
+
expect(result.installed.some((f) => f.endsWith("work-router.yml"))).toBe(true);
|
|
40
|
+
// No worker .md files
|
|
41
|
+
expect(result.installed).not.toContain(".github/workflows/agent-refine.md");
|
|
42
|
+
expect(result.installed).not.toContain(".github/workflows/agent-implement.md");
|
|
43
|
+
expect(result.installed).not.toContain(".github/workflows/agent-audit.md");
|
|
44
|
+
expect(result.installed).not.toContain(".github/workflows/agent-propose.md");
|
|
45
|
+
});
|
|
18
46
|
it("installs package-owned loops files including mandatory opencode.ci.json and compile script", async () => {
|
|
19
47
|
const sourcePath = await createDirectory({
|
|
20
48
|
"actions/check/action.yml": "name: Check\n",
|
|
@@ -226,6 +254,17 @@ describe("catalog installation", () => {
|
|
|
226
254
|
});
|
|
227
255
|
await expect(readFile(join(repositoryPath, "opencode.ci.json"), "utf8")).resolves.toBe("{ \"model\": \"plainconcepts/glm-5-2\" }\n");
|
|
228
256
|
});
|
|
257
|
+
it("installs the .NET and Next.js CI template as app-ci.yml", async () => {
|
|
258
|
+
const sourcePath = await createDirectory({
|
|
259
|
+
"templates/ci/app-ci-dotnet-next.yml": "name: App: CI\n",
|
|
260
|
+
});
|
|
261
|
+
const repositoryPath = await createDirectory({});
|
|
262
|
+
await expect(installTemplate(repositoryPath, "app-ci-dotnet-next", { sourcePath })).resolves.toEqual({
|
|
263
|
+
installed: [".github/workflows/app-ci.yml"],
|
|
264
|
+
conflicts: [],
|
|
265
|
+
});
|
|
266
|
+
await expect(readFile(join(repositoryPath, ".github/workflows/app-ci.yml"), "utf8")).resolves.toBe("name: App: CI\n");
|
|
267
|
+
});
|
|
229
268
|
it("requires force to replace the opencode.ci.json template", async () => {
|
|
230
269
|
const sourcePath = await createDirectory({
|
|
231
270
|
"templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { inspectRepository, parseVisibility, resolveVisibility } from "./repository-inspection.js";
|
|
3
3
|
import { installCatalog, installTemplate, isTemplateName } from "./catalog-installation.js";
|
|
4
4
|
import { formatCatalog, listCatalog, searchCatalog } from "./catalog-listing.js";
|
|
5
|
+
import { routeNames } from "./workflow-catalog.js";
|
|
5
6
|
import { runInteractive } from "./tui.js";
|
|
6
7
|
import { resolve } from "node:path";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
@@ -19,15 +20,25 @@ Usage: workflows <command> [options]
|
|
|
19
20
|
Commands:
|
|
20
21
|
(default) Launch the interactive TUI for selecting and installing items.
|
|
21
22
|
init Inspect the repository and report its stack and visibility.
|
|
22
|
-
add
|
|
23
|
-
update Alias for add.
|
|
23
|
+
add [routes] [--template <name>] [--force] Install route workers, a template, or mandatory files.
|
|
24
|
+
update Alias for add.
|
|
24
25
|
status Print repository inspection as JSON.
|
|
25
26
|
list List all available workflows and templates with install status.
|
|
26
27
|
search <query> Filter workflows and templates by name or description.
|
|
27
28
|
|
|
29
|
+
Route names (positional arguments to add):
|
|
30
|
+
refine, implement, direct, apply-review, merge-gate, audit, propose
|
|
31
|
+
|
|
32
|
+
add Mandatory files only (opencode.ci.json, compile script,
|
|
33
|
+
shared imports, actions, router, classifier, route matrix).
|
|
34
|
+
add implement refine direct Installs those route workers plus mandatory files.
|
|
35
|
+
add --template agentics-checks Installs the named template only (no mandatory files).
|
|
36
|
+
add refine --template agentics-checks Installs routes + mandatory + the named template.
|
|
37
|
+
add refine implement --force Forces re-install of routes plus mandatory, overwriting.
|
|
38
|
+
|
|
28
39
|
Options:
|
|
29
40
|
--visibility public|private Override repository visibility (init only).
|
|
30
|
-
--template <name> Install a standalone template instead of
|
|
41
|
+
--template <name> Install a standalone template alongside or instead of routes.
|
|
31
42
|
Templates: agentics-checks, agentics-maintenance,
|
|
32
43
|
app-ci-dotnet-next, app-ci-node-monorepo,
|
|
33
44
|
opencode.ci.json.
|
|
@@ -74,18 +85,35 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
|
|
|
74
85
|
return 0;
|
|
75
86
|
}
|
|
76
87
|
if (command === "add" || command === "update") {
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
79
|
-
return fail(
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
const parsed = parseAddOptions(options);
|
|
89
|
+
if (parsed.kind === "invalid")
|
|
90
|
+
return fail(parsed.message);
|
|
91
|
+
const inspection = await inspectRepository(repositoryPath);
|
|
92
|
+
const routes = parsed.routes;
|
|
93
|
+
const template = parsed.template;
|
|
94
|
+
const force = parsed.force;
|
|
95
|
+
const allConflicts = [];
|
|
96
|
+
const allInstalled = [];
|
|
97
|
+
if (routes.length > 0) {
|
|
98
|
+
const result = await installCatalog(repositoryPath, { force, selectedRoutes: routes, inspection });
|
|
99
|
+
allConflicts.push(...result.conflicts);
|
|
100
|
+
allInstalled.push(...result.installed);
|
|
101
|
+
}
|
|
102
|
+
else if (template === undefined) {
|
|
103
|
+
const result = await installCatalog(repositoryPath, { force, selectedRoutes: [], inspection });
|
|
104
|
+
allConflicts.push(...result.conflicts);
|
|
105
|
+
allInstalled.push(...result.installed);
|
|
106
|
+
}
|
|
107
|
+
if (template !== undefined) {
|
|
108
|
+
const result = await installTemplate(repositoryPath, template, { force, inspection });
|
|
109
|
+
allConflicts.push(...result.conflicts);
|
|
110
|
+
allInstalled.push(...result.installed);
|
|
111
|
+
}
|
|
112
|
+
if (allConflicts.length > 0 && !force) {
|
|
113
|
+
console.error(`Catalog conflicts found. Re-run with --force to overwrite package-managed files:\n${allConflicts.join("\n")}`);
|
|
86
114
|
return 1;
|
|
87
115
|
}
|
|
88
|
-
console.log(JSON.stringify({ command,
|
|
116
|
+
console.log(JSON.stringify({ command, installed: allInstalled.sort(), conflicts: allConflicts }, null, 2));
|
|
89
117
|
return 0;
|
|
90
118
|
}
|
|
91
119
|
return fail(`Unknown command: ${command}`);
|
|
@@ -97,18 +125,50 @@ function readVisibilityOption(options) {
|
|
|
97
125
|
return "invalid";
|
|
98
126
|
return parseVisibility(options[1]) ?? "invalid";
|
|
99
127
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
128
|
+
const TEMPLATE_NAMES = "agentics-checks|agentics-maintenance|app-ci-dotnet-next|app-ci-node-monorepo|opencode.ci.json";
|
|
129
|
+
function parseAddOptions(options) {
|
|
130
|
+
const routes = [];
|
|
131
|
+
let template;
|
|
132
|
+
let templateSeen = false;
|
|
133
|
+
let force = false;
|
|
134
|
+
let i = 0;
|
|
135
|
+
while (i < options.length) {
|
|
136
|
+
const token = options[i];
|
|
137
|
+
if (token === "--force") {
|
|
138
|
+
force = true;
|
|
139
|
+
i++;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (token === "--template") {
|
|
143
|
+
if (templateSeen)
|
|
144
|
+
return invalid("--template can only be specified once.");
|
|
145
|
+
templateSeen = true;
|
|
146
|
+
if (i + 1 >= options.length)
|
|
147
|
+
return invalid("--template requires a name.");
|
|
148
|
+
const name = options[i + 1];
|
|
149
|
+
if (!isTemplateName(name))
|
|
150
|
+
return invalid(`--template must be one of: ${TEMPLATE_NAMES}.`);
|
|
151
|
+
template = name;
|
|
152
|
+
i += 2;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (token.startsWith("--")) {
|
|
156
|
+
return invalid(`Unknown option: ${token}`);
|
|
157
|
+
}
|
|
158
|
+
if (routeNames.includes(token)) {
|
|
159
|
+
const route = token;
|
|
160
|
+
if (routes.includes(route))
|
|
161
|
+
return invalid(`Duplicate route: ${route}.`);
|
|
162
|
+
routes.push(route);
|
|
163
|
+
i++;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
return invalid(`Unknown route: ${token}. Valid routes: ${routeNames.join(", ")}.`);
|
|
167
|
+
}
|
|
168
|
+
return { kind: "ok", routes, template, force };
|
|
109
169
|
}
|
|
110
|
-
function
|
|
111
|
-
return
|
|
170
|
+
function invalid(message) {
|
|
171
|
+
return { kind: "invalid", message };
|
|
112
172
|
}
|
|
113
173
|
function fail(message) {
|
|
114
174
|
console.error(message);
|
package/dist/index.test.js
CHANGED
|
@@ -3,10 +3,17 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { run } from "./index.js";
|
|
6
|
+
import * as catalogInstallation from "./catalog-installation.js";
|
|
6
7
|
const temporaryDirectories = [];
|
|
7
8
|
afterEach(async () => {
|
|
8
9
|
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })));
|
|
10
|
+
vi.restoreAllMocks();
|
|
9
11
|
});
|
|
12
|
+
function mockInstallers() {
|
|
13
|
+
const installCatalog = vi.spyOn(catalogInstallation, "installCatalog").mockResolvedValue({ installed: [], conflicts: [] });
|
|
14
|
+
const installTemplate = vi.spyOn(catalogInstallation, "installTemplate").mockResolvedValue({ installed: [], conflicts: [] });
|
|
15
|
+
return { installCatalog, installTemplate };
|
|
16
|
+
}
|
|
10
17
|
describe("workflows CLI", () => {
|
|
11
18
|
it("prints template names in help", async () => {
|
|
12
19
|
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
@@ -16,10 +23,30 @@ describe("workflows CLI", () => {
|
|
|
16
23
|
expect(log).toHaveBeenCalledWith(expect.stringContaining("opencode.ci.json"));
|
|
17
24
|
log.mockRestore();
|
|
18
25
|
});
|
|
19
|
-
it("
|
|
26
|
+
it("prints route names as positional arguments in help", async () => {
|
|
27
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
28
|
+
await expect(run(["--help"])).resolves.toBe(0);
|
|
29
|
+
const output = log.mock.calls[0][0];
|
|
30
|
+
expect(output).toContain("refine, implement, direct, apply-review, merge-gate, audit, propose");
|
|
31
|
+
expect(output).toContain("add [routes]");
|
|
32
|
+
log.mockRestore();
|
|
33
|
+
});
|
|
34
|
+
it("rejects an unsupported template name", async () => {
|
|
20
35
|
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
21
36
|
await expect(run(["add", "--template", "unknown"])).resolves.toBe(1);
|
|
22
|
-
expect(error).toHaveBeenCalledWith("
|
|
37
|
+
expect(error).toHaveBeenCalledWith("--template must be one of: agentics-checks|agentics-maintenance|app-ci-dotnet-next|app-ci-node-monorepo|opencode.ci.json.");
|
|
38
|
+
error.mockRestore();
|
|
39
|
+
});
|
|
40
|
+
it("rejects an unknown route passed to add", async () => {
|
|
41
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
42
|
+
await expect(run(["add", "frobnicate"])).resolves.toBe(1);
|
|
43
|
+
expect(error).toHaveBeenCalledWith("Unknown route: frobnicate. Valid routes: refine, implement, direct, apply-review, merge-gate, audit, propose.");
|
|
44
|
+
error.mockRestore();
|
|
45
|
+
});
|
|
46
|
+
it("rejects a duplicate route", async () => {
|
|
47
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
48
|
+
await expect(run(["add", "refine", "refine"])).resolves.toBe(1);
|
|
49
|
+
expect(error).toHaveBeenCalledWith("Duplicate route: refine.");
|
|
23
50
|
error.mockRestore();
|
|
24
51
|
});
|
|
25
52
|
it("lists all workflows and templates with install status [ ] when none installed", async () => {
|
|
@@ -98,6 +125,99 @@ describe("workflows CLI", () => {
|
|
|
98
125
|
expect(error).toHaveBeenCalledWith("search requires exactly one query argument.");
|
|
99
126
|
error.mockRestore();
|
|
100
127
|
});
|
|
128
|
+
it("add with no routes and no template calls installCatalog with empty selectedRoutes", async () => {
|
|
129
|
+
const { installCatalog, installTemplate } = mockInstallers();
|
|
130
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
131
|
+
await expect(run(["add"])).resolves.toBe(0);
|
|
132
|
+
expect(installCatalog).toHaveBeenCalledOnce();
|
|
133
|
+
expect(installCatalog).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ selectedRoutes: [] }));
|
|
134
|
+
expect(installTemplate).not.toHaveBeenCalled();
|
|
135
|
+
log.mockRestore();
|
|
136
|
+
});
|
|
137
|
+
it("add with explicit routes passes those routes to installCatalog", async () => {
|
|
138
|
+
const { installCatalog, installTemplate } = mockInstallers();
|
|
139
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
140
|
+
await expect(run(["add", "implement", "refine", "direct"])).resolves.toBe(0);
|
|
141
|
+
expect(installCatalog).toHaveBeenCalledOnce();
|
|
142
|
+
expect(installCatalog).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ selectedRoutes: ["implement", "refine", "direct"] }));
|
|
143
|
+
expect(installTemplate).not.toHaveBeenCalled();
|
|
144
|
+
log.mockRestore();
|
|
145
|
+
});
|
|
146
|
+
it("add --force passes force flag to installCatalog", async () => {
|
|
147
|
+
const { installCatalog } = mockInstallers();
|
|
148
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
149
|
+
await expect(run(["add", "--force"])).resolves.toBe(0);
|
|
150
|
+
expect(installCatalog).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ force: true, selectedRoutes: [] }));
|
|
151
|
+
log.mockRestore();
|
|
152
|
+
});
|
|
153
|
+
it("add with routes and --force passes both", async () => {
|
|
154
|
+
const { installCatalog } = mockInstallers();
|
|
155
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
156
|
+
await expect(run(["add", "refine", "implement", "--force"])).resolves.toBe(0);
|
|
157
|
+
expect(installCatalog).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ force: true, selectedRoutes: ["refine", "implement"] }));
|
|
158
|
+
log.mockRestore();
|
|
159
|
+
});
|
|
160
|
+
it("add with routes and template calls both installCatalog and installTemplate", async () => {
|
|
161
|
+
const { installCatalog, installTemplate } = mockInstallers();
|
|
162
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
163
|
+
await expect(run(["add", "refine", "--template", "agentics-checks"])).resolves.toBe(0);
|
|
164
|
+
expect(installCatalog).toHaveBeenCalledOnce();
|
|
165
|
+
expect(installCatalog).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ selectedRoutes: ["refine"] }));
|
|
166
|
+
expect(installTemplate).toHaveBeenCalledOnce();
|
|
167
|
+
expect(installTemplate).toHaveBeenCalledWith(expect.any(String), "agentics-checks", expect.any(Object));
|
|
168
|
+
log.mockRestore();
|
|
169
|
+
});
|
|
170
|
+
it("add with template only calls installTemplate but not installCatalog", async () => {
|
|
171
|
+
const { installCatalog, installTemplate } = mockInstallers();
|
|
172
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
173
|
+
await expect(run(["add", "--template", "agentics-checks"])).resolves.toBe(0);
|
|
174
|
+
expect(installCatalog).not.toHaveBeenCalled();
|
|
175
|
+
expect(installTemplate).toHaveBeenCalledOnce();
|
|
176
|
+
expect(installTemplate).toHaveBeenCalledWith(expect.any(String), "agentics-checks", expect.any(Object));
|
|
177
|
+
log.mockRestore();
|
|
178
|
+
});
|
|
179
|
+
it("add with routes and template and force passes all options", async () => {
|
|
180
|
+
const { installCatalog, installTemplate } = mockInstallers();
|
|
181
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
182
|
+
await expect(run(["add", "refine", "implement", "--template", "agentics-checks", "--force"])).resolves.toBe(0);
|
|
183
|
+
expect(installCatalog).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ force: true, selectedRoutes: ["refine", "implement"] }));
|
|
184
|
+
expect(installTemplate).toHaveBeenCalledWith(expect.any(String), "agentics-checks", expect.objectContaining({ force: true }));
|
|
185
|
+
log.mockRestore();
|
|
186
|
+
});
|
|
187
|
+
it("add with conflict and no force exits 1", async () => {
|
|
188
|
+
const { installCatalog } = mockInstallers();
|
|
189
|
+
installCatalog.mockResolvedValue({ installed: [], conflicts: ["opencode.ci.json"] });
|
|
190
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
191
|
+
await expect(run(["add"])).resolves.toBe(1);
|
|
192
|
+
expect(error).toHaveBeenCalledWith(expect.stringContaining("Catalog conflicts found"));
|
|
193
|
+
error.mockRestore();
|
|
194
|
+
});
|
|
195
|
+
it("add with conflict and --force still succeeds", async () => {
|
|
196
|
+
const { installCatalog } = mockInstallers();
|
|
197
|
+
installCatalog.mockResolvedValue({ installed: ["opencode.ci.json"], conflicts: ["opencode.ci.json"] });
|
|
198
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
199
|
+
await expect(run(["add", "--force"])).resolves.toBe(0);
|
|
200
|
+
expect(log).toHaveBeenCalled();
|
|
201
|
+
log.mockRestore();
|
|
202
|
+
});
|
|
203
|
+
it("add with --template but missing name produces an error", async () => {
|
|
204
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
205
|
+
await expect(run(["add", "--template"])).resolves.toBe(1);
|
|
206
|
+
expect(error).toHaveBeenCalledWith("--template requires a name.");
|
|
207
|
+
error.mockRestore();
|
|
208
|
+
});
|
|
209
|
+
it("add with multiple --template flags produces an error", async () => {
|
|
210
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
211
|
+
await expect(run(["add", "--template", "agentics-checks", "--template", "agentics-maintenance"])).resolves.toBe(1);
|
|
212
|
+
expect(error).toHaveBeenCalledWith("--template can only be specified once.");
|
|
213
|
+
error.mockRestore();
|
|
214
|
+
});
|
|
215
|
+
it("add with unknown flag produces an error", async () => {
|
|
216
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
217
|
+
await expect(run(["add", "--unknown"])).resolves.toBe(1);
|
|
218
|
+
expect(error).toHaveBeenCalledWith("Unknown option: --unknown");
|
|
219
|
+
error.mockRestore();
|
|
220
|
+
});
|
|
101
221
|
});
|
|
102
222
|
async function createRepository(files) {
|
|
103
223
|
const repositoryPath = await mkdtemp(join(tmpdir(), "workflows-"));
|
|
@@ -54,7 +54,7 @@ describe("CLI commands", () => {
|
|
|
54
54
|
const repositoryPath = await createRepository({});
|
|
55
55
|
const error = captureConsole("error");
|
|
56
56
|
await expect(run(["update", "--invalid"], repositoryPath)).resolves.toBe(1);
|
|
57
|
-
expect(error.calls).toEqual(["
|
|
57
|
+
expect(error.calls).toEqual(["Unknown option: --invalid"]);
|
|
58
58
|
error.restore();
|
|
59
59
|
});
|
|
60
60
|
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type RouteName } from "./workflow-catalog.js";
|
|
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
|
+
export declare function processRoutes(files: Map<string, string>, selectedRoutes: readonly RouteName[]): Map<string, string>;
|
|
6
|
+
export declare function excludedWorkerFiles(selectedRoutes: readonly RouteName[]): Set<string>;
|