@lchase/kiln 0.6.0 → 0.7.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.
@@ -0,0 +1,204 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { assertBranchName, assertCheckoutDirectory } from "./config.js";
6
+ import { DEFAULT_BRANCH_TEMPLATE, DEFAULT_PR_TITLE } from "./pr-names.js";
7
+ import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_FIX_ROUNDS, DEFAULT_STALL_TIMEOUT_MS, KilnConfigFileSchema, SettingsWriteSchema, } from "./schemas.js";
8
+ const DEFAULTS = {
9
+ deliver: "local",
10
+ prTitle: DEFAULT_PR_TITLE,
11
+ branch: DEFAULT_BRANCH_TEMPLATE,
12
+ maxAttempts: DEFAULT_MAX_ATTEMPTS,
13
+ stallTimeoutMs: DEFAULT_STALL_TIMEOUT_MS,
14
+ maxFixRounds: DEFAULT_MAX_FIX_ROUNDS,
15
+ sandboxMode: "off",
16
+ };
17
+ export function readSettings(configPath) {
18
+ const read = readConfigFile(configPath);
19
+ if ("error" in read)
20
+ return fail(409, read.error);
21
+ const parsed = KilnConfigFileSchema.safeParse(read.raw);
22
+ if (!parsed.success)
23
+ return fail(409, `Kiln config ${configPath} is invalid.`);
24
+ return { ok: true, document: project(configPath, read.revision, parsed.data) };
25
+ }
26
+ /**
27
+ * Validate `body`, merge it into the file, and write atomically.
28
+ * Unknown keys already in the file stay. A revision that does not match the
29
+ * bytes on disk is a 409 and writes nothing.
30
+ */
31
+ export function saveSettings(configPath, body, home = homedir()) {
32
+ if (!isRecord(body))
33
+ return fail(400, "Request body must be a JSON object.");
34
+ const read = readConfigFile(configPath);
35
+ if ("error" in read)
36
+ return fail(409, "The settings file changed on disk. Reload and try again.");
37
+ const claimed = body.revision;
38
+ if (typeof claimed !== "string" || claimed.length === 0) {
39
+ return fail(400, "Settings are invalid.", { revision: "Reload settings and try again." });
40
+ }
41
+ if (claimed !== read.revision) {
42
+ return fail(409, "The settings file changed on disk. Reload and try again.");
43
+ }
44
+ const parsed = SettingsWriteSchema.safeParse(body);
45
+ if (!parsed.success)
46
+ return fail(400, "Settings are invalid.", fieldErrors(parsed.error));
47
+ const { revision: _revision, ...editable } = parsed.data;
48
+ const fields = checkoutFields(dirname(configPath), editable, home);
49
+ if (Object.keys(fields).length > 0)
50
+ return fail(400, "Settings are invalid.", fields);
51
+ const next = mergeSettings(read.raw, editable);
52
+ const file = KilnConfigFileSchema.safeParse(next);
53
+ if (!file.success)
54
+ return fail(400, "Settings are invalid.", fieldErrors(file.error));
55
+ const again = readConfigFile(configPath);
56
+ if ("error" in again || again.revision !== read.revision) {
57
+ return fail(409, "The settings file changed on disk. Reload and try again.");
58
+ }
59
+ const contents = `${JSON.stringify(next, null, 2)}\n`;
60
+ writeAtomic(configPath, contents);
61
+ return { ok: true, document: project(configPath, hashContents(contents), file.data) };
62
+ }
63
+ function project(path, revision, file) {
64
+ const sandbox = editableSandbox(file.sandbox);
65
+ return {
66
+ path,
67
+ revision,
68
+ repos: file.repos,
69
+ ...(file.stallTimeoutMs !== undefined ? { stallTimeoutMs: file.stallTimeoutMs } : {}),
70
+ ...(file.maxAttempts !== undefined ? { maxAttempts: file.maxAttempts } : {}),
71
+ ...(file.maxFixRounds !== undefined ? { maxFixRounds: file.maxFixRounds } : {}),
72
+ ...(sandbox ? { sandbox } : {}),
73
+ defaults: DEFAULTS,
74
+ };
75
+ }
76
+ function editableSandbox(sandbox) {
77
+ if (!sandbox)
78
+ return undefined;
79
+ const mode = sandbox.mode;
80
+ const allowDomains = sandbox.allowDomains;
81
+ if (mode === undefined && allowDomains === undefined)
82
+ return undefined;
83
+ return {
84
+ ...(mode !== undefined ? { mode } : {}),
85
+ ...(allowDomains !== undefined ? { allowDomains } : {}),
86
+ };
87
+ }
88
+ function mergeSettings(raw, settings) {
89
+ const next = { ...raw };
90
+ const previousRepos = isRecord(raw.repos) ? raw.repos : {};
91
+ const repos = {};
92
+ for (const [name, entry] of Object.entries(settings.repos)) {
93
+ const prior = isRecord(previousRepos[name]) ? { ...previousRepos[name] } : {};
94
+ prior.path = entry.path;
95
+ setOptional(prior, "test", entry.test);
96
+ setOptional(prior, "deliver", entry.deliver);
97
+ setOptional(prior, "base", entry.base);
98
+ setOptional(prior, "prTitle", entry.prTitle);
99
+ setOptional(prior, "branch", entry.branch);
100
+ repos[name] = prior;
101
+ }
102
+ next.repos = repos;
103
+ setOptional(next, "stallTimeoutMs", settings.stallTimeoutMs);
104
+ setOptional(next, "maxAttempts", settings.maxAttempts);
105
+ setOptional(next, "maxFixRounds", settings.maxFixRounds);
106
+ if (settings.sandbox) {
107
+ const prior = isRecord(raw.sandbox) ? { ...raw.sandbox } : {};
108
+ setOptional(prior, "mode", settings.sandbox.mode);
109
+ setOptional(prior, "allowDomains", settings.sandbox.allowDomains);
110
+ if (Object.keys(prior).length === 0)
111
+ delete next.sandbox;
112
+ else
113
+ next.sandbox = prior;
114
+ }
115
+ return next;
116
+ }
117
+ function checkoutFields(configDir, settings, home) {
118
+ const fields = {};
119
+ for (const [name, entry] of Object.entries(settings.repos)) {
120
+ try {
121
+ assertCheckoutDirectory(name, configDir, entry.path, home);
122
+ }
123
+ catch (error) {
124
+ fields[`repos.${name}.path`] = error instanceof Error ? error.message : String(error);
125
+ }
126
+ if (entry.base === undefined)
127
+ continue;
128
+ try {
129
+ assertBranchName(entry.base);
130
+ }
131
+ catch (error) {
132
+ const message = error instanceof Error ? error.message : String(error);
133
+ fields[`repos.${name}.base`] = message.replace(/^--base\s+\S+\s+/, "Base ");
134
+ }
135
+ }
136
+ return fields;
137
+ }
138
+ function fieldErrors(error) {
139
+ const fields = {};
140
+ for (const issue of error.issues) {
141
+ if (issue.code === "unrecognized_keys") {
142
+ const prefix = issue.path.length ? `${issue.path.map(String).join(".")}.` : "";
143
+ for (const key of issue.keys) {
144
+ const field = `${prefix}${key}`;
145
+ if (!fields[field])
146
+ fields[field] = "Unknown field.";
147
+ }
148
+ continue;
149
+ }
150
+ const key = issue.path.length ? issue.path.map(String).join(".") : "body";
151
+ if (!fields[key])
152
+ fields[key] = issue.message;
153
+ }
154
+ return fields;
155
+ }
156
+ function setOptional(target, key, value) {
157
+ if (value === undefined)
158
+ delete target[key];
159
+ else
160
+ target[key] = value;
161
+ }
162
+ function readConfigFile(configPath) {
163
+ let text;
164
+ try {
165
+ text = readFileSync(configPath, "utf8");
166
+ }
167
+ catch {
168
+ return { error: "The settings file changed on disk. Reload and try again." };
169
+ }
170
+ let parsed;
171
+ try {
172
+ parsed = JSON.parse(text);
173
+ }
174
+ catch {
175
+ return { error: `Kiln config ${configPath} is invalid.` };
176
+ }
177
+ if (!isRecord(parsed))
178
+ return { error: `Kiln config ${configPath} must be a JSON object.` };
179
+ return { revision: hashContents(text), raw: parsed };
180
+ }
181
+ function hashContents(contents) {
182
+ return createHash("sha256").update(contents).digest("hex");
183
+ }
184
+ function writeAtomic(configPath, contents) {
185
+ const tmp = join(dirname(configPath), `.${basename(configPath)}.${process.pid}.tmp`);
186
+ try {
187
+ writeFileSync(tmp, contents, { mode: 0o600 });
188
+ renameSync(tmp, configPath);
189
+ }
190
+ catch (error) {
191
+ rmSync(tmp, { force: true });
192
+ throw error;
193
+ }
194
+ }
195
+ function fail(status, error, fields) {
196
+ return {
197
+ ok: false,
198
+ status,
199
+ body: fields && Object.keys(fields).length > 0 ? { error, fields } : { error },
200
+ };
201
+ }
202
+ function isRecord(value) {
203
+ return typeof value === "object" && value !== null && !Array.isArray(value);
204
+ }
package/dist/workflow.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { basename, join, sep } from "node:path";
4
4
  import { jobBasename, packageRoot } from "./paths.js";
