@lchase/kiln 0.6.1 → 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.
- package/LICENSE +21 -0
- package/README.md +45 -18
- package/assets/serve.css +93 -0
- package/assets/serve.js +844 -0
- package/dist/attention.js +5 -0
- package/dist/auth.js +48 -0
- package/dist/board.js +10 -5
- package/dist/cli.js +31 -7
- package/dist/config.js +33 -10
- package/dist/delivery.js +53 -15
- package/dist/driver.js +29 -5
- package/dist/errors.js +9 -0
- package/dist/fs-browse.js +113 -0
- package/dist/host.js +7 -1
- package/dist/intake/file.js +1 -0
- package/dist/intake/jira.js +19 -4
- package/dist/intake/prompt.js +1 -0
- package/dist/intake/tickets.js +41 -1
- package/dist/intake-http.js +201 -0
- package/dist/jobs.js +5 -1
- package/dist/pr-names.js +116 -0
- package/dist/reconcile.js +5 -2
- package/dist/reliability.js +83 -22
- package/dist/report.js +7 -2
- package/dist/schemas.js +76 -16
- package/dist/serve-page.js +157 -13
- package/dist/serve.js +254 -16
- package/dist/settings.js +204 -0
- package/dist/workflow.js +17 -1
- package/package.json +2 -1
- package/workflows/feature.yaml +7 -4
package/dist/serve.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
1
2
|
import { createServer } from "node:http";
|
|
2
|
-
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
3
5
|
import { join, resolve, sep } from "node:path";
|
|
6
|
+
import { AUTH_PREFLIGHT_PREFIX } from "./auth.js";
|
|
7
|
+
import { loadKilnConfig } from "./config.js";
|
|
4
8
|
import { answerJob, readStatus, retryJob, runTick, startupRecovery } from "./driver.js";
|
|
9
|
+
import { browse } from "./fs-browse.js";
|
|
5
10
|
import { defaultHost } from "./host.js";
|
|
6
11
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
12
|
+
import { intakeJobs } from "./intake-http.js";
|
|
7
13
|
import { packageRoot } from "./paths.js";
|
|
8
14
|
import { SANDBOX_PREFLIGHT_PREFIX } from "./sandbox.js";
|
|
9
15
|
import { renderConsole, renderMessagePage, renderTextPage } from "./serve-page.js";
|
|
16
|
+
import { readSettings, saveSettings } from "./settings.js";
|
|
17
|
+
import { listWorkflowNames } from "./workflow.js";
|
|
10
18
|
const BODY_LIMIT = 65_536;
|
|
11
19
|
const TEXT_LIMIT = 100_000;
|
|
12
20
|
const JOB_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
@@ -17,14 +25,15 @@ const JOB_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
|
17
25
|
export function startServe(options) {
|
|
18
26
|
startupRecovery(options.config);
|
|
19
27
|
const css = loadServeCss();
|
|
20
|
-
const
|
|
28
|
+
const csrf = randomBytes(32).toString("hex");
|
|
29
|
+
const state = openLiveConfig(options.config);
|
|
21
30
|
let port = options.port;
|
|
22
31
|
return new Promise((resolvePromise, reject) => {
|
|
23
32
|
const server = createServer((req, res) => {
|
|
24
|
-
void handle(req, res,
|
|
33
|
+
void handle(req, res, state, () => port, css, csrf).catch((error) => {
|
|
25
34
|
const message = error instanceof Error ? error.message : String(error);
|
|
26
35
|
const status = statusCode(error) ?? 500;
|
|
27
|
-
sendJson(res, status, { error: redact(message, secrets) }, secrets);
|
|
36
|
+
sendJson(res, status, { error: redact(message, state.secrets) }, state.secrets);
|
|
28
37
|
});
|
|
29
38
|
});
|
|
30
39
|
server.on("error", reject);
|
|
@@ -56,7 +65,8 @@ export function startServe(options) {
|
|
|
56
65
|
const tick = () => {
|
|
57
66
|
if (stopped)
|
|
58
67
|
return;
|
|
59
|
-
|
|
68
|
+
refreshLiveConfig(state);
|
|
69
|
+
safeTick(state.config, state.secrets, false);
|
|
60
70
|
};
|
|
61
71
|
firstTick = setTimeout(tick, 0);
|
|
62
72
|
timer = setInterval(tick, options.tickSeconds * 1000);
|
|
@@ -73,7 +83,7 @@ export function loadServeCss() {
|
|
|
73
83
|
function safeTick(config, secrets, startup) {
|
|
74
84
|
try {
|
|
75
85
|
const report = runTick(config, defaultHost(), { startup });
|
|
76
|
-
if (report.startsWith(SANDBOX_PREFLIGHT_PREFIX)) {
|
|
86
|
+
if (report.startsWith(SANDBOX_PREFLIGHT_PREFIX) || report.startsWith(AUTH_PREFLIGHT_PREFIX)) {
|
|
77
87
|
const tickAt = report.indexOf("\ntick ");
|
|
78
88
|
const head = tickAt === -1 ? report : report.slice(0, tickAt);
|
|
79
89
|
process.stderr.write(`${redact(head.trimEnd(), secrets)}\n`);
|
|
@@ -84,7 +94,10 @@ function safeTick(config, secrets, startup) {
|
|
|
84
94
|
process.stderr.write(`tick: ${redact(message, secrets)}\n`);
|
|
85
95
|
}
|
|
86
96
|
}
|
|
87
|
-
async function handle(req, res,
|
|
97
|
+
async function handle(req, res, state, boundPort, css, csrf) {
|
|
98
|
+
refreshLiveConfig(state);
|
|
99
|
+
const config = state.config;
|
|
100
|
+
const secrets = state.secrets;
|
|
88
101
|
const method = req.method ?? "GET";
|
|
89
102
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
90
103
|
const path = url.pathname;
|
|
@@ -93,12 +106,60 @@ async function handle(req, res, config, boundPort, css, secrets) {
|
|
|
93
106
|
res.end();
|
|
94
107
|
return;
|
|
95
108
|
}
|
|
109
|
+
if (method === "GET" && path === "/api/settings") {
|
|
110
|
+
if (!originAllowed(req, boundPort())) {
|
|
111
|
+
sendJson(res, 403, { error: "Cross-origin request rejected." }, secrets);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const result = readSettings(config.configPath);
|
|
115
|
+
if (!result.ok) {
|
|
116
|
+
sendJson(res, result.status, result.body, secrets);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
sendJson(res, 200, result.document, secrets);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (method === "GET" && path === "/api/fs") {
|
|
123
|
+
if (!originAllowed(req, boundPort())) {
|
|
124
|
+
sendJson(res, 403, { error: "Cross-origin request rejected." }, secrets);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
sendJson(res, 200, browse(homedir(), url.searchParams.get("path") ?? undefined), secrets);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
132
|
+
sendJson(res, statusCode(error) ?? 500, { error: message }, secrets);
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (method === "PUT" && path === "/api/settings") {
|
|
137
|
+
const raw = await readBody(req);
|
|
138
|
+
if (!originAllowed(req, boundPort())) {
|
|
139
|
+
sendJson(res, 403, { error: "Cross-origin request rejected." }, secrets);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (!csrfOk(csrf, req, raw)) {
|
|
143
|
+
sendJson(res, 403, { error: "CSRF token rejected." }, secrets);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
await putSettings(req, res, state, raw);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
96
149
|
if (method === "POST") {
|
|
97
|
-
await
|
|
150
|
+
const raw = await readBody(req);
|
|
98
151
|
if (!originAllowed(req, boundPort())) {
|
|
99
152
|
sendJson(res, 403, { error: "Cross-origin request rejected." }, secrets);
|
|
100
153
|
return;
|
|
101
154
|
}
|
|
155
|
+
if (!csrfOk(csrf, req, raw)) {
|
|
156
|
+
sendJson(res, 403, { error: "CSRF token rejected." }, secrets);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (path === "/api/jobs") {
|
|
160
|
+
await createJob(req, res, state, raw);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
102
163
|
const action = /^\/api\/jobs\/([^/]+)\/(approve|reject|retry)$/.exec(path);
|
|
103
164
|
if (!action?.[1] || (action[2] !== "approve" && action[2] !== "reject" && action[2] !== "retry")) {
|
|
104
165
|
sendJson(res, 404, { error: "Not found." }, secrets);
|
|
@@ -121,11 +182,11 @@ async function handle(req, res, config, boundPort, css, secrets) {
|
|
|
121
182
|
return;
|
|
122
183
|
}
|
|
123
184
|
if (path === "/" || path === "" || path === "/board") {
|
|
124
|
-
sendHtml(res, 200, renderConsole(css, readStatus(config).attention), secrets);
|
|
185
|
+
sendHtml(res, 200, renderConsole(css, readStatus(config).attention, pageOptions(state, csrf)), secrets);
|
|
125
186
|
return;
|
|
126
187
|
}
|
|
127
|
-
if (path === "/api/jobs") {
|
|
128
|
-
sendJson(res, 200,
|
|
188
|
+
if (path === "/api/jobs" || path === "/api/status") {
|
|
189
|
+
sendJson(res, 200, statusDocument(state), secrets);
|
|
129
190
|
return;
|
|
130
191
|
}
|
|
131
192
|
const apiJob = /^\/api\/jobs\/([^/]+)$/.exec(path);
|
|
@@ -238,12 +299,15 @@ function findJob(config, jobId) {
|
|
|
238
299
|
}
|
|
239
300
|
}
|
|
240
301
|
/**
|
|
302
|
+
* Host must be localhost or 127.0.0.1 on the bound port.
|
|
241
303
|
* Browsers send Origin on POST. A cross-site page cannot use this server:
|
|
242
|
-
*
|
|
304
|
+
* Origin, when present, has to be `http://127.0.0.1:<port>` or `http://localhost:<port>`.
|
|
243
305
|
* Curl and other non-browser clients omit Origin and are allowed.
|
|
244
306
|
* `Sec-Fetch-Site: cross-site` is rejected even when Origin is absent.
|
|
245
307
|
*/
|
|
246
308
|
export function originAllowed(req, port) {
|
|
309
|
+
if (!hostAllowed(req, port))
|
|
310
|
+
return false;
|
|
247
311
|
const fetchSite = req.headers["sec-fetch-site"];
|
|
248
312
|
if (fetchSite === "cross-site")
|
|
249
313
|
return false;
|
|
@@ -252,7 +316,14 @@ export function originAllowed(req, port) {
|
|
|
252
316
|
return false;
|
|
253
317
|
if (origin === undefined)
|
|
254
318
|
return true;
|
|
255
|
-
return origin === `http://127.0.0.1:${port}`;
|
|
319
|
+
return origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`;
|
|
320
|
+
}
|
|
321
|
+
function hostAllowed(req, port) {
|
|
322
|
+
const host = req.headers.host;
|
|
323
|
+
if (typeof host !== "string")
|
|
324
|
+
return false;
|
|
325
|
+
const value = host.trim().toLowerCase();
|
|
326
|
+
return value === `127.0.0.1:${port}` || value === `localhost:${port}`;
|
|
256
327
|
}
|
|
257
328
|
function redirectTarget(req) {
|
|
258
329
|
const referer = req.headers.referer;
|
|
@@ -316,16 +387,178 @@ function readInside(kilnDir, target) {
|
|
|
316
387
|
return text;
|
|
317
388
|
return `showing the last ${TEXT_LIMIT} characters\n\n${text.slice(text.length - TEXT_LIMIT)}`;
|
|
318
389
|
}
|
|
319
|
-
async function
|
|
390
|
+
async function readBody(req) {
|
|
391
|
+
const chunks = [];
|
|
320
392
|
let size = 0;
|
|
321
393
|
for await (const chunk of req) {
|
|
322
|
-
|
|
394
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
395
|
+
size += buf.length;
|
|
323
396
|
if (size > BODY_LIMIT) {
|
|
324
397
|
const error = new Error("Request body is too large.");
|
|
325
398
|
error.statusCode = 413;
|
|
326
399
|
throw error;
|
|
327
400
|
}
|
|
401
|
+
chunks.push(buf);
|
|
402
|
+
}
|
|
403
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
404
|
+
}
|
|
405
|
+
function csrfOk(expected, req, body) {
|
|
406
|
+
const header = req.headers["x-kiln-csrf"];
|
|
407
|
+
const fromHeader = typeof header === "string" ? header : undefined;
|
|
408
|
+
let fromForm;
|
|
409
|
+
if (headerValue(req.headers["content-type"]).includes("application/x-www-form-urlencoded")) {
|
|
410
|
+
fromForm = new URLSearchParams(body).get("csrf") ?? undefined;
|
|
411
|
+
}
|
|
412
|
+
const provided = fromHeader || fromForm;
|
|
413
|
+
if (!provided)
|
|
414
|
+
return false;
|
|
415
|
+
const left = Buffer.from(expected);
|
|
416
|
+
const right = Buffer.from(provided);
|
|
417
|
+
if (left.length !== right.length)
|
|
418
|
+
return false;
|
|
419
|
+
return timingSafeEqual(left, right);
|
|
420
|
+
}
|
|
421
|
+
async function putSettings(req, res, state, raw) {
|
|
422
|
+
const secrets = state.secrets;
|
|
423
|
+
if (!headerValue(req.headers["content-type"]).includes("application/json")) {
|
|
424
|
+
sendJson(res, 400, { error: "Content-Type must be application/json." }, secrets);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
let parsed;
|
|
428
|
+
if (!raw.trim()) {
|
|
429
|
+
sendJson(res, 400, { error: "Request body must be a JSON object." }, secrets);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
parsed = JSON.parse(raw);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
sendJson(res, 400, { error: "Request body is not JSON." }, secrets);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const result = saveSettings(state.config.configPath, parsed, homedir());
|
|
440
|
+
if (!result.ok) {
|
|
441
|
+
sendJson(res, result.status, result.body, secrets);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
state.mtimeMs = -1;
|
|
445
|
+
state.size = -1;
|
|
446
|
+
refreshLiveConfig(state);
|
|
447
|
+
sendJson(res, 200, result.document, state.secrets);
|
|
448
|
+
}
|
|
449
|
+
async function createJob(req, res, state, raw) {
|
|
450
|
+
const secrets = state.secrets;
|
|
451
|
+
if (!headerValue(req.headers["content-type"]).includes("application/json")) {
|
|
452
|
+
sendJson(res, 400, { error: "Content-Type must be application/json." }, secrets);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
let parsed;
|
|
456
|
+
if (!raw.trim()) {
|
|
457
|
+
sendJson(res, 400, { error: "Request body must be a JSON object." }, secrets);
|
|
458
|
+
return;
|
|
328
459
|
}
|
|
460
|
+
try {
|
|
461
|
+
parsed = JSON.parse(raw);
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
sendJson(res, 400, { error: "Request body is not JSON." }, secrets);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
const result = await intakeJobs(state.config, parsed);
|
|
469
|
+
sendJson(res, 200, result, state.secrets);
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
473
|
+
sendJson(res, statusCode(error) ?? 400, { error: message }, state.secrets);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function openLiveConfig(config) {
|
|
477
|
+
const file = fileStamp(config.configPath);
|
|
478
|
+
return {
|
|
479
|
+
config,
|
|
480
|
+
secrets: secretValues(config),
|
|
481
|
+
mtimeMs: file?.mtimeMs ?? 0,
|
|
482
|
+
size: file?.size ?? -1,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Re-read the kiln config when the file's mtime or size changed.
|
|
487
|
+
* A file that does not parse keeps the last good config and records `configError`.
|
|
488
|
+
* The failed stamp is not stored, so the next request tries again.
|
|
489
|
+
*/
|
|
490
|
+
function refreshLiveConfig(state) {
|
|
491
|
+
const path = state.config.configPath;
|
|
492
|
+
const file = fileStamp(path);
|
|
493
|
+
if (!file) {
|
|
494
|
+
state.configError = redact(`Could not read kiln config ${path}: the file is missing.`, redactSecrets(state, path));
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (file.mtimeMs === state.mtimeMs && file.size === state.size)
|
|
498
|
+
return;
|
|
499
|
+
try {
|
|
500
|
+
const next = loadKilnConfig(path);
|
|
501
|
+
state.config = next;
|
|
502
|
+
state.secrets = secretValues(next);
|
|
503
|
+
state.configError = undefined;
|
|
504
|
+
state.mtimeMs = file.mtimeMs;
|
|
505
|
+
state.size = file.size;
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
509
|
+
const named = message.includes(path) ? message : `${path}: ${message}`;
|
|
510
|
+
state.configError = redact(named, redactSecrets(state, path));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
function fileStamp(path) {
|
|
514
|
+
try {
|
|
515
|
+
const stat = statSync(path);
|
|
516
|
+
return { mtimeMs: stat.mtimeMs, size: stat.size };
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
return undefined;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
/** Last good secrets, plus a token sitting in a file that failed to load. */
|
|
523
|
+
function redactSecrets(state, configPath) {
|
|
524
|
+
const secrets = [...state.secrets];
|
|
525
|
+
const disk = tokenFromDisk(configPath);
|
|
526
|
+
if (disk && !secrets.includes(disk))
|
|
527
|
+
secrets.push(disk);
|
|
528
|
+
return secrets;
|
|
529
|
+
}
|
|
530
|
+
function tokenFromDisk(configPath) {
|
|
531
|
+
try {
|
|
532
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
533
|
+
const token = parsed.jira?.apiToken;
|
|
534
|
+
if (typeof token !== "string")
|
|
535
|
+
return undefined;
|
|
536
|
+
const trimmed = token.trim();
|
|
537
|
+
return trimmed.length >= 8 ? trimmed : undefined;
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
function repoList(config) {
|
|
544
|
+
return Object.entries(config.repos)
|
|
545
|
+
.map(([name, repo]) => ({ name, path: repo.path }))
|
|
546
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
547
|
+
}
|
|
548
|
+
function pageOptions(state, csrf) {
|
|
549
|
+
return {
|
|
550
|
+
csrf,
|
|
551
|
+
repos: repoList(state.config),
|
|
552
|
+
workflows: listWorkflowNames(),
|
|
553
|
+
...(state.configError ? { configError: state.configError } : {}),
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
function statusDocument(state) {
|
|
557
|
+
return {
|
|
558
|
+
...readStatus(state.config),
|
|
559
|
+
repos: repoList(state.config),
|
|
560
|
+
configError: state.configError ?? null,
|
|
561
|
+
};
|
|
329
562
|
}
|
|
330
563
|
function statusCode(error) {
|
|
331
564
|
if (typeof error === "object" && error !== null && "statusCode" in error) {
|
|
@@ -362,7 +595,12 @@ function redirect(res, location) {
|
|
|
362
595
|
res.end();
|
|
363
596
|
}
|
|
364
597
|
function secretValues(config) {
|
|
365
|
-
const values = [
|
|
598
|
+
const values = [
|
|
599
|
+
config.jira?.apiToken,
|
|
600
|
+
process.env.JIRA_API_TOKEN,
|
|
601
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
|
602
|
+
process.env.ANTHROPIC_API_KEY,
|
|
603
|
+
];
|
|
366
604
|
const found = [];
|
|
367
605
|
for (const value of values) {
|
|
368
606
|
const trimmed = value?.trim();
|
package/dist/settings.js
ADDED
|
@@ -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
package/workflows/feature.yaml
CHANGED
|
@@ -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
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
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:
|