@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/README.md
CHANGED
|
@@ -1,88 +1,105 @@
|
|
|
1
|
-
# Workflows CLI
|
|
2
|
-
|
|
3
|
-
Install and update shared GitHub Agentic Workflows for Plain Concepts Platform repositories.
|
|
4
|
-
|
|
5
|
-
## Quick start
|
|
6
|
-
|
|
7
|
-
The primary entrypoint is the interactive TUI. Run it with no arguments:
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
npx @plainconceptsplatform/workflows
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
The TUI lists all routes and templates with install status. Arrow keys navigate, space toggles, Enter installs.
|
|
14
|
-
|
|
15
|
-
## Install
|
|
16
|
-
|
|
17
|
-
Before installing workflows, install and configure [`PlainConceptsPlatform/agent-harness`](https://github.com/PlainConceptsPlatform/agent-harness) in the consumer repository. Loop workers invoke the skills and commands it provides. Verify the required skills and commands are available before compiling workflows.
|
|
18
|
-
|
|
19
|
-
For non-interactive use (advanced):
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
npx @plainconceptsplatform/workflows@latest init
|
|
23
|
-
npx @plainconceptsplatform/workflows@latest add
|
|
24
|
-
npx @plainconceptsplatform/workflows@latest update
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
pnpm
|
|
32
|
-
pnpm exec workflows
|
|
33
|
-
pnpm exec workflows
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
workflows add
|
|
43
|
-
workflows add
|
|
44
|
-
workflows add
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
1
|
+
# Workflows CLI
|
|
2
|
+
|
|
3
|
+
Install and update shared GitHub Agentic Workflows for Plain Concepts Platform repositories.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
The primary entrypoint is the interactive TUI. Run it with no arguments:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx @plainconceptsplatform/workflows
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The TUI lists all routes and templates with install status. Arrow keys navigate, space toggles, Enter installs. The checked routes are the target set: checking adds a worker, unchecking removes it, and Enter on an unchanged selection refreshes every installed package file to this version. Selecting only templates installs just those templates.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Before installing workflows, install and configure [`PlainConceptsPlatform/agent-harness`](https://github.com/PlainConceptsPlatform/agent-harness) in the consumer repository. Loop workers invoke the skills and commands it provides. Verify the required skills and commands are available before compiling workflows.
|
|
18
|
+
|
|
19
|
+
For non-interactive use (advanced):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx @plainconceptsplatform/workflows@latest init
|
|
23
|
+
npx @plainconceptsplatform/workflows@latest add refine implement
|
|
24
|
+
npx @plainconceptsplatform/workflows@latest update --dry-run
|
|
25
|
+
npx @plainconceptsplatform/workflows@latest update
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
For a project-local development dependency:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pnpm add -D @plainconceptsplatform/workflows
|
|
32
|
+
pnpm exec workflows # launch interactive TUI
|
|
33
|
+
pnpm exec workflows init
|
|
34
|
+
pnpm exec workflows add
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`init` inspects the repository and reports its stack and visibility. It does not create or manage repository configuration or a manifest.
|
|
38
|
+
|
|
39
|
+
`add` installs the mandatory files (opencode.ci.json, compile script, shared imports, actions, router, classifier, route matrix) and the workers named as positional arguments, on top of the workers already installed. With no routes it refreshes what is there:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
workflows add # refresh everything installed; in an empty repository, no workers
|
|
43
|
+
workflows add implement refine # those workers on top of the installed ones
|
|
44
|
+
workflows add --template agentics-checks # named template only (no mandatory files)
|
|
45
|
+
workflows add refine --template agentics-checks --force # routes + template + mandatory; --force also replaces a changed template
|
|
46
|
+
workflows remove audit # uninstall the audit worker and drop it from the router
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Route names: refine, implement, triage, apply-review, merge-gate, audit, release. Unknown arguments produce an error.
|
|
50
|
+
|
|
51
|
+
## Update
|
|
52
|
+
|
|
53
|
+
`update` is `add` with no routes: it refreshes exactly the installed set to this package version. Every package-managed file is replaced and its ownership header records the version it came from:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
# Managed by @plainconceptsplatform/workflows@0.7.0. Source: loops/workflows/work-router.yml. ...
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The `env:` block at the top of a worker is the repository's and survives: your values are kept, keys the package added arrive with their defaults, keys only you defined stay. When the header records the version you installed from, that release is fetched from npm and used as the merge baseline, so a value you never changed follows the package when its default changes. The agent runner pool and the engine gateway URL are kept as well. Everything else in the file is the package's.
|
|
60
|
+
|
|
61
|
+
- `update --dry-run` prints the plan as JSON and writes nothing.
|
|
62
|
+
- A file whose ownership header was removed is consumer-owned and is skipped unless `--force` is passed.
|
|
63
|
+
- Templates are consumer-owned after installation and are replaced only with `--force`.
|
|
64
|
+
- `--version` prints the package version.
|
|
65
|
+
|
|
66
|
+
## List and search
|
|
67
|
+
|
|
68
|
+
List all available workflows, routes, and templates with install status:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npx --yes --package @plainconceptsplatform/workflows@latest workflows list
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Each entry is marked `[x]` when the corresponding `.github/workflows/agent-*.md` file already exists in the current directory, or `[ ]` when it is not yet installed.
|
|
75
|
+
|
|
76
|
+
Search by name or description:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
npx --yes --package @plainconceptsplatform/workflows@latest workflows search "ci"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Templates
|
|
83
|
+
|
|
84
|
+
Install optional standalone templates with `add --template`. Available templates are `agentics-checks`, `agentics-maintenance`, `app-ci-dotnet-next`, `app-ci-node-monorepo`, `bug-report`, `feature-request`, `github-release`, and `opencode.ci.json`. CI templates are stack-specific copies, not a combined template. `github-release` publishes generated release notes when a `v*` tag is pushed. Edit their top-level `env:` values for repository paths, package names, and commands.
|
|
85
|
+
|
|
86
|
+
## Manual installation
|
|
87
|
+
|
|
88
|
+
The package includes `loops/`, a copyable equivalent of `.github/`:
|
|
89
|
+
|
|
90
|
+
- `loops/actions/` maps to `.github/actions/`
|
|
91
|
+
- `loops/workflows/` maps to `.github/workflows/`
|
|
92
|
+
- `loops/scripts/` maps to `scripts/`
|
|
93
|
+
|
|
94
|
+
Copy these files manually if you do not use the CLI. Each worker is self-contained. Edit its
|
|
95
|
+
top-level `env:` defaults directly for consumer-specific endpoint, model, labels, paths, and checks.
|
|
96
|
+
|
|
97
|
+
## Compile
|
|
98
|
+
|
|
99
|
+
Consumer repositories generate and commit `*.lock.yml` files:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
node scripts/compile-agent-workflows.mjs --force
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Do not commit generated locks to this package source repository.
|
|
@@ -1,15 +1,49 @@
|
|
|
1
1
|
import { type RouteName, type TemplateName } from "./workflow-catalog.js";
|
|
2
|
+
import { type BaselineFetcher } from "./package-baseline.js";
|
|
2
3
|
import type { RepositoryInspection } from "./repository-inspection.js";
|
|
4
|
+
export type ChangeStatus = "added" | "updated" | "unchanged" | "skipped" | "removed";
|
|
5
|
+
export interface FileChange {
|
|
6
|
+
readonly target: string;
|
|
7
|
+
readonly status: ChangeStatus;
|
|
8
|
+
/** Why a file was skipped. */
|
|
9
|
+
readonly reason?: string;
|
|
10
|
+
/** Version the consumer's copy was installed from, when its header records one. */
|
|
11
|
+
readonly installedVersion?: string;
|
|
12
|
+
readonly keptEnv?: readonly string[];
|
|
13
|
+
readonly updatedDefaults?: readonly string[];
|
|
14
|
+
readonly consumerOnlyEnv?: readonly string[];
|
|
15
|
+
readonly droppedEnv?: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
export interface BaselineStatus {
|
|
18
|
+
readonly version: string;
|
|
19
|
+
readonly status: "used" | "unavailable";
|
|
20
|
+
}
|
|
3
21
|
export interface CatalogInstallResult {
|
|
22
|
+
/** Every package-managed target in the installed set, whether it changed or not. */
|
|
4
23
|
readonly installed: readonly string[];
|
|
24
|
+
/** Consumer-owned files that differ and were not overwritten. Only templates report these. */
|
|
5
25
|
readonly conflicts: readonly string[];
|
|
26
|
+
readonly changes: readonly FileChange[];
|
|
27
|
+
readonly packageVersion: string;
|
|
28
|
+
/** Versions found in the consumer's ownership headers before this run. */
|
|
29
|
+
readonly installedVersions: readonly string[];
|
|
30
|
+
readonly baselines: readonly BaselineStatus[];
|
|
31
|
+
readonly upToDate: boolean;
|
|
32
|
+
readonly dryRun: boolean;
|
|
6
33
|
}
|
|
7
34
|
export interface CatalogInstallOptions {
|
|
35
|
+
/** Also overwrite files whose ownership header was removed, and changed templates. */
|
|
8
36
|
readonly force?: boolean;
|
|
9
37
|
readonly sourcePath?: string;
|
|
10
38
|
readonly selectedRoutes?: readonly RouteName[];
|
|
11
39
|
readonly inspection?: RepositoryInspection;
|
|
12
40
|
readonly compile?: (repositoryPath: string) => Promise<void>;
|
|
41
|
+
/** Compute the plan and write nothing. */
|
|
42
|
+
readonly dryRun?: boolean;
|
|
43
|
+
/** Where the baseline release comes from. Defaults to npm. */
|
|
44
|
+
readonly baseline?: BaselineFetcher;
|
|
45
|
+
/** The version stamped into headers. Defaults to this package's. */
|
|
46
|
+
readonly packageVersion?: string;
|
|
13
47
|
}
|
|
14
48
|
interface CatalogFile {
|
|
15
49
|
readonly source: string;
|
|
@@ -19,8 +53,11 @@ interface CatalogFile {
|
|
|
19
53
|
export declare function mandatoryFileSpecs(sourcePath: string): CatalogFile[];
|
|
20
54
|
export declare function catalogSourcePath(modulePath?: string): string;
|
|
21
55
|
export declare function installCatalog(repositoryPath: string, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
|
|
22
|
-
export
|
|
23
|
-
|
|
56
|
+
export interface TemplateInstallResult {
|
|
57
|
+
readonly installed: readonly string[];
|
|
58
|
+
readonly conflicts: readonly string[];
|
|
59
|
+
}
|
|
60
|
+
export declare function installTemplate(repositoryPath: string, template: TemplateName, options?: Pick<CatalogInstallOptions, "force" | "sourcePath" | "inspection">): Promise<TemplateInstallResult>;
|
|
24
61
|
export declare function installedRoutes(repositoryPath: string): Promise<RouteName[]>;
|
|
25
62
|
export declare function removeRouteFiles(repositoryPath: string, routes: readonly RouteName[]): Promise<string[]>;
|
|
26
63
|
export declare function isTemplateName(value: string): value is TemplateName;
|
|
@@ -8,6 +8,8 @@ import { parse as parseYaml } from "yaml";
|
|
|
8
8
|
import { catalogTemplates, mandatoryFiles, routeNames, templateNames, workflowRoutes } from "./workflow-catalog.js";
|
|
9
9
|
import { processRoutes, excludedWorkerFiles } from "./route-processing.js";
|
|
10
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";
|
|
11
13
|
const execFileAsync = promisify(execFile);
|
|
12
14
|
const sourceMappings = [
|
|
13
15
|
["actions", ".github/actions"],
|
|
@@ -24,116 +26,187 @@ export function mandatoryFileSpecs(sourcePath) {
|
|
|
24
26
|
export function catalogSourcePath(modulePath = fileURLToPath(import.meta.url)) {
|
|
25
27
|
return resolve(dirname(modulePath), "..", "loops");
|
|
26
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");
|
|
27
33
|
export async function installCatalog(repositoryPath, options = {}) {
|
|
34
|
+
const version = options.packageVersion ?? await packageVersion();
|
|
28
35
|
const sourcePath = options.sourcePath ?? catalogSourcePath();
|
|
29
36
|
const selectedRoutes = options.selectedRoutes ?? routeNames;
|
|
30
37
|
const allFiles = [...await catalogFiles(sourcePath), ...mandatoryFileSpecs(sourcePath)];
|
|
31
38
|
const deduplicated = allFiles.filter((file, index) => allFiles.findIndex((f) => f.target === file.target) === index).sort((left, right) => left.target.localeCompare(right.target));
|
|
32
39
|
const excluded = excludedWorkerFiles(selectedRoutes);
|
|
33
|
-
const filtered = deduplicated.filter((file) =>
|
|
34
|
-
|
|
35
|
-
return !excluded.has(fileName);
|
|
36
|
-
});
|
|
37
|
-
const fileContents = new Map();
|
|
40
|
+
const filtered = deduplicated.filter((file) => !excluded.has(file.target.split("/").pop() ?? ""));
|
|
41
|
+
const packageContents = new Map();
|
|
38
42
|
for (const file of filtered) {
|
|
39
|
-
|
|
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")));
|
|
40
56
|
}
|
|
41
|
-
let processedContents = processRoutes(fileContents, selectedRoutes);
|
|
42
57
|
if (options.inspection !== undefined) {
|
|
43
|
-
|
|
44
|
-
processedContents = injectStackIntoWorkers(processedContents, defaults);
|
|
45
|
-
processedContents = transformOpencodeFiles(processedContents, options.inspection);
|
|
58
|
+
processed = applyStackDefaults(processed, options.inspection, existing);
|
|
46
59
|
}
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
for (const [
|
|
61
|
-
|
|
62
|
-
|
|
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" };
|
|
63
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)]);
|
|
64
141
|
}
|
|
65
142
|
return result;
|
|
66
143
|
}
|
|
67
|
-
|
|
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);
|
|
68
150
|
const result = new Map(files);
|
|
69
151
|
for (const [target, content] of result) {
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
}
|
|
76
161
|
}
|
|
77
162
|
return result;
|
|
78
163
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
function preserveRunnerPool(packageContent, consumerContent) {
|
|
102
|
-
const mine = runnerPools(consumerContent);
|
|
103
|
-
const theirs = runnerPools(packageContent);
|
|
104
|
-
// Only an unambiguous swap. A consumer naming several pools has drifted rather than
|
|
105
|
-
// decided, and guessing which it meant is worse than leaving the package's.
|
|
106
|
-
if (mine.length !== 1 || theirs.length !== 1 || mine[0] === theirs[0])
|
|
107
|
-
return packageContent;
|
|
108
|
-
return packageContent.replace(/^(\s*runs-on(?:-slim)?: )(\S+)(\s*)$/gm, (line, prefix, pool, tail) => pool === "ubuntu-latest" ? line : `${prefix}${mine[0]}${tail}`);
|
|
109
|
-
}
|
|
110
|
-
function workerEnvValues(content) {
|
|
111
|
-
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/m.exec(content)?.[1];
|
|
112
|
-
const envBlock = frontmatter === undefined ? undefined : /^env:\r?\n((?: .*\r?\n?)*)/m.exec(frontmatter)?.[1];
|
|
113
|
-
const values = new Map();
|
|
114
|
-
if (envBlock === undefined)
|
|
115
|
-
return values;
|
|
116
|
-
for (const line of envBlock.split(/\r?\n/)) {
|
|
117
|
-
const match = /^ ([A-Z][A-Z0-9_]*): (.+)$/.exec(line);
|
|
118
|
-
if (match !== null)
|
|
119
|
-
values.set(match[1], match[2]);
|
|
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
|
+
}
|
|
120
186
|
}
|
|
121
|
-
return
|
|
187
|
+
return orphans.sort();
|
|
122
188
|
}
|
|
123
|
-
function
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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 });
|
|
131
197
|
}
|
|
132
|
-
|
|
133
|
-
|
|
198
|
+
catch {
|
|
199
|
+
// the directory is gone or not empty, either of which is fine
|
|
134
200
|
}
|
|
135
201
|
}
|
|
136
|
-
|
|
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"));
|
|
137
210
|
}
|
|
138
211
|
// Templates that need a companion config beside the workflow file. actionlint only
|
|
139
212
|
// knows GitHub-hosted runner labels, so every workflow naming a self-hosted label is an
|
|
@@ -180,19 +253,6 @@ export async function installTemplate(repositoryPath, template, options = {}) {
|
|
|
180
253
|
}
|
|
181
254
|
return { installed: (await Promise.all(destinations.map(async (entry) => await exists(entry.source) ? entry.target : undefined))).filter((file) => file !== undefined), conflicts };
|
|
182
255
|
}
|
|
183
|
-
export async function installMandatoryFiles(repositoryPath, options = {}) {
|
|
184
|
-
const sourcePath = options.sourcePath ?? catalogSourcePath();
|
|
185
|
-
const files = mandatoryFileSpecs(sourcePath).sort((left, right) => left.target.localeCompare(right.target));
|
|
186
|
-
const conflicts = (await Promise.all(files.map(async (file) => {
|
|
187
|
-
const destination = join(repositoryPath, file.target);
|
|
188
|
-
return await exists(destination) && !(await filesMatch(file.source, destination)) ? file.target : undefined;
|
|
189
|
-
}))).filter((file) => file !== undefined);
|
|
190
|
-
if (conflicts.length > 0 && !options.force)
|
|
191
|
-
return { installed: [], conflicts };
|
|
192
|
-
const updates = await Promise.all(files.map(async (file) => ({ target: file.target, content: await readFile(file.source, "utf8") })));
|
|
193
|
-
await applyTransaction(repositoryPath, [...updates, await preCommitHookUpdate(repositoryPath)]);
|
|
194
|
-
return { installed: files.map((file) => file.target), conflicts };
|
|
195
|
-
}
|
|
196
256
|
export async function installedRoutes(repositoryPath) {
|
|
197
257
|
const found = await Promise.all(workflowRoutes.map(async (route) => (await exists(join(repositoryPath, ".github", "workflows", route.worker))) ? route.name : undefined));
|
|
198
258
|
return found.filter((name) => name !== undefined);
|
|
@@ -253,15 +313,6 @@ export async function runCompileIfAvailable(repositoryPath) {
|
|
|
253
313
|
await execFileAsync("node", [script, "--force"], { cwd: repositoryPath });
|
|
254
314
|
}
|
|
255
315
|
}
|
|
256
|
-
async function conflictingTargets(repositoryPath, updates) {
|
|
257
|
-
const conflicts = (await Promise.all(updates.map(async ({ target, content }) => {
|
|
258
|
-
const destination = join(repositoryPath, target);
|
|
259
|
-
if (!await exists(destination))
|
|
260
|
-
return undefined;
|
|
261
|
-
return (await readFile(destination, "utf8")) === content ? undefined : target;
|
|
262
|
-
}))).filter((target) => target !== undefined);
|
|
263
|
-
return [...new Set(conflicts)].sort();
|
|
264
|
-
}
|
|
265
316
|
async function validateStagedCatalog(repositoryPath, updates, compileOverride) {
|
|
266
317
|
const temporaryRoot = join(repositoryPath, ".opencode", ".tmp");
|
|
267
318
|
await mkdir(temporaryRoot, { recursive: true });
|
|
@@ -280,10 +331,17 @@ async function validateStagedCatalog(repositoryPath, updates, compileOverride) {
|
|
|
280
331
|
await rm(stagingPath, { force: true, recursive: true });
|
|
281
332
|
}
|
|
282
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.
|
|
283
336
|
async function copyCompilationInputs(repositoryPath, stagingPath) {
|
|
284
337
|
const githubPath = join(repositoryPath, ".github");
|
|
285
338
|
if (await exists(githubPath))
|
|
286
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
|
+
}
|
|
287
345
|
}
|
|
288
346
|
async function initializeStagingRepository(stagingPath) {
|
|
289
347
|
await execFileAsync("git", ["init", "--quiet"], { cwd: stagingPath, windowsHide: true });
|
|
@@ -369,7 +427,10 @@ function catalogTemplateMeta(template) {
|
|
|
369
427
|
const entry = catalogTemplates.find((item) => item.name === template);
|
|
370
428
|
if (entry === undefined)
|
|
371
429
|
throw new Error(`Unknown template: ${template}`);
|
|
372
|
-
|
|
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");
|
|
373
434
|
const isWorkflow = entry.file.endsWith(".yml");
|
|
374
435
|
const inferredTarget = template === "app-ci-dotnet-next"
|
|
375
436
|
? ".github/workflows/app-ci.yml"
|
|
@@ -380,6 +441,8 @@ function catalogTemplateMeta(template) {
|
|
|
380
441
|
async function catalogFiles(sourcePath) {
|
|
381
442
|
const files = [];
|
|
382
443
|
for (const [sourceDirectory, targetDirectory] of sourceMappings) {
|
|
444
|
+
if (!await exists(join(sourcePath, sourceDirectory)))
|
|
445
|
+
continue;
|
|
383
446
|
for (const file of await filesIn(join(sourcePath, sourceDirectory))) {
|
|
384
447
|
if (isGeneratedFile(file))
|
|
385
448
|
continue;
|