5
5
  const FACTORY_DIRECTIVE = /^<!--\s*factory:\s*(prompts\/factory\/[a-z0-9-]+\.md)\s*-->/;
@@ -26,6 +26,22 @@ const INPUT_VALUES = {
26
26
  description: (input) => input.specText,
27
27
  acceptance: (input) => input.specText,
28
28
  };
29
+ /** Workflow names from `workflows/*.yaml`, sorted. */
30
+ export function listWorkflowNames() {
31
+ const dir = join(packageRoot(), "workflows");
32
+ let names = [];
33
+ try {
34
+ names = readdirSync(dir);
35
+ }
36
+ catch {
37
+ return [];
38
+ }
39
+ return names
40
+ .filter((name) => name.endsWith(".yaml") && !name.startsWith("."))
41
+ .map((name) => name.slice(0, -".yaml".length))
42
+ .filter((name) => WORKFLOW_NAME.test(name))
43
+ .sort();
44
+ }
29
45
  export function assertWorkflowName(name) {
30
46
  if (!WORKFLOW_NAME.test(name)) {
31
47
  throw new Error(`Workflow name ${JSON.stringify(name)} must start with a letter or digit and contain only letters, digits, dots, underscores, and hyphens.`);
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@lchase/kiln",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Run a workflow on any registered repo. A markdown spec is the input.",
5
+ "license": "MIT",
5
6
  "type": "module",
6
7
  "bin": {
7
8
  "kiln": "./dist/cli.js"
@@ -33,7 +34,7 @@
33
34
  "node": ">=22"
34
35
  },
35
36
  "dependencies": {
36
- "@lchase/yak": "0.7.1",
37
+ "@lchase/yak": "0.8.0",
37
38
  "zod": "^4.6.7"
38
39
  },
39
40
  "devDependencies": {
@@ -125,10 +125,13 @@ steps:
125
125
 
126
126
  # Build and verify stay in order. These four do not need each other,
127
127
  # so concurrency 4 starts them together. They have no Write or Edit.
128
- # Bash stays, because the factory prompts diff with git. Yak warns
129
- # that concurrent Bash steps share one cwd. The preambles forbid
130
- # re-running tests, builds, and any other write; git and file reads
131
- # stay. input is inherited for baseRef; it is not in needs.
128
+ # Bash stays: the factory prompts run `git diff <main>...HEAD`. The
129
+ # kiln preambles allow read-only git and forbid re-running tests,
130
+ # builds, and writes. Yak 0.8.0 counts Bash as write-capable
131
+ # (WRITE_CAPABLE_TOOLS in src/ir/validate.ts) and has no read-only
132
+ # exemption, so tick prints a concurrency warning. Do not drop Bash
133
+ # or set concurrency to 1 to silence it. That needs a yak change.
134
+ # input is inherited for baseRef; it is not in needs.
132
135
  - id: security
133
136
  needs: [spec, build, verify]
134
137
  agent: