@oisincoveney/pipeline 3.20.1 → 3.22.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/dist/cli/bootstrap-commands.js +1 -1
- package/dist/cli/factory-commands.js +33 -0
- package/dist/cli/program.js +2 -0
- package/dist/commands/pipeline-command.js +3 -1
- package/dist/factory/create-experiment.js +163 -0
- package/dist/factory/exec.js +17 -0
- package/dist/factory/stamp-answers.js +28 -0
- package/dist/factory/template-update.js +167 -0
- package/dist/factory-lane.d.ts +108 -0
- package/dist/factory-lane.js +101 -0
- package/dist/pipeline-init.js +17 -98
- package/package.json +5 -1
- package/dist/agent-assets.js +0 -7
- package/dist/install-hooks.js +0 -274
- package/dist/install-rules.js +0 -200
|
@@ -10,7 +10,7 @@ function registerBootstrapCommands(program) {
|
|
|
10
10
|
console.log(flags.json ? JSON.stringify(result) : formatDoctorResult(result));
|
|
11
11
|
if (!result.passed) throw new Error("Doctor checks failed.");
|
|
12
12
|
});
|
|
13
|
-
program.command("init").description("Install or refresh
|
|
13
|
+
program.command("init").description("Install or refresh moka's slash-command adapters (/moka-execute, /moka-inspect, /moka-quick) plus the singleton MCP gateway host config, globally to ~/.claude, ~/.config/opencode, ~/.codex with no repo-local config. The agent harness (skills, hooks, instruction rules) is provisioned separately from oisin-ee/agent via chezmoi, not by moka.").option("--check", "verify the installed adapters are current; fail if stale").option("--dry-run", "show planned changes without writing files").option("--force", "overwrite manually edited command adapter files").action(async (flags) => {
|
|
14
14
|
const result = await initPipelineProject({
|
|
15
15
|
...flags,
|
|
16
16
|
cwd: process.env.PIPELINE_TARGET_PATH ?? process.cwd()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { runCreateExperiment } from "../factory/create-experiment.js";
|
|
2
|
+
import { runTemplateUpdate, summarizeTemplateUpdate } from "../factory/template-update.js";
|
|
3
|
+
import { Option } from "commander";
|
|
4
|
+
//#region src/cli/factory-commands.ts
|
|
5
|
+
function registerFactoryCommands(program) {
|
|
6
|
+
program.command("create-experiment").description("Birth a fleet experiment: copier-stamp momokaya-template, create+push the org repo, register it in infra's fleet registry").requiredOption("--name <name>", "app name (kebab-case)").addOption(new Option("--flavor <flavor>", "app flavor").choices(["web", "expo-web"]).default("web")).option("--no-db", "skip the database surface").option("--no-previews", "skip per-PR preview environments").option("--org <org>", "GitHub org for the new repo").option("--template-src <source>", "copier template source").option("--template-ref <ref>", "template tag/ref (default: latest tag)").option("--infra-repo-url <url>", "infra repo the registry entry lands in").action(async (flags) => {
|
|
7
|
+
const result = await runCreateExperiment({
|
|
8
|
+
db: flags.db,
|
|
9
|
+
flavor: flags.flavor,
|
|
10
|
+
...flags.infraRepoUrl ? { infraRepoUrl: flags.infraRepoUrl } : {},
|
|
11
|
+
name: flags.name,
|
|
12
|
+
...flags.org ? { org: flags.org } : {},
|
|
13
|
+
previews: flags.previews,
|
|
14
|
+
...flags.templateRef ? { templateRef: flags.templateRef } : {},
|
|
15
|
+
...flags.templateSrc ? { templateSource: flags.templateSrc } : {}
|
|
16
|
+
});
|
|
17
|
+
console.log(`Experiment born: ${result.repoUrl} (registry ${result.registryPath} @ infra ${result.infraCommitSha})`);
|
|
18
|
+
});
|
|
19
|
+
program.command("template-update").description("Fan copier-update PRs out across repos stamped from momokaya-template").option("--repos <repos>", "comma-separated repo list (skips fleet-registry discovery)").option("--org <org>", "GitHub org the stamped repos live in").option("--template-match <substring>", "answers-file _src_path filter for stamp detection").option("--template-ref <ref>", "template tag/ref (default: latest tag)").option("--infra-repo-url <url>", "infra repo used for discovery").action(async (flags) => {
|
|
20
|
+
const { results } = await runTemplateUpdate({
|
|
21
|
+
...flags.infraRepoUrl ? { infraRepoUrl: flags.infraRepoUrl } : {},
|
|
22
|
+
...flags.org ? { org: flags.org } : {},
|
|
23
|
+
...flags.repos ? { repos: flags.repos.split(",").map((repo) => repo.trim()).filter((repo) => repo.length > 0) } : {},
|
|
24
|
+
...flags.templateMatch ? { templateMatch: flags.templateMatch } : {},
|
|
25
|
+
...flags.templateRef ? { templateRef: flags.templateRef } : {}
|
|
26
|
+
});
|
|
27
|
+
const { failed, opened } = summarizeTemplateUpdate(results);
|
|
28
|
+
console.log(`template-update: ${opened} PR(s) opened, ${failed} error(s)`);
|
|
29
|
+
if (failed > 0) process.exitCode = 1;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
export { registerFactoryCommands };
|
package/dist/cli/program.js
CHANGED
|
@@ -7,6 +7,7 @@ import { registerTicketCommand } from "../commands/ticket-command.js";
|
|
|
7
7
|
import { addMokaSubmitOptions, runMokaSubmitFromCli } from "./submit-options.js";
|
|
8
8
|
import { registerRunControlCommands } from "../run-control/commands.js";
|
|
9
9
|
import { registerBootstrapCommands } from "./bootstrap-commands.js";
|
|
10
|
+
import { registerFactoryCommands } from "./factory-commands.js";
|
|
10
11
|
import { registerLoopCommand } from "./loop-commands.js";
|
|
11
12
|
import { registerMcpGatewayCommands } from "./mcp-gateway-commands.js";
|
|
12
13
|
import { registerPlanCommands } from "./plan-commands.js";
|
|
@@ -34,6 +35,7 @@ function registerApplicationCommands(program, options) {
|
|
|
34
35
|
registerMcpGatewayCommands(program);
|
|
35
36
|
registerSubmitCommand(program);
|
|
36
37
|
registerLoopCommand(program);
|
|
38
|
+
registerFactoryCommands(program);
|
|
37
39
|
registerRunnerCommandCommand(program);
|
|
38
40
|
registerBenchCommand(program);
|
|
39
41
|
registerTicketCommand(program, {
|
|
@@ -10,7 +10,9 @@ const BUILTIN_PIPE_COMMANDS = /* @__PURE__ */ new Set([
|
|
|
10
10
|
"submit",
|
|
11
11
|
"argo",
|
|
12
12
|
"runner-command",
|
|
13
|
-
"ticket"
|
|
13
|
+
"ticket",
|
|
14
|
+
"create-experiment",
|
|
15
|
+
"template-update"
|
|
14
16
|
]);
|
|
15
17
|
var EntrypointCommandService = class extends Context.Service()("EntrypointCommandService") {};
|
|
16
18
|
const createEntrypointCommandServiceLive = (runEntrypoint) => Layer.succeed(EntrypointCommandService, { runEntrypoint: (entrypoint, task, opts) => Effect.tryPromise({
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { DEFAULT_RUNNER_COMMAND_GIT_COMMITTER } from "../config/schema/catalog.js";
|
|
2
|
+
import { resolveFactorySeams } from "./exec.js";
|
|
3
|
+
import { existsSync, mkdtempSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { copyFile, mkdir } from "node:fs/promises";
|
|
7
|
+
//#region src/factory/create-experiment.ts
|
|
8
|
+
/**
|
|
9
|
+
* create-experiment lane (INFRA-087.12): one deterministic run births a
|
|
10
|
+
* deployable fleet experiment.
|
|
11
|
+
*
|
|
12
|
+
* 1. headless `copier copy` stamps the app tree from momokaya-template;
|
|
13
|
+
* 2. `gh repo create <org>/<name> --private` + authenticated push publish it;
|
|
14
|
+
* 3. the stamped `infra-registry/config/<name>.yaml` is committed to the
|
|
15
|
+
* infra repo's fleet registry (`k8s/apps/platform-fleet/config/`), where
|
|
16
|
+
* the platform-fleet chart renders its previews ApplicationSet
|
|
17
|
+
* (`lifecycle: experiment` renders previews only, no prod Application).
|
|
18
|
+
*
|
|
19
|
+
* Ordered data-driven steps; each failure surfaces with the step name. No
|
|
20
|
+
* automatic rollback: a partially-born experiment is reported, and retirement
|
|
21
|
+
* (registry `lifecycle: retired` + repo deletion) is the documented cleanup.
|
|
22
|
+
*/
|
|
23
|
+
const EXPERIMENT_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
24
|
+
const DEFAULT_ORG = "oisin-ee";
|
|
25
|
+
const DEFAULT_TEMPLATE_SOURCE = "gh:oisin-ee/momokaya-template";
|
|
26
|
+
const DEFAULT_INFRA_REPO_URL = "https://github.com/oisin-ee/infra.git";
|
|
27
|
+
const FLEET_REGISTRY_DIR = "k8s/apps/platform-fleet/config";
|
|
28
|
+
const STAMPED_REGISTRY_DIR = "infra-registry/config";
|
|
29
|
+
function buildCopierCopyArgs(options) {
|
|
30
|
+
return [
|
|
31
|
+
"copy",
|
|
32
|
+
"--trust",
|
|
33
|
+
"--defaults",
|
|
34
|
+
...options.templateRef ? ["--vcs-ref", options.templateRef] : [],
|
|
35
|
+
"--data",
|
|
36
|
+
`name=${options.name}`,
|
|
37
|
+
"--data",
|
|
38
|
+
`flavor=${options.flavor}`,
|
|
39
|
+
"--data",
|
|
40
|
+
`db=${options.db}`,
|
|
41
|
+
"--data",
|
|
42
|
+
`previews=${options.previews}`,
|
|
43
|
+
options.templateSource,
|
|
44
|
+
options.destination
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
function committerConfigArgs() {
|
|
48
|
+
return [
|
|
49
|
+
"-c",
|
|
50
|
+
`user.name=${DEFAULT_RUNNER_COMMAND_GIT_COMMITTER.name}`,
|
|
51
|
+
"-c",
|
|
52
|
+
`user.email=${DEFAULT_RUNNER_COMMAND_GIT_COMMITTER.email}`
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
async function runCreateExperiment(options) {
|
|
56
|
+
const { exec, git, log } = resolveFactorySeams(options);
|
|
57
|
+
const name = options.name;
|
|
58
|
+
if (!EXPERIMENT_NAME_PATTERN.test(name)) throw new Error(`create-experiment: name must be kebab-case (got ${JSON.stringify(name)})`);
|
|
59
|
+
const org = options.org ?? DEFAULT_ORG;
|
|
60
|
+
const flavor = options.flavor ?? "web";
|
|
61
|
+
const db = options.db ?? true;
|
|
62
|
+
const previews = options.previews ?? true;
|
|
63
|
+
const templateSource = options.templateSource ?? DEFAULT_TEMPLATE_SOURCE;
|
|
64
|
+
const infraRepoUrl = options.infraRepoUrl ?? DEFAULT_INFRA_REPO_URL;
|
|
65
|
+
const workRoot = options.workRoot ?? mkdtempSync(join(tmpdir(), "create-experiment-"));
|
|
66
|
+
const stampDir = resolve(workRoot, name);
|
|
67
|
+
const repoUrl = `https://github.com/${org}/${name}`;
|
|
68
|
+
log(`create-experiment: birthing ${org}/${name} (flavor=${flavor} db=${db} previews=${previews})`);
|
|
69
|
+
await assertRepoAbsent({
|
|
70
|
+
exec,
|
|
71
|
+
name,
|
|
72
|
+
org
|
|
73
|
+
});
|
|
74
|
+
log(`create-experiment: stamping ${templateSource} -> ${stampDir}`);
|
|
75
|
+
await exec("copier", buildCopierCopyArgs({
|
|
76
|
+
db,
|
|
77
|
+
destination: stampDir,
|
|
78
|
+
flavor,
|
|
79
|
+
name,
|
|
80
|
+
previews,
|
|
81
|
+
...options.templateRef ? { templateRef: options.templateRef } : {},
|
|
82
|
+
templateSource
|
|
83
|
+
}));
|
|
84
|
+
const stampedRegistryEntry = join(stampDir, STAMPED_REGISTRY_DIR, `${name}.yaml`);
|
|
85
|
+
if (!existsSync(stampedRegistryEntry)) throw new Error(`create-experiment: stamp is missing the registry entry ${STAMPED_REGISTRY_DIR}/${name}.yaml — template contract changed?`);
|
|
86
|
+
log("create-experiment: committing the stamped tree");
|
|
87
|
+
await git(stampDir, ["init", "--initial-branch=main"]);
|
|
88
|
+
await git(stampDir, ["add", "--all"]);
|
|
89
|
+
await git(stampDir, [
|
|
90
|
+
...committerConfigArgs(),
|
|
91
|
+
"commit",
|
|
92
|
+
"-m",
|
|
93
|
+
`feat: initial stamp from ${templateSource}`
|
|
94
|
+
]);
|
|
95
|
+
log(`create-experiment: creating ${repoUrl} (private)`);
|
|
96
|
+
await exec("gh", [
|
|
97
|
+
"repo",
|
|
98
|
+
"create",
|
|
99
|
+
`${org}/${name}`,
|
|
100
|
+
"--private"
|
|
101
|
+
]);
|
|
102
|
+
await git(stampDir, [
|
|
103
|
+
"remote",
|
|
104
|
+
"add",
|
|
105
|
+
"origin",
|
|
106
|
+
`${repoUrl}.git`
|
|
107
|
+
]);
|
|
108
|
+
await git(stampDir, [
|
|
109
|
+
"push",
|
|
110
|
+
"-u",
|
|
111
|
+
"origin",
|
|
112
|
+
"main"
|
|
113
|
+
]);
|
|
114
|
+
log(`create-experiment: registering ${name} in the fleet registry`);
|
|
115
|
+
const infraDir = resolve(workRoot, "infra");
|
|
116
|
+
await git(workRoot, [
|
|
117
|
+
"clone",
|
|
118
|
+
"--depth",
|
|
119
|
+
"1",
|
|
120
|
+
"--single-branch",
|
|
121
|
+
infraRepoUrl,
|
|
122
|
+
infraDir
|
|
123
|
+
]);
|
|
124
|
+
const registryPath = `${FLEET_REGISTRY_DIR}/${name}.yaml`;
|
|
125
|
+
await mkdir(join(infraDir, FLEET_REGISTRY_DIR), { recursive: true });
|
|
126
|
+
await copyFile(stampedRegistryEntry, join(infraDir, registryPath));
|
|
127
|
+
await git(infraDir, [
|
|
128
|
+
"add",
|
|
129
|
+
"--",
|
|
130
|
+
registryPath
|
|
131
|
+
]);
|
|
132
|
+
await git(infraDir, [
|
|
133
|
+
...committerConfigArgs(),
|
|
134
|
+
"commit",
|
|
135
|
+
"-m",
|
|
136
|
+
`feat(fleet): register experiment ${name} (create-experiment lane)`
|
|
137
|
+
]);
|
|
138
|
+
await git(infraDir, [
|
|
139
|
+
"push",
|
|
140
|
+
"origin",
|
|
141
|
+
"HEAD:main"
|
|
142
|
+
]);
|
|
143
|
+
const infraCommitSha = (await git(infraDir, ["rev-parse", "HEAD"])).trim();
|
|
144
|
+
log(`create-experiment: done — repo=${repoUrl} registry=${registryPath} infraCommit=${infraCommitSha}`);
|
|
145
|
+
return {
|
|
146
|
+
infraCommitSha,
|
|
147
|
+
registryPath,
|
|
148
|
+
repoUrl,
|
|
149
|
+
stampDir
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
async function assertRepoAbsent(input) {
|
|
153
|
+
const slug = `${input.org}/${input.name}`;
|
|
154
|
+
if (await input.exec("gh", [
|
|
155
|
+
"repo",
|
|
156
|
+
"view",
|
|
157
|
+
slug,
|
|
158
|
+
"--json",
|
|
159
|
+
"name"
|
|
160
|
+
]).then(() => true).catch(() => false)) throw new Error(`create-experiment: repo ${slug} already exists — pick another name or retire the old experiment first`);
|
|
161
|
+
}
|
|
162
|
+
//#endregion
|
|
163
|
+
export { EXPERIMENT_NAME_PATTERN, buildCopierCopyArgs, committerConfigArgs, runCreateExperiment };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { runAuthenticatedGit } from "../run-state/git-refs.js";
|
|
2
|
+
import { execa } from "execa";
|
|
3
|
+
//#region src/factory/exec.ts
|
|
4
|
+
const defaultFactoryExec = (command, args, options) => execa(command, [...args], {
|
|
5
|
+
...options?.cwd ? { cwd: options.cwd } : {},
|
|
6
|
+
stdin: "ignore"
|
|
7
|
+
});
|
|
8
|
+
const defaultFactoryGit = (cwd, args) => runAuthenticatedGit(cwd, args);
|
|
9
|
+
function resolveFactorySeams(seams = {}) {
|
|
10
|
+
return {
|
|
11
|
+
exec: seams.exec ?? defaultFactoryExec,
|
|
12
|
+
git: seams.git ?? defaultFactoryGit,
|
|
13
|
+
log: seams.log ?? ((line) => console.log(line))
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
export { defaultFactoryExec, defaultFactoryGit, resolveFactorySeams };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { parse } from "yaml";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
//#region src/factory/stamp-answers.ts
|
|
4
|
+
/**
|
|
5
|
+
* `.copier-answers.yml` is copier's stamp receipt: `_src_path` records the
|
|
6
|
+
* template a repo was generated from and `_commit` the template version.
|
|
7
|
+
*
|
|
8
|
+
* The org has MULTIPLE copier templates (e.g. the @oisincoveney/dev scaffold
|
|
9
|
+
* also writes `.copier-answers.yml`), so the marker file alone does NOT mean
|
|
10
|
+
* "momokaya-template stamp" — template-update must filter on `_src_path`
|
|
11
|
+
* before fanning a `copier update` PR out to a repo.
|
|
12
|
+
*/
|
|
13
|
+
const copierAnswersSchema = z.object({
|
|
14
|
+
_commit: z.string().optional(),
|
|
15
|
+
_src_path: z.string().optional()
|
|
16
|
+
}).passthrough();
|
|
17
|
+
function parseCopierAnswers(source) {
|
|
18
|
+
const parsed = copierAnswersSchema.parse(parse(source));
|
|
19
|
+
return {
|
|
20
|
+
...parsed._commit === void 0 ? {} : { commit: parsed._commit },
|
|
21
|
+
...parsed._src_path === void 0 ? {} : { srcPath: parsed._src_path }
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function isStampOf(receipt, templateMatch) {
|
|
25
|
+
return receipt.srcPath?.includes(templateMatch) ?? false;
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
export { isStampOf, parseCopierAnswers };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { resolveFactorySeams } from "./exec.js";
|
|
2
|
+
import { committerConfigArgs } from "./create-experiment.js";
|
|
3
|
+
import { isStampOf, parseCopierAnswers } from "./stamp-answers.js";
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { basename, join, resolve } from "node:path";
|
|
9
|
+
//#region src/factory/template-update.ts
|
|
10
|
+
/**
|
|
11
|
+
* template-update lane (INFRA-087.12): when momokaya-template ships a new tag,
|
|
12
|
+
* fan `copier update` PRs out across every repo stamped from it — one PR per
|
|
13
|
+
* repo, never a direct push to an app repo's default branch.
|
|
14
|
+
*
|
|
15
|
+
* Discovery = fleet registry entries (the infra repo's
|
|
16
|
+
* `k8s/apps/platform-fleet/config/*.yaml` → `repo:`) probed for a
|
|
17
|
+
* `.copier-answers.yml` whose `_src_path` matches the template (the marker
|
|
18
|
+
* alone is ambiguous across the org's copier templates — see
|
|
19
|
+
* stamp-answers.ts).
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_ORG = "oisin-ee";
|
|
22
|
+
const DEFAULT_TEMPLATE_MATCH = "momokaya-template";
|
|
23
|
+
const DEFAULT_INFRA_REPO_URL = "https://github.com/oisin-ee/infra.git";
|
|
24
|
+
const FLEET_REGISTRY_DIR = "k8s/apps/platform-fleet/config";
|
|
25
|
+
const ANSWERS_FILE = ".copier-answers.yml";
|
|
26
|
+
const registryEntryRepoSchema = z.object({ repo: z.string() }).passthrough();
|
|
27
|
+
function summarizeTemplateUpdate(results) {
|
|
28
|
+
return {
|
|
29
|
+
failed: results.filter((entry) => entry.status === "error").length,
|
|
30
|
+
opened: results.filter((entry) => entry.status === "pr-opened").length
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async function runTemplateUpdate(options) {
|
|
34
|
+
const seams = resolveFactorySeams(options);
|
|
35
|
+
const { log } = seams;
|
|
36
|
+
const org = options.org ?? DEFAULT_ORG;
|
|
37
|
+
const templateMatch = options.templateMatch ?? DEFAULT_TEMPLATE_MATCH;
|
|
38
|
+
const workRoot = options.workRoot ?? mkdtempSync(join(tmpdir(), "template-update-"));
|
|
39
|
+
const repos = options.repos && options.repos.length > 0 ? [...options.repos] : await discoverRegistryRepos({
|
|
40
|
+
git: seams.git,
|
|
41
|
+
infraRepoUrl: options.infraRepoUrl ?? DEFAULT_INFRA_REPO_URL,
|
|
42
|
+
workRoot
|
|
43
|
+
});
|
|
44
|
+
log(`template-update: candidates [${repos.join(", ")}]`);
|
|
45
|
+
const results = [];
|
|
46
|
+
for (const repo of repos) results.push(await updateRepo({
|
|
47
|
+
org,
|
|
48
|
+
repo,
|
|
49
|
+
seams,
|
|
50
|
+
...options.templateRef ? { templateRef: options.templateRef } : {},
|
|
51
|
+
templateMatch,
|
|
52
|
+
workRoot
|
|
53
|
+
}));
|
|
54
|
+
for (const entry of results) log(`template-update: ${entry.repo} -> ${entry.status}${entry.prUrl ? ` ${entry.prUrl}` : ""}${entry.message ? ` (${entry.message})` : ""}`);
|
|
55
|
+
return { results };
|
|
56
|
+
}
|
|
57
|
+
async function discoverRegistryRepos(input) {
|
|
58
|
+
const infraDir = resolve(input.workRoot, "infra-discovery");
|
|
59
|
+
await input.git(input.workRoot, [
|
|
60
|
+
"clone",
|
|
61
|
+
"--depth",
|
|
62
|
+
"1",
|
|
63
|
+
"--single-branch",
|
|
64
|
+
input.infraRepoUrl,
|
|
65
|
+
infraDir
|
|
66
|
+
]);
|
|
67
|
+
const registryDir = join(infraDir, FLEET_REGISTRY_DIR);
|
|
68
|
+
const repos = readdirSync(registryDir).filter((file) => file.endsWith(".yaml")).map((file) => {
|
|
69
|
+
return registryEntryRepoSchema.parse(parse(readFileSync(join(registryDir, file), "utf8"))).repo;
|
|
70
|
+
});
|
|
71
|
+
return [...new Set(repos)].sort((left, right) => left.localeCompare(right));
|
|
72
|
+
}
|
|
73
|
+
async function updateRepo(input) {
|
|
74
|
+
const { exec, git } = input.seams;
|
|
75
|
+
const { repo } = input;
|
|
76
|
+
try {
|
|
77
|
+
const cloneDir = resolve(input.workRoot, `update-${repo}`);
|
|
78
|
+
await git(input.workRoot, [
|
|
79
|
+
"clone",
|
|
80
|
+
`https://github.com/${input.org}/${repo}.git`,
|
|
81
|
+
cloneDir
|
|
82
|
+
]);
|
|
83
|
+
const answersPath = join(cloneDir, ANSWERS_FILE);
|
|
84
|
+
if (!existsSync(answersPath)) return {
|
|
85
|
+
repo,
|
|
86
|
+
status: "not-stamped"
|
|
87
|
+
};
|
|
88
|
+
const receipt = parseCopierAnswers(readFileSync(answersPath, "utf8"));
|
|
89
|
+
if (!isStampOf(receipt, input.templateMatch)) return {
|
|
90
|
+
message: `stamped from ${receipt.srcPath ?? "unknown"}, not ${input.templateMatch}`,
|
|
91
|
+
repo,
|
|
92
|
+
status: "not-stamped"
|
|
93
|
+
};
|
|
94
|
+
await git(cloneDir, [
|
|
95
|
+
"checkout",
|
|
96
|
+
"-b",
|
|
97
|
+
"template-update/pending"
|
|
98
|
+
]);
|
|
99
|
+
await exec("copier", [
|
|
100
|
+
"update",
|
|
101
|
+
"--trust",
|
|
102
|
+
"--defaults",
|
|
103
|
+
...input.templateRef ? ["--vcs-ref", input.templateRef] : []
|
|
104
|
+
], { cwd: cloneDir });
|
|
105
|
+
const status = await git(cloneDir, ["status", "--porcelain"]);
|
|
106
|
+
if (status.trim().length === 0) return {
|
|
107
|
+
repo,
|
|
108
|
+
status: "up-to-date",
|
|
109
|
+
...receipt.commit === void 0 ? {} : { version: receipt.commit }
|
|
110
|
+
};
|
|
111
|
+
const version = parseCopierAnswers(readFileSync(answersPath, "utf8")).commit ?? "unknown";
|
|
112
|
+
const branch = `chore/template-update-${version}`;
|
|
113
|
+
const rejects = listRejectFiles(cloneDir, status);
|
|
114
|
+
await git(cloneDir, [
|
|
115
|
+
"branch",
|
|
116
|
+
"-m",
|
|
117
|
+
branch
|
|
118
|
+
]);
|
|
119
|
+
await git(cloneDir, ["add", "--all"]);
|
|
120
|
+
await git(cloneDir, [
|
|
121
|
+
...committerConfigArgs(),
|
|
122
|
+
"commit",
|
|
123
|
+
"-m",
|
|
124
|
+
`chore: copier update to ${version}`
|
|
125
|
+
]);
|
|
126
|
+
await git(cloneDir, [
|
|
127
|
+
"push",
|
|
128
|
+
"-u",
|
|
129
|
+
"origin",
|
|
130
|
+
branch
|
|
131
|
+
]);
|
|
132
|
+
const prBody = [`Automated \`copier update\` to momokaya-template ${version} (template-update lane).`, ...rejects.length > 0 ? [
|
|
133
|
+
"",
|
|
134
|
+
"WARNING — conflict rejects need manual resolution:",
|
|
135
|
+
...rejects.map((file) => `- \`${file}\``)
|
|
136
|
+
] : []].join("\n");
|
|
137
|
+
const { stdout } = await exec("gh", [
|
|
138
|
+
"pr",
|
|
139
|
+
"create",
|
|
140
|
+
"--repo",
|
|
141
|
+
`${input.org}/${repo}`,
|
|
142
|
+
"--head",
|
|
143
|
+
branch,
|
|
144
|
+
"--title",
|
|
145
|
+
`chore: copier update to ${version}`,
|
|
146
|
+
"--body",
|
|
147
|
+
prBody
|
|
148
|
+
], { cwd: cloneDir });
|
|
149
|
+
return {
|
|
150
|
+
prUrl: stdout.trim(),
|
|
151
|
+
repo,
|
|
152
|
+
status: "pr-opened",
|
|
153
|
+
version
|
|
154
|
+
};
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return {
|
|
157
|
+
message: error instanceof Error ? error.message : String(error),
|
|
158
|
+
repo,
|
|
159
|
+
status: "error"
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function listRejectFiles(cloneDir, porcelainStatus) {
|
|
164
|
+
return porcelainStatus.split("\n").map((line) => line.slice(3).trim()).filter((file) => file.endsWith(".rej") && existsSync(join(cloneDir, file))).map((file) => basename(file));
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
167
|
+
export { runTemplateUpdate, summarizeTemplateUpdate };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
//#region src/factory/factory-lane.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Kubernetes Job manifest builder for factory lanes (create-experiment /
|
|
6
|
+
* template-update). The console front door creates these Jobs directly
|
|
7
|
+
* (batch/v1, NOT an Argo Workflow): a lane is a single deterministic `moka`
|
|
8
|
+
* subcommand with no payload repo, no schedule and no node graph, so the
|
|
9
|
+
* runner-command DAG machinery does not apply. The runner image ENTRYPOINT is
|
|
10
|
+
* `entrypoint-preflight.sh moka`; the Job overrides only the container args,
|
|
11
|
+
* so `args: ["create-experiment", ...]` runs `moka create-experiment ...`.
|
|
12
|
+
*
|
|
13
|
+
* Credential mounts replicate the runner workflow's secret storage shapes
|
|
14
|
+
* (src/remote/argo/storage.ts): the git credential store dir at
|
|
15
|
+
* /etc/pipeline/git-credentials (consumed by runAuthenticatedGit) and the gh
|
|
16
|
+
* hosts.yml at /root/.config/gh/hosts.yml.
|
|
17
|
+
*/
|
|
18
|
+
declare const factoryLaneJobOptionsSchema: z.ZodObject<{
|
|
19
|
+
activeDeadlineSeconds: z.ZodDefault<z.ZodNumber>;
|
|
20
|
+
argv: z.ZodArray<z.ZodString>;
|
|
21
|
+
generateName: z.ZodDefault<z.ZodString>;
|
|
22
|
+
gitCredentialsSecretName: z.ZodString;
|
|
23
|
+
githubAuthSecretName: z.ZodString;
|
|
24
|
+
image: z.ZodString;
|
|
25
|
+
imagePullPolicy: z.ZodOptional<z.ZodString>;
|
|
26
|
+
imagePullSecretName: z.ZodOptional<z.ZodString>;
|
|
27
|
+
labels: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
28
|
+
namespace: z.ZodString;
|
|
29
|
+
resources: z.ZodOptional<z.ZodObject<{
|
|
30
|
+
limits: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
31
|
+
requests: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
32
|
+
}, z.core.$strip>>;
|
|
33
|
+
serviceAccountName: z.ZodOptional<z.ZodString>;
|
|
34
|
+
ttlSecondsAfterFinished: z.ZodDefault<z.ZodNumber>;
|
|
35
|
+
}, z.core.$strip>;
|
|
36
|
+
type FactoryLaneJobOptionsInput = z.input<typeof factoryLaneJobOptionsSchema>;
|
|
37
|
+
type FactoryLaneJobOptions = z.output<typeof factoryLaneJobOptionsSchema>;
|
|
38
|
+
declare const FACTORY_LANE_LABEL = "pipeline.oisin.dev/factory-lane";
|
|
39
|
+
declare function buildFactoryLaneJob(input: FactoryLaneJobOptionsInput): {
|
|
40
|
+
apiVersion: string;
|
|
41
|
+
kind: string;
|
|
42
|
+
metadata: {
|
|
43
|
+
generateName: string;
|
|
44
|
+
labels: {
|
|
45
|
+
"pipeline.oisin.dev/factory-lane": string;
|
|
46
|
+
};
|
|
47
|
+
namespace: string;
|
|
48
|
+
};
|
|
49
|
+
spec: {
|
|
50
|
+
activeDeadlineSeconds: number;
|
|
51
|
+
backoffLimit: number;
|
|
52
|
+
template: {
|
|
53
|
+
metadata: {
|
|
54
|
+
labels: {
|
|
55
|
+
"pipeline.oisin.dev/factory-lane": string;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
spec: {
|
|
59
|
+
containers: {
|
|
60
|
+
args: string[];
|
|
61
|
+
image: string;
|
|
62
|
+
imagePullPolicy?: string | undefined;
|
|
63
|
+
name: string;
|
|
64
|
+
resources?: {
|
|
65
|
+
limits?: Record<string, string> | undefined;
|
|
66
|
+
requests?: Record<string, string> | undefined;
|
|
67
|
+
} | undefined;
|
|
68
|
+
volumeMounts: ({
|
|
69
|
+
mountPath: string;
|
|
70
|
+
name: string;
|
|
71
|
+
readOnly: boolean;
|
|
72
|
+
subPath?: undefined;
|
|
73
|
+
} | {
|
|
74
|
+
mountPath: string;
|
|
75
|
+
name: string;
|
|
76
|
+
readOnly: boolean;
|
|
77
|
+
subPath: string;
|
|
78
|
+
})[];
|
|
79
|
+
}[];
|
|
80
|
+
imagePullSecrets?: {
|
|
81
|
+
name: string;
|
|
82
|
+
}[] | undefined;
|
|
83
|
+
restartPolicy: string;
|
|
84
|
+
serviceAccountName?: string | undefined;
|
|
85
|
+
volumes: ({
|
|
86
|
+
name: string;
|
|
87
|
+
secret: {
|
|
88
|
+
defaultMode: number;
|
|
89
|
+
secretName: string;
|
|
90
|
+
items?: undefined;
|
|
91
|
+
};
|
|
92
|
+
} | {
|
|
93
|
+
name: string;
|
|
94
|
+
secret: {
|
|
95
|
+
defaultMode?: undefined;
|
|
96
|
+
items: {
|
|
97
|
+
key: string;
|
|
98
|
+
path: string;
|
|
99
|
+
}[];
|
|
100
|
+
secretName: string;
|
|
101
|
+
};
|
|
102
|
+
})[];
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
//#endregion
|
|
108
|
+
export { FACTORY_LANE_LABEL, FactoryLaneJobOptions, FactoryLaneJobOptionsInput, buildFactoryLaneJob, factoryLaneJobOptionsSchema };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
//#region src/factory/factory-lane.ts
|
|
3
|
+
/**
|
|
4
|
+
* Kubernetes Job manifest builder for factory lanes (create-experiment /
|
|
5
|
+
* template-update). The console front door creates these Jobs directly
|
|
6
|
+
* (batch/v1, NOT an Argo Workflow): a lane is a single deterministic `moka`
|
|
7
|
+
* subcommand with no payload repo, no schedule and no node graph, so the
|
|
8
|
+
* runner-command DAG machinery does not apply. The runner image ENTRYPOINT is
|
|
9
|
+
* `entrypoint-preflight.sh moka`; the Job overrides only the container args,
|
|
10
|
+
* so `args: ["create-experiment", ...]` runs `moka create-experiment ...`.
|
|
11
|
+
*
|
|
12
|
+
* Credential mounts replicate the runner workflow's secret storage shapes
|
|
13
|
+
* (src/remote/argo/storage.ts): the git credential store dir at
|
|
14
|
+
* /etc/pipeline/git-credentials (consumed by runAuthenticatedGit) and the gh
|
|
15
|
+
* hosts.yml at /root/.config/gh/hosts.yml.
|
|
16
|
+
*/
|
|
17
|
+
const factoryLaneJobOptionsSchema = z.object({
|
|
18
|
+
activeDeadlineSeconds: z.number().int().positive().default(1800),
|
|
19
|
+
argv: z.array(z.string().min(1)).min(1),
|
|
20
|
+
generateName: z.string().min(1).default("moka-factory-"),
|
|
21
|
+
gitCredentialsSecretName: z.string().min(1),
|
|
22
|
+
githubAuthSecretName: z.string().min(1),
|
|
23
|
+
image: z.string().min(1),
|
|
24
|
+
imagePullPolicy: z.string().min(1).optional(),
|
|
25
|
+
imagePullSecretName: z.string().min(1).optional(),
|
|
26
|
+
labels: z.record(z.string(), z.string()).default({}),
|
|
27
|
+
namespace: z.string().min(1),
|
|
28
|
+
resources: z.object({
|
|
29
|
+
limits: z.record(z.string(), z.string()).optional(),
|
|
30
|
+
requests: z.record(z.string(), z.string()).optional()
|
|
31
|
+
}).optional(),
|
|
32
|
+
serviceAccountName: z.string().min(1).optional(),
|
|
33
|
+
ttlSecondsAfterFinished: z.number().int().positive().default(86400)
|
|
34
|
+
});
|
|
35
|
+
const FACTORY_LANE_LABEL = "pipeline.oisin.dev/factory-lane";
|
|
36
|
+
function buildFactoryLaneJob(input) {
|
|
37
|
+
const options = factoryLaneJobOptionsSchema.parse(input);
|
|
38
|
+
const lane = options.argv[0] ?? "unknown";
|
|
39
|
+
return {
|
|
40
|
+
apiVersion: "batch/v1",
|
|
41
|
+
kind: "Job",
|
|
42
|
+
metadata: {
|
|
43
|
+
generateName: options.generateName,
|
|
44
|
+
labels: {
|
|
45
|
+
[FACTORY_LANE_LABEL]: lane,
|
|
46
|
+
...options.labels
|
|
47
|
+
},
|
|
48
|
+
namespace: options.namespace
|
|
49
|
+
},
|
|
50
|
+
spec: {
|
|
51
|
+
activeDeadlineSeconds: options.activeDeadlineSeconds,
|
|
52
|
+
backoffLimit: 0,
|
|
53
|
+
template: {
|
|
54
|
+
metadata: { labels: {
|
|
55
|
+
[FACTORY_LANE_LABEL]: lane,
|
|
56
|
+
...options.labels
|
|
57
|
+
} },
|
|
58
|
+
spec: {
|
|
59
|
+
containers: [{
|
|
60
|
+
args: [...options.argv],
|
|
61
|
+
image: options.image,
|
|
62
|
+
...options.imagePullPolicy ? { imagePullPolicy: options.imagePullPolicy } : {},
|
|
63
|
+
name: "lane",
|
|
64
|
+
...options.resources ? { resources: options.resources } : {},
|
|
65
|
+
volumeMounts: [{
|
|
66
|
+
mountPath: "/etc/pipeline/git-credentials",
|
|
67
|
+
name: "runner-git-credentials",
|
|
68
|
+
readOnly: true
|
|
69
|
+
}, {
|
|
70
|
+
mountPath: "/root/.config/gh/hosts.yml",
|
|
71
|
+
name: "github-auth",
|
|
72
|
+
readOnly: true,
|
|
73
|
+
subPath: "hosts.yml"
|
|
74
|
+
}]
|
|
75
|
+
}],
|
|
76
|
+
...options.imagePullSecretName ? { imagePullSecrets: [{ name: options.imagePullSecretName }] } : {},
|
|
77
|
+
restartPolicy: "Never",
|
|
78
|
+
...options.serviceAccountName ? { serviceAccountName: options.serviceAccountName } : {},
|
|
79
|
+
volumes: [{
|
|
80
|
+
name: "runner-git-credentials",
|
|
81
|
+
secret: {
|
|
82
|
+
defaultMode: 256,
|
|
83
|
+
secretName: options.gitCredentialsSecretName
|
|
84
|
+
}
|
|
85
|
+
}, {
|
|
86
|
+
name: "github-auth",
|
|
87
|
+
secret: {
|
|
88
|
+
items: [{
|
|
89
|
+
key: "hosts.yml",
|
|
90
|
+
path: "hosts.yml"
|
|
91
|
+
}],
|
|
92
|
+
secretName: options.githubAuthSecretName
|
|
93
|
+
}
|
|
94
|
+
}]
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
//#endregion
|
|
101
|
+
export { FACTORY_LANE_LABEL, buildFactoryLaneJob, factoryLaneJobOptionsSchema };
|
package/dist/pipeline-init.js
CHANGED
|
@@ -1,102 +1,21 @@
|
|
|
1
|
-
import { claudeGlobalConfigDir, codexGlobalConfigDir, opencodeGlobalConfigDir } from "./install-commands/shared.js";
|
|
2
|
-
import { AGENT_SKILL_SOURCE } from "./agent-assets.js";
|
|
3
1
|
import { installCommands } from "./install-commands.js";
|
|
4
|
-
import { installHooks } from "./install-hooks.js";
|
|
5
|
-
import { installRules } from "./install-rules.js";
|
|
6
|
-
import { execa } from "execa";
|
|
7
|
-
import { homedir } from "node:os";
|
|
8
|
-
import { join } from "node:path";
|
|
9
|
-
import { rm } from "node:fs/promises";
|
|
10
2
|
//#region src/pipeline-init.ts
|
|
11
|
-
const DEFAULT_SKILL_INSTALL_SOURCE = AGENT_SKILL_SOURCE;
|
|
12
|
-
const SKILL_INSTALL_AGENT_ARGS = [
|
|
13
|
-
"--agent",
|
|
14
|
-
"opencode",
|
|
15
|
-
"--agent",
|
|
16
|
-
"codex",
|
|
17
|
-
"--agent",
|
|
18
|
-
"claude-code",
|
|
19
|
-
"--skill",
|
|
20
|
-
"*",
|
|
21
|
-
"--yes",
|
|
22
|
-
"--global"
|
|
23
|
-
];
|
|
24
3
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* `
|
|
33
|
-
* test suite's env redirect isolates them), the master store and lock honor
|
|
34
|
-
* the home dir and `XDG_STATE_HOME`.
|
|
4
|
+
* `moka init` installs only moka's own slash-command adapters
|
|
5
|
+
* (`/moka-execute|inspect|quick`) plus the singleton MCP gateway host config,
|
|
6
|
+
* globally for Claude Code, Codex, and OpenCode. The shared agent harness
|
|
7
|
+
* (skills, agent hooks, and global instruction rules) is no longer installed by
|
|
8
|
+
* moka — it is provisioned from `oisin-ee/agent` via chezmoi (the dotfiles'
|
|
9
|
+
* `.chezmoiexternal` clone + `run_onchange` harness installer). Keeping moka's
|
|
10
|
+
* command adapters here means the runner image (and local dev) still gets the
|
|
11
|
+
* `/moka-*` entrypoints after `chezmoi apply` lays down the harness.
|
|
35
12
|
*/
|
|
36
|
-
function globalSkillCleanTargets() {
|
|
37
|
-
const agentsHome = homedir();
|
|
38
|
-
const skillLockPath = process.env.XDG_STATE_HOME ? join(process.env.XDG_STATE_HOME, "skills", ".skill-lock.json") : join(agentsHome, ".agents", ".skill-lock.json");
|
|
39
|
-
return [
|
|
40
|
-
join(claudeGlobalConfigDir(), "skills"),
|
|
41
|
-
join(codexGlobalConfigDir(), "skills"),
|
|
42
|
-
join(opencodeGlobalConfigDir(), "skills"),
|
|
43
|
-
join(agentsHome, ".agents", "skills"),
|
|
44
|
-
skillLockPath
|
|
45
|
-
];
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Clean-replace step run before the additive `skills add`. `npx skills add`
|
|
49
|
-
* only ever adds, so without this a renamed, removed, or foreign global skill
|
|
50
|
-
* accumulates forever across `moka init` runs. Removing the per-agent symlink
|
|
51
|
-
* farms + shared master store + lock resets global skill state so the post-add
|
|
52
|
-
* set equals exactly the canonical `oisin-ee/agent` source. Safe when absent
|
|
53
|
-
* (rm force); only `skills` subdirs, the master store, and the lock are
|
|
54
|
-
* touched — never a whole host config dir.
|
|
55
|
-
*/
|
|
56
|
-
async function cleanGlobalSkills() {
|
|
57
|
-
await Promise.all(globalSkillCleanTargets().map((target) => rm(target, {
|
|
58
|
-
force: true,
|
|
59
|
-
recursive: true
|
|
60
|
-
})));
|
|
61
|
-
}
|
|
62
|
-
async function installDefaultSkills(cwd) {
|
|
63
|
-
try {
|
|
64
|
-
await cleanGlobalSkills();
|
|
65
|
-
await execa("npx", [
|
|
66
|
-
"--yes",
|
|
67
|
-
"skills",
|
|
68
|
-
"add",
|
|
69
|
-
DEFAULT_SKILL_INSTALL_SOURCE,
|
|
70
|
-
...SKILL_INSTALL_AGENT_ARGS
|
|
71
|
-
], {
|
|
72
|
-
cwd,
|
|
73
|
-
stdio: "inherit"
|
|
74
|
-
});
|
|
75
|
-
} catch (error) {
|
|
76
|
-
const cause = error instanceof Error ? `: ${error.message}` : "";
|
|
77
|
-
throw new Error(`Failed to install default skills from ${DEFAULT_SKILL_INSTALL_SOURCE}${cause}. If this is a private repository, authenticate GitHub access for npx skills add and rerun \`moka init\`.`);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
function hookInstallerFiles(result) {
|
|
81
|
-
return "items" in result ? result.items.map((item) => item.path) : result.files;
|
|
82
|
-
}
|
|
83
13
|
async function initPipelineProject(options = {}) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const installerFlags = initInstallerFlags(options);
|
|
87
|
-
if (!(check || dryRun)) await (options.skillInstaller ?? installDefaultSkills)(cwd);
|
|
88
|
-
const result = await installCommands({
|
|
89
|
-
cwd,
|
|
14
|
+
return { files: (await installCommands({
|
|
15
|
+
cwd: options.cwd ?? process.cwd(),
|
|
90
16
|
host: "all",
|
|
91
|
-
...
|
|
92
|
-
});
|
|
93
|
-
const hooks = await (options.hookInstaller ?? (() => installHooks(installerFlags)))(cwd);
|
|
94
|
-
const rulesResult = await (options.rulesInstaller ?? (() => installRules(installerFlags)))(cwd);
|
|
95
|
-
return { files: [
|
|
96
|
-
...result.items.map((item) => item.path),
|
|
97
|
-
...hookInstallerFiles(hooks),
|
|
98
|
-
...rulesResult.items.map((item) => item.path)
|
|
99
|
-
] };
|
|
17
|
+
...initInstallerFlags(options)
|
|
18
|
+
})).items.map((item) => item.path) };
|
|
100
19
|
}
|
|
101
20
|
function initInstallerFlags(options) {
|
|
102
21
|
const { check, dryRun } = options;
|
|
@@ -108,17 +27,17 @@ function initInstallerFlags(options) {
|
|
|
108
27
|
}
|
|
109
28
|
const INIT_RESULT_COPY = {
|
|
110
29
|
install: {
|
|
111
|
-
headline: "Initialized
|
|
30
|
+
headline: "Initialized moka slash-command adapters:",
|
|
112
31
|
fileVerb: "generated",
|
|
113
32
|
footer: "no repo-local pipeline config files were created"
|
|
114
33
|
},
|
|
115
34
|
check: {
|
|
116
|
-
headline: "Verified
|
|
35
|
+
headline: "Verified moka slash-command adapters are current:",
|
|
117
36
|
fileVerb: "current",
|
|
118
|
-
footer: "
|
|
37
|
+
footer: "adapters verified; no changes written"
|
|
119
38
|
},
|
|
120
39
|
dryRun: {
|
|
121
|
-
headline: "Planned
|
|
40
|
+
headline: "Planned moka slash-command adapters:",
|
|
122
41
|
fileVerb: "would generate",
|
|
123
42
|
footer: "dry run; no changes written"
|
|
124
43
|
}
|
|
@@ -132,7 +51,7 @@ function formatPipelineInitResult(result, mode = {}) {
|
|
|
132
51
|
const copy = INIT_RESULT_COPY[initResultMode(mode)];
|
|
133
52
|
return [
|
|
134
53
|
copy.headline,
|
|
135
|
-
"per-machine
|
|
54
|
+
"per-machine slash-command adapters (/moka-execute|inspect|quick) installed globally (~/.claude, ~/.config/opencode, ~/.codex); the agent harness (skills, hooks, instruction rules) comes from oisin-ee/agent via chezmoi, not moka",
|
|
136
55
|
...result.files.map((path) => `${copy.fileVerb} ${path}`),
|
|
137
56
|
copy.footer
|
|
138
57
|
].join("\n");
|
package/package.json
CHANGED
|
@@ -81,6 +81,10 @@
|
|
|
81
81
|
"types": "./dist/config.d.ts",
|
|
82
82
|
"import": "./dist/config.js"
|
|
83
83
|
},
|
|
84
|
+
"./factory-lane": {
|
|
85
|
+
"types": "./dist/factory-lane.d.ts",
|
|
86
|
+
"import": "./dist/factory-lane.js"
|
|
87
|
+
},
|
|
84
88
|
"./events": {
|
|
85
89
|
"types": "./dist/runner-event-schema.d.ts",
|
|
86
90
|
"import": "./dist/runner-event-schema.js"
|
|
@@ -138,7 +142,7 @@
|
|
|
138
142
|
"prepack": "nub run build:cli"
|
|
139
143
|
},
|
|
140
144
|
"type": "module",
|
|
141
|
-
"version": "3.
|
|
145
|
+
"version": "3.22.0",
|
|
142
146
|
"description": "Config-driven multi-agent pipeline runner for repository work",
|
|
143
147
|
"main": "./dist/index.js",
|
|
144
148
|
"types": "./dist/index.d.ts",
|
package/dist/agent-assets.js
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
//#region src/agent-assets.ts
|
|
2
|
-
const AGENT_ASSET_SOURCE = "oisin-ee/agent";
|
|
3
|
-
const AGENT_SKILL_SOURCE = "oisin-ee/agent/skills";
|
|
4
|
-
const AGENT_HOOKS_DIR = "hooks";
|
|
5
|
-
const AGENT_RULES_DIR = "rules";
|
|
6
|
-
//#endregion
|
|
7
|
-
export { AGENT_ASSET_SOURCE, AGENT_HOOKS_DIR, AGENT_RULES_DIR, AGENT_SKILL_SOURCE };
|
package/dist/install-hooks.js
DELETED
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
import { applyJsonEdit, ensureTrailingNewline, parseJsonRecord } from "./json-config-merge.js";
|
|
2
|
-
import { resolveHarnessTarget } from "./install-commands/shared.js";
|
|
3
|
-
import { AGENT_ASSET_SOURCE, AGENT_HOOKS_DIR } from "./agent-assets.js";
|
|
4
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
5
|
-
import { execa } from "execa";
|
|
6
|
-
import { tmpdir } from "node:os";
|
|
7
|
-
import { dirname, join, relative } from "node:path";
|
|
8
|
-
import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
|
9
|
-
import { createHash } from "node:crypto";
|
|
10
|
-
//#region src/install-hooks.ts
|
|
11
|
-
const DEFAULT_HOOK_INSTALL_SOURCE = AGENT_ASSET_SOURCE;
|
|
12
|
-
const HOOK_HOSTS = [
|
|
13
|
-
"claude-code",
|
|
14
|
-
"codex",
|
|
15
|
-
"opencode"
|
|
16
|
-
];
|
|
17
|
-
const MANIFEST_FILE = ".moka-agent-hooks.json";
|
|
18
|
-
const HOST_TARGET_ROOT = {
|
|
19
|
-
"claude-code": ".claude",
|
|
20
|
-
codex: ".codex",
|
|
21
|
-
opencode: ".opencode"
|
|
22
|
-
};
|
|
23
|
-
const NON_HOOK_OWNED_TARGETS = /* @__PURE__ */ new Set([".opencode/opencode.json"]);
|
|
24
|
-
function hashContent(content) {
|
|
25
|
-
return createHash("sha256").update(content).digest("hex");
|
|
26
|
-
}
|
|
27
|
-
const MERGE_MANAGED = { ".claude/settings.json": [
|
|
28
|
-
["hooks"],
|
|
29
|
-
["skillListingBudgetFraction"],
|
|
30
|
-
["skillOverrides"]
|
|
31
|
-
] };
|
|
32
|
-
function mergeKeysFor(path) {
|
|
33
|
-
return MERGE_MANAGED[path];
|
|
34
|
-
}
|
|
35
|
-
function canonicalize(value) {
|
|
36
|
-
if (Array.isArray(value)) return value.map(canonicalize);
|
|
37
|
-
if (isRecord(value)) {
|
|
38
|
-
const out = {};
|
|
39
|
-
for (const key of Object.keys(value).sort()) out[key] = canonicalize(value[key]);
|
|
40
|
-
return out;
|
|
41
|
-
}
|
|
42
|
-
return value;
|
|
43
|
-
}
|
|
44
|
-
function hashJson(value) {
|
|
45
|
-
return hashContent(Buffer.from(JSON.stringify(canonicalize(value) ?? null)));
|
|
46
|
-
}
|
|
47
|
-
function managedSubtree(text, keyPath) {
|
|
48
|
-
const parsed = parseJsonRecord(text);
|
|
49
|
-
if (!parsed.ok) return;
|
|
50
|
-
let cursor = parsed.value;
|
|
51
|
-
for (const key of keyPath) {
|
|
52
|
-
if (!isRecord(cursor)) return;
|
|
53
|
-
cursor = cursor[key];
|
|
54
|
-
}
|
|
55
|
-
return cursor;
|
|
56
|
-
}
|
|
57
|
-
function targetIdentityHash(path, content) {
|
|
58
|
-
const mergeKeys = mergeKeysFor(path);
|
|
59
|
-
if (!mergeKeys) return hashContent(content);
|
|
60
|
-
const text = content.toString("utf8");
|
|
61
|
-
const subtrees = {};
|
|
62
|
-
for (const keyPath of mergeKeys) subtrees[keyPath.join(".")] = managedSubtree(text, keyPath);
|
|
63
|
-
return hashJson(subtrees);
|
|
64
|
-
}
|
|
65
|
-
async function cloneHookRepository(targetDir) {
|
|
66
|
-
await execa("gh", [
|
|
67
|
-
"repo",
|
|
68
|
-
"clone",
|
|
69
|
-
DEFAULT_HOOK_INSTALL_SOURCE,
|
|
70
|
-
targetDir,
|
|
71
|
-
"--",
|
|
72
|
-
"--depth=1"
|
|
73
|
-
], { stdio: "inherit" });
|
|
74
|
-
}
|
|
75
|
-
async function withHookSource(useSource) {
|
|
76
|
-
const parent = await mkdtemp(join(tmpdir(), "moka-agent-"));
|
|
77
|
-
const source = join(parent, "agent");
|
|
78
|
-
try {
|
|
79
|
-
await cloneHookRepository(source);
|
|
80
|
-
return await useSource(source);
|
|
81
|
-
} finally {
|
|
82
|
-
await rm(parent, {
|
|
83
|
-
force: true,
|
|
84
|
-
recursive: true
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
async function listFiles(root) {
|
|
89
|
-
if (!existsSync(root)) return [];
|
|
90
|
-
if (statSync(root).isFile()) return [root];
|
|
91
|
-
const entries = await readdir(root, { withFileTypes: true });
|
|
92
|
-
return (await Promise.all(entries.map((entry) => {
|
|
93
|
-
const path = join(root, entry.name);
|
|
94
|
-
return entry.isDirectory() ? listFiles(path) : [path];
|
|
95
|
-
}))).flat();
|
|
96
|
-
}
|
|
97
|
-
async function sourceHookFiles(source) {
|
|
98
|
-
return (await Promise.all(HOOK_HOSTS.map(async (host) => {
|
|
99
|
-
const hostRoot = join(source, AGENT_HOOKS_DIR, host);
|
|
100
|
-
return (await listFiles(hostRoot)).flatMap((file) => {
|
|
101
|
-
const relativePath = relative(hostRoot, file).replaceAll("\\", "/");
|
|
102
|
-
const content = readFileSync(file);
|
|
103
|
-
const path = `${HOST_TARGET_ROOT[host]}/${relativePath}`;
|
|
104
|
-
return isHookOwnedTarget(path) ? [{
|
|
105
|
-
content,
|
|
106
|
-
hash: targetIdentityHash(path, content),
|
|
107
|
-
host,
|
|
108
|
-
path
|
|
109
|
-
}] : [];
|
|
110
|
-
});
|
|
111
|
-
}))).flat().sort((a, b) => a.path.localeCompare(b.path));
|
|
112
|
-
}
|
|
113
|
-
function manifestPath(host) {
|
|
114
|
-
return resolveHarnessTarget(`${HOST_TARGET_ROOT[host]}/${MANIFEST_FILE}`);
|
|
115
|
-
}
|
|
116
|
-
function emptyManifest() {
|
|
117
|
-
return {
|
|
118
|
-
files: {},
|
|
119
|
-
repository: DEFAULT_HOOK_INSTALL_SOURCE,
|
|
120
|
-
version: 1
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
function isHookOwnedTarget(path) {
|
|
124
|
-
return !NON_HOOK_OWNED_TARGETS.has(path);
|
|
125
|
-
}
|
|
126
|
-
function readManifest(host) {
|
|
127
|
-
const path = manifestPath(host);
|
|
128
|
-
if (!existsSync(path)) return emptyManifest();
|
|
129
|
-
try {
|
|
130
|
-
return normalizeManifest(JSON.parse(readFileSync(path, "utf8")));
|
|
131
|
-
} catch {
|
|
132
|
-
return emptyManifest();
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
function isRecord(value) {
|
|
136
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
137
|
-
}
|
|
138
|
-
function normalizeManifest(value) {
|
|
139
|
-
const files = {};
|
|
140
|
-
const manifestFiles = isRecord(value) ? value.files : void 0;
|
|
141
|
-
if (!isRecord(manifestFiles)) return emptyManifest();
|
|
142
|
-
for (const [path, entry] of Object.entries(manifestFiles)) if (isRecord(entry) && typeof entry.hash === "string") files[path] = { hash: entry.hash };
|
|
143
|
-
return {
|
|
144
|
-
files,
|
|
145
|
-
repository: DEFAULT_HOOK_INSTALL_SOURCE,
|
|
146
|
-
version: 1
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
function targetPath(path) {
|
|
150
|
-
return resolveHarnessTarget(path);
|
|
151
|
-
}
|
|
152
|
-
function actionForFile(file, force, manifests) {
|
|
153
|
-
const target = targetPath(file.path);
|
|
154
|
-
if (!existsSync(target)) return "create";
|
|
155
|
-
const currentHash = targetIdentityHash(file.path, readFileSync(target));
|
|
156
|
-
if (currentHash === file.hash) return "unchanged";
|
|
157
|
-
if (force) return "update";
|
|
158
|
-
return (manifests.get(file.host)?.files[file.path])?.hash === currentHash ? "update" : "conflict";
|
|
159
|
-
}
|
|
160
|
-
function planFiles(files, force, manifests) {
|
|
161
|
-
return files.map((file) => ({
|
|
162
|
-
...file,
|
|
163
|
-
action: actionForFile(file, force, manifests)
|
|
164
|
-
}));
|
|
165
|
-
}
|
|
166
|
-
function planObsoleteFiles(desiredPaths, force, manifests) {
|
|
167
|
-
const obsolete = [];
|
|
168
|
-
for (const [host, manifest] of manifests) for (const [path, entry] of Object.entries(manifest.files)) {
|
|
169
|
-
const planned = planObsoleteFile(host, path, entry, force, desiredPaths);
|
|
170
|
-
if (planned) obsolete.push(planned);
|
|
171
|
-
}
|
|
172
|
-
return obsolete.sort((a, b) => a.path.localeCompare(b.path));
|
|
173
|
-
}
|
|
174
|
-
function planObsoleteFile(host, path, entry, force, desiredPaths) {
|
|
175
|
-
if (desiredPaths.has(path) || !isHookOwnedTarget(path)) return;
|
|
176
|
-
const target = targetPath(path);
|
|
177
|
-
if (!existsSync(target)) return;
|
|
178
|
-
const currentHash = hashContent(readFileSync(target));
|
|
179
|
-
return {
|
|
180
|
-
action: force || currentHash === entry.hash ? "delete" : "conflict",
|
|
181
|
-
host,
|
|
182
|
-
path
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
async function writePlannedFile(file) {
|
|
186
|
-
if (file.action === "conflict" || file.action === "unchanged") return;
|
|
187
|
-
const target = targetPath(file.path);
|
|
188
|
-
await mkdir(dirname(target), { recursive: true });
|
|
189
|
-
const mergeKeys = mergeKeysFor(file.path);
|
|
190
|
-
if (mergeKeys && existsSync(target)) {
|
|
191
|
-
const sourceText = file.content.toString("utf8");
|
|
192
|
-
let merged = readFileSync(target, "utf8");
|
|
193
|
-
for (const keyPath of mergeKeys) {
|
|
194
|
-
const desired = managedSubtree(sourceText, keyPath);
|
|
195
|
-
if (desired !== void 0) merged = applyJsonEdit(merged, keyPath, desired);
|
|
196
|
-
}
|
|
197
|
-
await writeFile(target, ensureTrailingNewline(merged));
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
await writeFile(target, file.content);
|
|
201
|
-
}
|
|
202
|
-
function itemFor(file) {
|
|
203
|
-
return {
|
|
204
|
-
action: file.action,
|
|
205
|
-
host: file.host,
|
|
206
|
-
path: file.path
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
function itemForObsolete(file) {
|
|
210
|
-
return {
|
|
211
|
-
action: file.action,
|
|
212
|
-
host: file.host,
|
|
213
|
-
path: file.path
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
async function removeObsoleteFile(file) {
|
|
217
|
-
if (file.action !== "delete") return;
|
|
218
|
-
await rm(targetPath(file.path), { force: true });
|
|
219
|
-
}
|
|
220
|
-
function assertNoConflicts(items, dryRun) {
|
|
221
|
-
if (dryRun) return;
|
|
222
|
-
const conflicts = items.filter((item) => item.action === "conflict");
|
|
223
|
-
if (conflicts.length === 0) return;
|
|
224
|
-
throw new Error([
|
|
225
|
-
"Refusing to overwrite manually edited hook files.",
|
|
226
|
-
...conflicts.map((item) => `- ${item.path}`),
|
|
227
|
-
"Re-run with --force to overwrite them."
|
|
228
|
-
].join("\n"));
|
|
229
|
-
}
|
|
230
|
-
function assertCheckCurrent(items, check) {
|
|
231
|
-
if (!check) return;
|
|
232
|
-
const changed = items.filter((item) => item.action !== "unchanged");
|
|
233
|
-
if (changed.length === 0) return;
|
|
234
|
-
throw new Error(["Installed hook files are not up to date.", ...changed.map((item) => `- ${item.path}: ${item.action}`)].join("\n"));
|
|
235
|
-
}
|
|
236
|
-
async function writeManifests(files) {
|
|
237
|
-
const byHost = /* @__PURE__ */ new Map();
|
|
238
|
-
for (const host of HOOK_HOSTS) byHost.set(host, emptyManifest());
|
|
239
|
-
for (const file of files) {
|
|
240
|
-
const manifest = byHost.get(file.host);
|
|
241
|
-
if (manifest) manifest.files[file.path] = { hash: file.hash };
|
|
242
|
-
}
|
|
243
|
-
await Promise.all([...byHost.entries()].map(async ([host, manifest]) => {
|
|
244
|
-
const path = manifestPath(host);
|
|
245
|
-
if (Object.keys(manifest.files).length === 0) {
|
|
246
|
-
await rm(path, { force: true });
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
await mkdir(dirname(path), { recursive: true });
|
|
250
|
-
await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
251
|
-
}));
|
|
252
|
-
}
|
|
253
|
-
function installHooks(options = {}) {
|
|
254
|
-
return withHookSource(async (source) => {
|
|
255
|
-
const files = await sourceHookFiles(source);
|
|
256
|
-
const manifests = new Map(HOOK_HOSTS.map((host) => [host, readManifest(host)]));
|
|
257
|
-
const planned = planFiles(files, Boolean(options.force), manifests);
|
|
258
|
-
const obsolete = planObsoleteFiles(new Set(files.map((file) => file.path)), Boolean(options.force), manifests);
|
|
259
|
-
const items = [...planned.map(itemFor), ...obsolete.map(itemForObsolete)];
|
|
260
|
-
assertCheckCurrent(items, Boolean(options.check));
|
|
261
|
-
assertNoConflicts(items, Boolean(options.dryRun));
|
|
262
|
-
if (!(options.check || options.dryRun)) {
|
|
263
|
-
for (const file of planned) await writePlannedFile(file);
|
|
264
|
-
for (const file of obsolete) await removeObsoleteFile(file);
|
|
265
|
-
await writeManifests(planned);
|
|
266
|
-
}
|
|
267
|
-
return {
|
|
268
|
-
items,
|
|
269
|
-
source: DEFAULT_HOOK_INSTALL_SOURCE
|
|
270
|
-
};
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
//#endregion
|
|
274
|
-
export { installHooks };
|
package/dist/install-rules.js
DELETED
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
import { resolveHarnessTarget } from "./install-commands/shared.js";
|
|
2
|
-
import { AGENT_ASSET_SOURCE, AGENT_RULES_DIR } from "./agent-assets.js";
|
|
3
|
-
import { execa } from "execa";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import { dirname, join } from "node:path";
|
|
6
|
-
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
7
|
-
//#region src/install-rules.ts
|
|
8
|
-
const DEFAULT_RULES_INSTALL_SOURCE = AGENT_ASSET_SOURCE;
|
|
9
|
-
const RULESYNC_PACKAGE = "rulesync@8.30.1";
|
|
10
|
-
const RULESYNC_TARGETS = [
|
|
11
|
-
"claudecode",
|
|
12
|
-
"codexcli",
|
|
13
|
-
"geminicli",
|
|
14
|
-
"opencode"
|
|
15
|
-
];
|
|
16
|
-
const RULE_OUTPUTS = [
|
|
17
|
-
{
|
|
18
|
-
generatedPath: ".claude/CLAUDE.md",
|
|
19
|
-
targetPath: ".claude/CLAUDE.md"
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
generatedPath: ".codex/AGENTS.md",
|
|
23
|
-
targetPath: ".codex/AGENTS.md"
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
generatedPath: ".gemini/GEMINI.md",
|
|
27
|
-
targetPath: ".gemini/GEMINI.md"
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
generatedPath: ".config/opencode/AGENTS.md",
|
|
31
|
-
targetPath: ".opencode/AGENTS.md"
|
|
32
|
-
}
|
|
33
|
-
];
|
|
34
|
-
async function cloneRulesRepository(targetDir) {
|
|
35
|
-
await execa("gh", [
|
|
36
|
-
"repo",
|
|
37
|
-
"clone",
|
|
38
|
-
DEFAULT_RULES_INSTALL_SOURCE,
|
|
39
|
-
targetDir,
|
|
40
|
-
"--",
|
|
41
|
-
"--depth=1"
|
|
42
|
-
], { stdio: "inherit" });
|
|
43
|
-
}
|
|
44
|
-
async function withRulesSource(sourceOverride, useSource) {
|
|
45
|
-
if (sourceOverride !== void 0) return useSource(sourceOverride);
|
|
46
|
-
const parent = await mkdtemp(join(tmpdir(), "moka-agent-rules-"));
|
|
47
|
-
const source = join(parent, "agent");
|
|
48
|
-
try {
|
|
49
|
-
await cloneRulesRepository(source);
|
|
50
|
-
return await useSource(source);
|
|
51
|
-
} finally {
|
|
52
|
-
await rm(parent, {
|
|
53
|
-
force: true,
|
|
54
|
-
recursive: true
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
async function defaultRulesyncRunner(args, opts) {
|
|
59
|
-
try {
|
|
60
|
-
await execa("npx", [
|
|
61
|
-
"--yes",
|
|
62
|
-
RULESYNC_PACKAGE,
|
|
63
|
-
...args
|
|
64
|
-
], {
|
|
65
|
-
cwd: opts.cwd,
|
|
66
|
-
env: opts.env,
|
|
67
|
-
stdio: "inherit"
|
|
68
|
-
});
|
|
69
|
-
} catch (error) {
|
|
70
|
-
const cause = error instanceof Error ? `: ${error.message}` : "";
|
|
71
|
-
throw new Error(`Failed to generate global rules from ${DEFAULT_RULES_INSTALL_SOURCE}${cause}. If this is a private repository, authenticate GitHub access with \`gh auth login\` and rerun \`moka init\`.`);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
async function buildRootRule(source) {
|
|
75
|
-
const rulesDir = join(source, AGENT_RULES_DIR);
|
|
76
|
-
let entries = [];
|
|
77
|
-
try {
|
|
78
|
-
entries = (await readdir(rulesDir, { withFileTypes: true })).filter((d) => d.isFile() && d.name.endsWith(".md")).map((d) => d.name).sort((a, b) => a.localeCompare(b));
|
|
79
|
-
} catch {}
|
|
80
|
-
const rootContent = `---
|
|
81
|
-
root: true
|
|
82
|
-
targets:
|
|
83
|
-
- "*"
|
|
84
|
-
---
|
|
85
|
-
|
|
86
|
-
${(await Promise.all(entries.map(async (name) => {
|
|
87
|
-
return (await readFile(join(rulesDir, name), "utf8")).trimEnd();
|
|
88
|
-
}))).join("\n\n")}\n`;
|
|
89
|
-
const rulesyncRulesDir = join(source, ".rulesync", "rules");
|
|
90
|
-
await mkdir(rulesyncRulesDir, { recursive: true });
|
|
91
|
-
await writeFile(join(rulesyncRulesDir, "_root.md"), rootContent);
|
|
92
|
-
}
|
|
93
|
-
async function withRulesyncHome(useHome) {
|
|
94
|
-
const home = await mkdtemp(join(tmpdir(), "moka-rules-home-"));
|
|
95
|
-
try {
|
|
96
|
-
return await useHome(home);
|
|
97
|
-
} finally {
|
|
98
|
-
await rm(home, {
|
|
99
|
-
force: true,
|
|
100
|
-
recursive: true
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
function rulesyncArgs(options) {
|
|
105
|
-
const args = [
|
|
106
|
-
"generate",
|
|
107
|
-
"-t",
|
|
108
|
-
RULESYNC_TARGETS.join(","),
|
|
109
|
-
"-f",
|
|
110
|
-
"rules",
|
|
111
|
-
"--delete",
|
|
112
|
-
"--global"
|
|
113
|
-
];
|
|
114
|
-
if (options.dryRun) args.push("--dry-run");
|
|
115
|
-
if (options.silent) args.push("--silent");
|
|
116
|
-
return args;
|
|
117
|
-
}
|
|
118
|
-
async function runRulesyncGenerate(input) {
|
|
119
|
-
await input.runner(rulesyncArgs(input), {
|
|
120
|
-
cwd: input.source,
|
|
121
|
-
env: {
|
|
122
|
-
...process.env,
|
|
123
|
-
HOME_DIR: input.home
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
function ruleItems(action) {
|
|
128
|
-
return RULE_OUTPUTS.map((output) => ({
|
|
129
|
-
action,
|
|
130
|
-
path: resolveHarnessTarget(output.targetPath)
|
|
131
|
-
}));
|
|
132
|
-
}
|
|
133
|
-
function readGeneratedRuleFiles(home) {
|
|
134
|
-
return Promise.all(RULE_OUTPUTS.map(async (output) => ({
|
|
135
|
-
content: await readFile(join(home, output.generatedPath), "utf8"),
|
|
136
|
-
path: resolveHarnessTarget(output.targetPath)
|
|
137
|
-
})));
|
|
138
|
-
}
|
|
139
|
-
async function writeGeneratedRuleFiles(files) {
|
|
140
|
-
for (const file of files) {
|
|
141
|
-
await mkdir(dirname(file.path), { recursive: true });
|
|
142
|
-
await writeFile(file.path, file.content);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
async function installedRuleAction(file) {
|
|
146
|
-
try {
|
|
147
|
-
return await readFile(file.path, "utf8") === file.content ? "unchanged" : "update";
|
|
148
|
-
} catch {
|
|
149
|
-
return "create";
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
async function assertInstalledRulesCurrent(files) {
|
|
153
|
-
const changed = (await Promise.all(files.map(async (file) => ({
|
|
154
|
-
action: await installedRuleAction(file),
|
|
155
|
-
path: file.path
|
|
156
|
-
})))).filter((item) => item.action !== "unchanged");
|
|
157
|
-
if (changed.length === 0) return;
|
|
158
|
-
throw new Error(["Installed rule files are not up to date.", ...changed.map((item) => `- ${item.path}: ${item.action}`)].join("\n"));
|
|
159
|
-
}
|
|
160
|
-
function installRules(options = {}) {
|
|
161
|
-
const runner = options.rulesyncRunner ?? defaultRulesyncRunner;
|
|
162
|
-
return withRulesSource(options.sourceOverride, async (source) => {
|
|
163
|
-
await buildRootRule(source);
|
|
164
|
-
return withRulesyncHome(async (home) => {
|
|
165
|
-
if (options.dryRun) {
|
|
166
|
-
await runRulesyncGenerate({
|
|
167
|
-
dryRun: true,
|
|
168
|
-
home,
|
|
169
|
-
runner,
|
|
170
|
-
source
|
|
171
|
-
});
|
|
172
|
-
return {
|
|
173
|
-
items: ruleItems("skip"),
|
|
174
|
-
source: DEFAULT_RULES_INSTALL_SOURCE
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
await runRulesyncGenerate({
|
|
178
|
-
home,
|
|
179
|
-
runner,
|
|
180
|
-
silent: options.check,
|
|
181
|
-
source
|
|
182
|
-
});
|
|
183
|
-
const files = await readGeneratedRuleFiles(home);
|
|
184
|
-
if (options.check) {
|
|
185
|
-
await assertInstalledRulesCurrent(files);
|
|
186
|
-
return {
|
|
187
|
-
items: ruleItems("skip"),
|
|
188
|
-
source: DEFAULT_RULES_INSTALL_SOURCE
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
await writeGeneratedRuleFiles(files);
|
|
192
|
-
return {
|
|
193
|
-
items: ruleItems("generate"),
|
|
194
|
-
source: DEFAULT_RULES_INSTALL_SOURCE
|
|
195
|
-
};
|
|
196
|
-
});
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
//#endregion
|
|
200
|
-
export { installRules };
|