@lchase/kiln 0.0.0-stage → 0.3.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.js ADDED
@@ -0,0 +1,409 @@
1
+ #!/usr/bin/env node
2
+ import { addRepo, renderRepoList, resolveKilnConfig } from "./config.js";
3
+ import { answerJob, runStatus, runTick } from "./driver.js";
4
+ import { startServe } from "./serve.js";
5
+ import { addFromFile } from "./intake/file.js";
6
+ import { addFromJira } from "./intake/jira.js";
7
+ import { addFromPrompt } from "./intake/prompt.js";
8
+ import { importTickets } from "./intake/tickets.js";
9
+ import { renderAddedJob } from "./jobs.js";
10
+ function usage() {
11
+ console.error([
12
+ "Usage: kiln repo add <name> <path> [--test <cmd>] [--deliver local|pr] [--base <branch>] [--config <file>]",
13
+ " kiln repo list [--config <file>]",
14
+ " kiln add <workflow> --repo <name> (--spec <file.md> | --prompt <text> | --jira <KEY>) [--title <text>] [--after <jobId>]... [--stack] [--dry-run] [--config <file>]",
15
+ " kiln import <dir> --repo <name> [--workflow <name>] [--context <file.md>] [--stack] [--dry-run] [--config <file>]",
16
+ " kiln tick [--config <file>]",
17
+ " kiln status [jobId] [--config <file>]",
18
+ " kiln approve <jobId> [--config <file>]",
19
+ " kiln reject <jobId> [--config <file>]",
20
+ " kiln serve [--port 4100] [--tick <seconds>|--no-tick] [--config <file>]",
21
+ "",
22
+ "repo add registers a checkout. A relative path is stored absolute, resolved",
23
+ "from the working directory. ~ is expanded from the home directory.",
24
+ "",
25
+ "Config, jobs, logs, and deliveries live in one kiln home: --config <file>",
26
+ "if given, otherwise $KILN_HOME/kiln.config.json when KILN_HOME is set,",
27
+ "otherwise ~/.kiln/kiln.config.json. repo add creates that directory.",
28
+ "Run kiln repo add <name> <path> from any directory. A leftover",
29
+ "./.kiln/kiln.config.json is an error until you mv ./.kiln ~/.kiln,",
30
+ "pass --config <file>, or set KILN_HOME.",
31
+ "",
32
+ "add writes one job from a spec file, from --prompt text (--prompt - reads stdin),",
33
+ "or from a Jira Cloud issue (--jira PROJ-123). Pass one of those, not more.",
34
+ "The repo and the workflow (workflows/<name>.yaml) must already exist.",
35
+ "--after names jobs that must be done before this one launches. --stack waits",
36
+ "for one upstream to land and opens the pull request against that branch.",
37
+ "--stack cannot target an investigation job: that workflow does not land a branch.",
38
+ "A Jira Blocks link where this issue is blocked by another key is included",
39
+ "when a kiln job for that key already exists.",
40
+ "--dry-run with --jira prints the job and writes nothing. Running add again",
41
+ "for the same Jira key prints the existing job id and creates nothing.",
42
+ "",
43
+ "import reads a directory of NN-slug.md tickets and writes one job per file.",
44
+ "Blocked-by becomes dependsOn. Re-running the same directory does not duplicate jobs.",
45
+ "--dry-run prints the jobs and writes nothing.",
46
+ "",
47
+ "tick launches at most one ready job. After merged, dependencies must be done.",
48
+ "Stacked jobs launch once the upstream has landed. deliver=pr then pushes",
49
+ "yak/<run-id> and opens a draft pull request. An investigation is done when",
50
+ "its gate is approved. It does not open a pull request. status prints the",
51
+ "derived state, and the findings path once an investigation is approved.",
52
+ "approve and reject answer the human gate.",
53
+ "",
54
+ "serve listens on 127.0.0.1. The inbox lists needs-human jobs. The board",
55
+ "groups every job by the same derived state as status. Approve and reject",
56
+ "call the same functions as the CLI. POST from another origin is rejected.",
57
+ "A tick runs every 30 seconds unless --no-tick is set, using the tick lock.",
58
+ ].join("\n"));
59
+ process.exit(2);
60
+ }
61
+ function parseFlags(argv) {
62
+ const bag = { positionals: [], after: [], dryRun: false, stack: false, noTick: false };
63
+ for (let i = 0; i < argv.length; i += 1) {
64
+ const flag = argv[i];
65
+ const next = argv[i + 1];
66
+ if (flag === "--config") {
67
+ if (!next || next.startsWith("--"))
68
+ usage();
69
+ bag.configPath = next;
70
+ i += 1;
71
+ continue;
72
+ }
73
+ if (flag === "--test") {
74
+ if (!next || next.startsWith("--"))
75
+ usage();
76
+ bag.test = next;
77
+ i += 1;
78
+ continue;
79
+ }
80
+ if (flag === "--repo") {
81
+ if (!next || next.startsWith("--"))
82
+ usage();
83
+ bag.repo = next;
84
+ i += 1;
85
+ continue;
86
+ }
87
+ if (flag === "--spec") {
88
+ if (!next || next.startsWith("--"))
89
+ usage();
90
+ bag.spec = next;
91
+ i += 1;
92
+ continue;
93
+ }
94
+ if (flag === "--prompt") {
95
+ if (!next || next.startsWith("--"))
96
+ usage();
97
+ bag.prompt = next;
98
+ i += 1;
99
+ continue;
100
+ }
101
+ if (flag === "--jira") {
102
+ if (!next || next.startsWith("--"))
103
+ usage();
104
+ bag.jira = next;
105
+ i += 1;
106
+ continue;
107
+ }
108
+ if (flag === "--workflow") {
109
+ if (!next || next.startsWith("--"))
110
+ usage();
111
+ bag.workflow = next;
112
+ i += 1;
113
+ continue;
114
+ }
115
+ if (flag === "--context") {
116
+ if (!next || next.startsWith("--"))
117
+ usage();
118
+ bag.context = next;
119
+ i += 1;
120
+ continue;
121
+ }
122
+ if (flag === "--dry-run") {
123
+ bag.dryRun = true;
124
+ continue;
125
+ }
126
+ if (flag === "--title") {
127
+ if (!next || next.startsWith("--"))
128
+ usage();
129
+ bag.title = next;
130
+ i += 1;
131
+ continue;
132
+ }
133
+ if (flag === "--after") {
134
+ if (!next || next.startsWith("--"))
135
+ usage();
136
+ bag.after.push(next);
137
+ i += 1;
138
+ continue;
139
+ }
140
+ if (flag === "--stack") {
141
+ bag.stack = true;
142
+ continue;
143
+ }
144
+ if (flag === "--deliver") {
145
+ if (next !== "local" && next !== "pr")
146
+ usage();
147
+ bag.deliver = next;
148
+ i += 1;
149
+ continue;
150
+ }
151
+ if (flag === "--base") {
152
+ if (!next || next.startsWith("--"))
153
+ usage();
154
+ bag.base = next;
155
+ i += 1;
156
+ continue;
157
+ }
158
+ if (flag === "--port") {
159
+ if (!next || !/^\d+$/.test(next))
160
+ usage();
161
+ const port = Number(next);
162
+ if (port > 65535)
163
+ usage();
164
+ bag.port = port;
165
+ i += 1;
166
+ continue;
167
+ }
168
+ if (flag === "--tick") {
169
+ if (!next || !/^[1-9]\d*$/.test(next))
170
+ usage();
171
+ const seconds = Number(next);
172
+ if (seconds > 86_400)
173
+ usage();
174
+ bag.tickSeconds = seconds;
175
+ i += 1;
176
+ continue;
177
+ }
178
+ if (flag === "--no-tick") {
179
+ bag.noTick = true;
180
+ continue;
181
+ }
182
+ if (flag?.startsWith("--"))
183
+ usage();
184
+ if (flag !== undefined)
185
+ bag.positionals.push(flag);
186
+ }
187
+ return bag;
188
+ }
189
+ function rejectServeFlags(flags) {
190
+ if (flags.port !== undefined || flags.tickSeconds !== undefined || flags.noTick)
191
+ usage();
192
+ }
193
+ function repoCommand(argv) {
194
+ const sub = argv[0];
195
+ const flags = parseFlags(argv.slice(1));
196
+ rejectServeFlags(flags);
197
+ if (sub === "list") {
198
+ if (flags.positionals.length !== 0 || hasIntakeFlags(flags) || flags.test || flags.deliver || flags.base)
199
+ usage();
200
+ const config = resolveKilnConfig({ configPath: flags.configPath });
201
+ process.stdout.write(renderRepoList(config));
202
+ return;
203
+ }
204
+ if (sub === "add") {
205
+ const name = flags.positionals[0];
206
+ const repoPath = flags.positionals[1];
207
+ if (!name || !repoPath || flags.positionals.length !== 2 || hasIntakeFlags(flags))
208
+ usage();
209
+ process.stdout.write(addRepo({
210
+ configPath: flags.configPath,
211
+ name,
212
+ repoPath,
213
+ ...(flags.test !== undefined ? { test: flags.test } : {}),
214
+ ...(flags.deliver !== undefined ? { deliver: flags.deliver } : {}),
215
+ ...(flags.base !== undefined ? { base: flags.base } : {}),
216
+ }));
217
+ return;
218
+ }
219
+ usage();
220
+ }
221
+ async function addCommand(argv) {
222
+ const flags = parseFlags(argv);
223
+ rejectServeFlags(flags);
224
+ const workflow = flags.positionals[0];
225
+ const selected = [flags.spec !== undefined, flags.prompt !== undefined, flags.jira !== undefined].filter(Boolean).length;
226
+ if (!workflow ||
227
+ flags.positionals.length !== 1 ||
228
+ !flags.repo ||
229
+ selected !== 1 ||
230
+ flags.test ||
231
+ flags.workflow ||
232
+ flags.context ||
233
+ flags.deliver ||
234
+ flags.base ||
235
+ (flags.dryRun && flags.jira === undefined)) {
236
+ usage();
237
+ }
238
+ const config = resolveKilnConfig({ configPath: flags.configPath });
239
+ if (flags.jira !== undefined) {
240
+ const result = await addFromJira(config, {
241
+ workflow,
242
+ repo: flags.repo,
243
+ key: flags.jira,
244
+ ...(flags.title !== undefined ? { title: flags.title } : {}),
245
+ dependsOn: flags.after,
246
+ ...(flags.stack ? { stack: true } : {}),
247
+ dryRun: flags.dryRun,
248
+ });
249
+ process.stdout.write(result.report);
250
+ if (result.warnings.length > 0)
251
+ process.stderr.write(`${result.warnings.join("\n")}\n`);
252
+ return;
253
+ }
254
+ const { job, jsonPath } = flags.spec !== undefined
255
+ ? addFromFile(config, {
256
+ workflow,
257
+ repo: flags.repo,
258
+ specPath: flags.spec,
259
+ ...(flags.title !== undefined ? { title: flags.title } : {}),
260
+ dependsOn: flags.after,
261
+ ...(flags.stack ? { stack: true } : {}),
262
+ cwd: process.cwd(),
263
+ })
264
+ : addFromPrompt(config, {
265
+ workflow,
266
+ repo: flags.repo,
267
+ prompt: flags.prompt ?? "",
268
+ ...(flags.title !== undefined ? { title: flags.title } : {}),
269
+ dependsOn: flags.after,
270
+ ...(flags.stack ? { stack: true } : {}),
271
+ });
272
+ process.stdout.write(renderAddedJob(job, jsonPath));
273
+ }
274
+ function importCommand(argv) {
275
+ const flags = parseFlags(argv);
276
+ rejectServeFlags(flags);
277
+ const dir = flags.positionals[0];
278
+ if (!dir ||
279
+ flags.positionals.length !== 1 ||
280
+ !flags.repo ||
281
+ flags.spec ||
282
+ flags.prompt ||
283
+ flags.title ||
284
+ flags.after.length > 0 ||
285
+ flags.test ||
286
+ flags.deliver ||
287
+ flags.base) {
288
+ usage();
289
+ }
290
+ const config = resolveKilnConfig({ configPath: flags.configPath });
291
+ const result = importTickets(config, {
292
+ dir,
293
+ repo: flags.repo,
294
+ ...(flags.workflow !== undefined ? { workflow: flags.workflow } : {}),
295
+ ...(flags.context !== undefined ? { contextPath: flags.context } : {}),
296
+ dryRun: flags.dryRun,
297
+ ...(flags.stack ? { stack: true } : {}),
298
+ cwd: process.cwd(),
299
+ });
300
+ process.stdout.write(result.report);
301
+ if (result.warnings.length > 0)
302
+ process.stderr.write(`${result.warnings.join("\n")}\n`);
303
+ }
304
+ function tickCommand(argv) {
305
+ const flags = parseFlags(argv);
306
+ rejectServeFlags(flags);
307
+ if (flags.positionals.length !== 0 || flags.test || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
308
+ usage();
309
+ }
310
+ const config = resolveKilnConfig({ configPath: flags.configPath });
311
+ process.stdout.write(runTick(config));
312
+ }
313
+ function statusCommand(argv) {
314
+ const flags = parseFlags(argv);
315
+ rejectServeFlags(flags);
316
+ if (flags.positionals.length > 1 || flags.test || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
317
+ usage();
318
+ }
319
+ const config = resolveKilnConfig({ configPath: flags.configPath });
320
+ process.stdout.write(runStatus(config, flags.positionals[0]));
321
+ }
322
+ function gateCommand(choice, argv) {
323
+ const flags = parseFlags(argv);
324
+ rejectServeFlags(flags);
325
+ const jobId = flags.positionals[0];
326
+ if (!jobId || flags.positionals.length !== 1 || flags.test || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
327
+ usage();
328
+ }
329
+ const config = resolveKilnConfig({ configPath: flags.configPath });
330
+ process.stdout.write(answerJob(config, jobId, choice));
331
+ }
332
+ async function serveCommand(argv) {
333
+ const flags = parseFlags(argv);
334
+ if (flags.positionals.length !== 0 ||
335
+ flags.test ||
336
+ hasIntakeFlags(flags) ||
337
+ flags.deliver ||
338
+ flags.base ||
339
+ flags.stack ||
340
+ (flags.noTick && flags.tickSeconds !== undefined)) {
341
+ usage();
342
+ }
343
+ const config = resolveKilnConfig({ configPath: flags.configPath });
344
+ const port = flags.port ?? 4100;
345
+ const tickSeconds = flags.noTick ? undefined : (flags.tickSeconds ?? 30);
346
+ await startServe({
347
+ config,
348
+ port,
349
+ ...(tickSeconds !== undefined ? { tickSeconds } : {}),
350
+ });
351
+ }
352
+ function hasIntakeFlags(flags) {
353
+ return Boolean(flags.repo ||
354
+ flags.spec ||
355
+ flags.prompt ||
356
+ flags.jira ||
357
+ flags.title ||
358
+ flags.workflow ||
359
+ flags.context ||
360
+ flags.dryRun ||
361
+ flags.stack ||
362
+ flags.after.length > 0);
363
+ }
364
+ async function main() {
365
+ const argv = process.argv.slice(2);
366
+ const command = argv[0];
367
+ if (!command || command === "--help" || command === "-h" || command === "help")
368
+ usage();
369
+ if (command === "repo") {
370
+ repoCommand(argv.slice(1));
371
+ return;
372
+ }
373
+ if (command === "add") {
374
+ await addCommand(argv.slice(1));
375
+ return;
376
+ }
377
+ if (command === "import") {
378
+ importCommand(argv.slice(1));
379
+ return;
380
+ }
381
+ if (command === "tick") {
382
+ tickCommand(argv.slice(1));
383
+ return;
384
+ }
385
+ if (command === "status") {
386
+ statusCommand(argv.slice(1));
387
+ return;
388
+ }
389
+ if (command === "approve") {
390
+ gateCommand("approve", argv.slice(1));
391
+ return;
392
+ }
393
+ if (command === "reject") {
394
+ gateCommand("reject", argv.slice(1));
395
+ return;
396
+ }
397
+ if (command === "serve") {
398
+ await serveCommand(argv.slice(1));
399
+ return;
400
+ }
401
+ usage();
402
+ }
403
+ try {
404
+ await main();
405
+ }
406
+ catch (error) {
407
+ console.error(error instanceof Error ? error.message : String(error));
408
+ process.exit(1);
409
+ }
package/dist/config.js ADDED
@@ -0,0 +1,255 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { KilnConfigError } from "./errors.js";
5
+ import { KilnConfigFileSchema } from "./schemas.js";
6
+ const CONFIG_NAME = "kiln.config.json";
7
+ const REPO_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
8
+ export function loadKilnConfig(configPath, options = {}) {
9
+ const home = options.home ?? homedir();
10
+ const resolvedPath = resolve(configPath);
11
+ let raw;
12
+ try {
13
+ raw = JSON.parse(readFileSync(resolvedPath, "utf8"));
14
+ }
15
+ catch (error) {
16
+ const message = error instanceof Error ? error.message : String(error);
17
+ throw new KilnConfigError(`Could not read kiln config ${resolvedPath}: ${message}`);
18
+ }
19
+ const parsed = KilnConfigFileSchema.safeParse(raw);
20
+ if (!parsed.success) {
21
+ throw new KilnConfigError(`Kiln config ${resolvedPath} is invalid: ${parsed.error.message}`);
22
+ }
23
+ const configDir = dirname(resolvedPath);
24
+ const repos = {};
25
+ for (const [name, entry] of Object.entries(parsed.data.repos)) {
26
+ assertRepoName(name);
27
+ const checkout = resolveConfiguredPath(configDir, entry.path, home);
28
+ if (!existsSync(checkout) || !statSync(checkout).isDirectory()) {
29
+ throw new KilnConfigError(`Checkout for ${name} does not exist or is not a directory: ${checkout}. Set repos.${name}.path in the kiln config.`);
30
+ }
31
+ repos[name] = {
32
+ path: checkout,
33
+ ...(entry.test ? { test: entry.test } : {}),
34
+ deliver: entry.deliver ?? "local",
35
+ ...(entry.base ? { base: entry.base } : {}),
36
+ };
37
+ }
38
+ const kilnDir = configDir;
39
+ const jira = jiraFromFile(parsed.data.jira);
40
+ return {
41
+ configPath: resolvedPath,
42
+ configDir,
43
+ note: parsed.data.note,
44
+ repos,
45
+ kilnDir,
46
+ jobsDir: join(kilnDir, "jobs"),
47
+ maxConcurrent: parsed.data.maxConcurrent ?? 2,
48
+ adapter: parsed.data.adapter ?? "claude-code",
49
+ ...(jira ? { jira } : {}),
50
+ };
51
+ }
52
+ /**
53
+ * One kiln home: `--config <file>` if given, else `$KILN_HOME/kiln.config.json`
54
+ * when `KILN_HOME` is set, else `~/.kiln/kiln.config.json`.
55
+ */
56
+ export function resolveKilnConfig(options = {}) {
57
+ const cwd = options.cwd ?? process.cwd();
58
+ const home = options.home ?? homedir();
59
+ if (options.configPath) {
60
+ return loadKilnConfig(resolve(cwd, options.configPath), { cwd, home });
61
+ }
62
+ const configPath = defaultConfigPath(options);
63
+ assertNoStaleCwdConfig(cwd, configPath);
64
+ if (!existsSync(configPath)) {
65
+ throw new KilnConfigError([
66
+ "No kiln config found.",
67
+ `Run kiln repo add <name> <path> from any directory to create ${configPath}.`,
68
+ "Set KILN_HOME or pass --config <file> to use a different kiln home.",
69
+ "Do not commit checkout paths.",
70
+ ].join("\n"));
71
+ }
72
+ return loadKilnConfig(configPath, { cwd, home });
73
+ }
74
+ const BRANCH_NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
75
+ /** Create or update one repo entry. Returns the text to print. */
76
+ export function addRepo(options) {
77
+ const cwd = options.cwd ?? process.cwd();
78
+ const home = options.home ?? homedir();
79
+ assertRepoName(options.name);
80
+ if (options.test !== undefined && !options.test.trim()) {
81
+ throw new KilnConfigError("The test command is empty. Omit --test, or pass a command.");
82
+ }
83
+ if (options.base !== undefined)
84
+ assertBranchName(options.base);
85
+ const configPath = configPathForWrite(options);
86
+ const configDir = dirname(configPath);
87
+ const storedPath = pathToStore(options.repoPath, cwd, home);
88
+ const checkout = resolveConfiguredPath(configDir, storedPath, home);
89
+ if (!existsSync(checkout) || !statSync(checkout).isDirectory()) {
90
+ throw new KilnConfigError(`Checkout for ${options.name} does not exist or is not a directory: ${checkout}.`);
91
+ }
92
+ const raw = readConfigObject(configPath);
93
+ const repos = repoMap(raw);
94
+ const previous = repos[options.name];
95
+ const entry = { path: storedPath };
96
+ const previousTest = previousString(previous, "test");
97
+ const test = options.test ?? previousTest;
98
+ if (test)
99
+ entry.test = test;
100
+ const deliver = options.deliver ?? previousDeliver(previous);
101
+ if (deliver)
102
+ entry.deliver = deliver;
103
+ const base = options.base ?? previousString(previous, "base");
104
+ if (base)
105
+ entry.base = base;
106
+ repos[options.name] = entry;
107
+ raw.repos = repos;
108
+ const parsed = KilnConfigFileSchema.safeParse(raw);
109
+ if (!parsed.success) {
110
+ throw new KilnConfigError(`Kiln config ${configPath} is invalid: ${parsed.error.message}`);
111
+ }
112
+ mkdirSync(configDir, { recursive: true });
113
+ writeFileSync(configPath, `${JSON.stringify(raw, null, 2)}\n`);
114
+ const lines = [`added ${options.name}`, `path ${checkout}`];
115
+ if (entry.test)
116
+ lines.push(`test ${entry.test}`);
117
+ if (entry.deliver)
118
+ lines.push(`deliver ${entry.deliver}`);
119
+ if (entry.base)
120
+ lines.push(`base ${entry.base}`);
121
+ lines.push(`wrote ${configPath}`);
122
+ return `${lines.join("\n")}\n`;
123
+ }
124
+ export function renderRepoList(config) {
125
+ const names = Object.keys(config.repos).sort();
126
+ if (names.length === 0)
127
+ return "no repos\n";
128
+ const lines = names.map((name) => {
129
+ const repo = config.repos[name];
130
+ if (!repo)
131
+ return name;
132
+ const bits = [name, repo.path];
133
+ if (repo.test)
134
+ bits.push(`test ${repo.test}`);
135
+ if (repo.deliver === "pr")
136
+ bits.push("deliver pr");
137
+ if (repo.base)
138
+ bits.push(`base ${repo.base}`);
139
+ return bits.join(" ");
140
+ });
141
+ return `${lines.join("\n")}\n`;
142
+ }
143
+ export function assertBranchName(name) {
144
+ if (!BRANCH_NAME.test(name) || name.includes("..") || name.endsWith("/") || name.includes("//")) {
145
+ throw new KilnConfigError(`--base ${JSON.stringify(name)} must be a branch name (letters, digits, dots, underscores, hyphens, and slashes).`);
146
+ }
147
+ }
148
+ export function assertRepoName(name) {
149
+ if (!REPO_NAME.test(name)) {
150
+ throw new KilnConfigError(`Repo name ${JSON.stringify(name)} must start with a letter or digit and contain only letters, digits, dots, underscores, and hyphens.`);
151
+ }
152
+ }
153
+ function defaultConfigPath(options) {
154
+ return join(kilnHomeDirectory(options), CONFIG_NAME);
155
+ }
156
+ /** `$KILN_HOME` when set, otherwise `~/.kiln`. A relative `KILN_HOME` resolves from `cwd`. */
157
+ function kilnHomeDirectory(options) {
158
+ const cwd = options.cwd ?? process.cwd();
159
+ const fromEnv = process.env.KILN_HOME;
160
+ if (typeof fromEnv === "string" && fromEnv.trim().length > 0)
161
+ return resolve(cwd, fromEnv);
162
+ const home = options.home ?? homedir();
163
+ return join(home, ".kiln");
164
+ }
165
+ function configPathForWrite(options) {
166
+ const cwd = options.cwd ?? process.cwd();
167
+ if (options.configPath)
168
+ return resolve(cwd, options.configPath);
169
+ const configPath = defaultConfigPath(options);
170
+ assertNoStaleCwdConfig(cwd, configPath);
171
+ return configPath;
172
+ }
173
+ /**
174
+ * A leftover `./.kiln/kiln.config.json` used to be a second kiln home.
175
+ * Fail instead of ignoring it when the machine home has no config yet.
176
+ */
177
+ function assertNoStaleCwdConfig(cwd, homeConfig) {
178
+ const cwdConfig = join(cwd, ".kiln", CONFIG_NAME);
179
+ if (!existsSync(cwdConfig))
180
+ return;
181
+ if (resolve(cwdConfig) === resolve(homeConfig))
182
+ return;
183
+ if (existsSync(homeConfig))
184
+ return;
185
+ throw new KilnConfigError(`Found ${cwdConfig} but no home config at ${homeConfig}. mv ./.kiln ~/.kiln or pass --config / set KILN_HOME.`);
186
+ }
187
+ function readConfigObject(configPath) {
188
+ if (!existsSync(configPath))
189
+ return { repos: {} };
190
+ let raw;
191
+ try {
192
+ raw = JSON.parse(readFileSync(configPath, "utf8"));
193
+ }
194
+ catch (error) {
195
+ const message = error instanceof Error ? error.message : String(error);
196
+ throw new KilnConfigError(`Could not read kiln config ${configPath}: ${message}`);
197
+ }
198
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
199
+ throw new KilnConfigError(`Kiln config ${configPath} must be a JSON object.`);
200
+ }
201
+ return raw;
202
+ }
203
+ function previousString(previous, key) {
204
+ if (typeof previous !== "object" || previous === null || !(key in previous))
205
+ return undefined;
206
+ const value = previous[key];
207
+ return typeof value === "string" && value.length > 0 ? value : undefined;
208
+ }
209
+ function previousDeliver(previous) {
210
+ const value = previousString(previous, "deliver");
211
+ if (value === "local" || value === "pr")
212
+ return value;
213
+ return undefined;
214
+ }
215
+ function repoMap(raw) {
216
+ const repos = raw.repos;
217
+ if (repos === undefined)
218
+ return {};
219
+ if (typeof repos !== "object" || repos === null || Array.isArray(repos)) {
220
+ throw new KilnConfigError("Kiln config repos must be an object.");
221
+ }
222
+ return repos;
223
+ }
224
+ export function resolveConfiguredPath(configDir, value, home) {
225
+ const expanded = expandHome(value, home);
226
+ return isAbsolute(expanded) ? expanded : resolve(configDir, expanded);
227
+ }
228
+ function pathToStore(userPath, cwd, home) {
229
+ if (userPath === "~" || userPath.startsWith("~/"))
230
+ return userPath;
231
+ if (isAbsolute(userPath))
232
+ return userPath;
233
+ return resolve(cwd, userPath);
234
+ }
235
+ function jiraFromFile(jira) {
236
+ if (!jira)
237
+ return undefined;
238
+ const baseUrl = jira.baseUrl?.trim();
239
+ const email = jira.email?.trim();
240
+ const apiToken = jira.apiToken?.trim();
241
+ if (!baseUrl && !email && !apiToken)
242
+ return undefined;
243
+ return {
244
+ ...(baseUrl ? { baseUrl } : {}),
245
+ ...(email ? { email } : {}),
246
+ ...(apiToken ? { apiToken } : {}),
247
+ };
248
+ }
249
+ function expandHome(value, home) {
250
+ if (value === "~")
251
+ return home;
252
+ if (value.startsWith("~/"))
253
+ return join(home, value.slice(2));
254
+ return value;
255
+ }