cronfish 0.12.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +541 -0
  3. package/examples/.cronfish.json +17 -0
  4. package/examples/README.md +28 -0
  5. package/examples/disk-space.sh +25 -0
  6. package/examples/healthcheck.ts +27 -0
  7. package/examples/hello.md +18 -0
  8. package/examples/one-time/cleanup.ts +25 -0
  9. package/examples/one-time/reminder.md +22 -0
  10. package/package.json +44 -0
  11. package/src/alerts/dispatch.ts +134 -0
  12. package/src/alerts/index.ts +21 -0
  13. package/src/alerts/registry.ts +34 -0
  14. package/src/alerts/safe.ts +26 -0
  15. package/src/alerts/shell.ts +63 -0
  16. package/src/alerts/slack.ts +98 -0
  17. package/src/alerts/types.ts +34 -0
  18. package/src/cli.ts +1097 -0
  19. package/src/db.ts +365 -0
  20. package/src/frontmatter.ts +654 -0
  21. package/src/jobs.ts +536 -0
  22. package/src/models.ts +54 -0
  23. package/src/oneTime.ts +259 -0
  24. package/src/parsers/friendly.ts +53 -0
  25. package/src/platform/index.ts +14 -0
  26. package/src/platform/launchd.ts +564 -0
  27. package/src/prune.ts +155 -0
  28. package/src/result.ts +111 -0
  29. package/src/runner.ts +827 -0
  30. package/src/schedule.ts +188 -0
  31. package/src/state.ts +55 -0
  32. package/src/ts-shim.ts +44 -0
  33. package/src/ui/server.ts +469 -0
  34. package/src/watchdog.ts +201 -0
  35. package/templates/_examples/one-time/echo-at.md +10 -0
  36. package/templates/plist.template +35 -0
  37. package/ui/README.md +73 -0
  38. package/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  39. package/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  40. package/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  41. package/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  42. package/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  43. package/ui/dist/assets/index-DNE046Zp.js +9 -0
  44. package/ui/dist/assets/index-DmWTmu9X.css +2 -0
  45. package/ui/dist/favicon.svg +1 -0
  46. package/ui/dist/icons.svg +24 -0
  47. package/ui/dist/index.html +14 -0
@@ -0,0 +1,654 @@
1
+ // Strict YAML-subset frontmatter parser for cronfish job files.
2
+ //
3
+ // Supported scalar types: string, integer, boolean. No floats, no nulls.
4
+ // One level of nesting is supported (a key with no value followed by
5
+ // indented child key/value scalars) — used today only for `on_failure:`.
6
+ // Single-line inline arrays (`key: [a, "b c", d]`) are supported and land in
7
+ // a separate `lists` map — used by `env:` and `allowed_tools:`.
8
+ // Every key is validated against an expected type; unexpected types throw
9
+ // with file + key + expected + got.
10
+ //
11
+ // `every:` is no longer accepted — use `schedule:`.
12
+
13
+ export type Scalar = string | number | boolean;
14
+
15
+ export interface ParsedFrontmatter {
16
+ frontmatter: Record<string, Scalar>;
17
+ nested: Record<string, Record<string, Scalar>>;
18
+ lists: Record<string, string[]>;
19
+ body: string;
20
+ }
21
+
22
+ const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
23
+
24
+ export class FrontmatterError extends Error {
25
+ constructor(message: string) {
26
+ super(message);
27
+ this.name = "FrontmatterError";
28
+ }
29
+ }
30
+
31
+ function parseScalar(val: string): Scalar {
32
+ if (val === "true") return true;
33
+ if (val === "false") return false;
34
+ if (/^-?\d+$/.test(val)) return parseInt(val, 10);
35
+ return val;
36
+ }
37
+
38
+ function stripInlineComment(val: string): string {
39
+ // Strip ` #...` (space-then-hash) only when the value is unquoted.
40
+ // Quoted values keep `#` literally.
41
+ if (val.startsWith('"') || val.startsWith("'")) return val;
42
+ const idx = val.search(/\s#/);
43
+ return idx >= 0 ? val.slice(0, idx).trim() : val;
44
+ }
45
+
46
+ function unquote(val: string): string {
47
+ if (
48
+ (val.startsWith('"') && val.endsWith('"')) ||
49
+ (val.startsWith("'") && val.endsWith("'"))
50
+ ) {
51
+ return val.slice(1, -1);
52
+ }
53
+ return val;
54
+ }
55
+
56
+ function indentOf(line: string): number {
57
+ const m = line.match(/^( *)/);
58
+ return m ? m[0].length : 0;
59
+ }
60
+
61
+ // Split on commas at bracket/brace/paren depth 0, respecting quotes. Shared by
62
+ // inline-array parsing (YAML and TS config). Tool patterns like
63
+ // `Bash(git *, git status)` keep their inner commas because parens raise depth.
64
+ function splitTopLevelCommas(inner: string): string[] {
65
+ const parts: string[] = [];
66
+ let buf = "";
67
+ let s: string | null = null;
68
+ let d = 0;
69
+ for (let i = 0; i < inner.length; i++) {
70
+ const c = inner[i];
71
+ const prev = inner[i - 1];
72
+ if (s) {
73
+ buf += c;
74
+ if (c === s && prev !== "\\") s = null;
75
+ continue;
76
+ }
77
+ if (c === '"' || c === "'" || c === "`") {
78
+ s = c;
79
+ buf += c;
80
+ continue;
81
+ }
82
+ if (c === "(" || c === "[" || c === "{") d++;
83
+ else if (c === ")" || c === "]" || c === "}") d--;
84
+ if (c === "," && d === 0) {
85
+ parts.push(buf);
86
+ buf = "";
87
+ } else {
88
+ buf += c;
89
+ }
90
+ }
91
+ if (buf.trim()) parts.push(buf);
92
+ return parts;
93
+ }
94
+
95
+ // Parse a single-line inline array `[a, "b c", d]` into a string[]. Everything
96
+ // after the closing `]` (e.g. a trailing ` # comment`) is ignored. An empty
97
+ // `[]` yields `[]` — meaningfully "declared but empty" (scope to nothing),
98
+ // distinct from the key being absent.
99
+ function parseInlineList(raw: string, lineNo: number): string[] {
100
+ const t = raw.trim();
101
+ const lb = t.indexOf("[");
102
+ const rb = t.lastIndexOf("]");
103
+ if (lb < 0 || rb < lb) {
104
+ throw new FrontmatterError(
105
+ `line ${lineNo}: inline array must open with "[" and close with "]" on the same line`,
106
+ );
107
+ }
108
+ return splitTopLevelCommas(t.slice(lb + 1, rb))
109
+ .map((p) => unquote(p.trim()).trim())
110
+ .filter((p) => p.length > 0);
111
+ }
112
+
113
+ export function parseFrontmatter(raw: string): ParsedFrontmatter {
114
+ const m = raw.match(FM_RE);
115
+ if (!m) return { frontmatter: {}, nested: {}, lists: {}, body: raw };
116
+ const fm: Record<string, Scalar> = {};
117
+ const nested: Record<string, Record<string, Scalar>> = {};
118
+ const lists: Record<string, string[]> = {};
119
+ const lines = m[1].split(/\r?\n/);
120
+ for (let i = 0; i < lines.length; i++) {
121
+ const line = lines[i];
122
+ const trimmed = line.trim();
123
+ if (!trimmed || trimmed.startsWith("#")) continue;
124
+ if (indentOf(line) > 0) {
125
+ // Stray indented line outside a nested block — surface clearly.
126
+ throw new FrontmatterError(
127
+ `line ${i + 1}: unexpected indented line outside a nested block`,
128
+ );
129
+ }
130
+ const idx = trimmed.indexOf(":");
131
+ if (idx < 0) {
132
+ throw new FrontmatterError(
133
+ `line ${i + 1}: missing ":" — frontmatter is "key: value" only`,
134
+ );
135
+ }
136
+ const key = trimmed.slice(0, idx).trim();
137
+ if (!key) throw new FrontmatterError(`line ${i + 1}: empty key`);
138
+ const rawVal = trimmed.slice(idx + 1).trim();
139
+ if (key === "every") {
140
+ throw new FrontmatterError(
141
+ `key "every" is no longer supported — rename to "schedule"`,
142
+ );
143
+ }
144
+ if (rawVal === "") {
145
+ // Nested block — consume following indented lines.
146
+ const child: Record<string, Scalar> = {};
147
+ while (i + 1 < lines.length) {
148
+ const next = lines[i + 1];
149
+ const nextTrimmed = next.trim();
150
+ if (!nextTrimmed || nextTrimmed.startsWith("#")) {
151
+ i++;
152
+ continue;
153
+ }
154
+ if (indentOf(next) === 0) break;
155
+ i++;
156
+ const cIdx = nextTrimmed.indexOf(":");
157
+ if (cIdx < 0) {
158
+ throw new FrontmatterError(
159
+ `line ${i + 1}: missing ":" inside nested "${key}" block`,
160
+ );
161
+ }
162
+ const cKey = nextTrimmed.slice(0, cIdx).trim();
163
+ if (!cKey)
164
+ throw new FrontmatterError(`line ${i + 1}: empty nested key`);
165
+ const cRaw = nextTrimmed.slice(cIdx + 1).trim();
166
+ if (cRaw === "") {
167
+ throw new FrontmatterError(
168
+ `line ${i + 1}: nested key "${cKey}" needs a value (only one level of nesting supported)`,
169
+ );
170
+ }
171
+ const cleaned = unquote(stripInlineComment(cRaw));
172
+ child[cKey] = parseScalar(cleaned);
173
+ }
174
+ nested[key] = child;
175
+ continue;
176
+ }
177
+ if (rawVal.startsWith("[")) {
178
+ lists[key] = parseInlineList(rawVal, i + 1);
179
+ continue;
180
+ }
181
+ const cleaned = unquote(stripInlineComment(rawVal));
182
+ fm[key] = parseScalar(cleaned);
183
+ }
184
+ return { frontmatter: fm, nested, lists, body: m[2] };
185
+ }
186
+
187
+ // --- Shell job frontmatter parser ---
188
+ //
189
+ // Shell scripts use a comment-block frontmatter delimited by `# ---` lines.
190
+ // Each inner line is `# key: value`. The leading `# ` is stripped and the
191
+ // inner block is fed through parseFrontmatter so the same scalar rules apply.
192
+ //
193
+ // #!/bin/bash
194
+ // # ---
195
+ // # schedule: every 5 minutes
196
+ // # timeout: 30
197
+ // # ---
198
+ // echo hello
199
+
200
+ // The fence block itself — does NOT consume the leading shebang/blank lines.
201
+ const SH_FM_BLOCK_RE = /# ---\r?\n([\s\S]*?)\r?\n# ---\r?\n?/;
202
+
203
+ // Where is the start of "the top" — after a shebang if present, else 0.
204
+ function topOffset(raw: string): number {
205
+ if (!raw.startsWith("#!")) return 0;
206
+ const nl = raw.indexOf("\n");
207
+ return nl < 0 ? raw.length : nl + 1;
208
+ }
209
+
210
+ // Find the fence block only if it lives at the top of the file (immediately
211
+ // after the shebang, modulo blank lines). Returns the match index/length and
212
+ // the inner content, or null.
213
+ function findShellFmBlock(
214
+ raw: string,
215
+ ): { start: number; end: number; inner: string } | null {
216
+ const top = topOffset(raw);
217
+ const rest = raw.slice(top);
218
+ const lead = rest.match(/^(?:\s*\n)*/);
219
+ const leadLen = lead ? lead[0].length : 0;
220
+ const m = rest.slice(leadLen).match(SH_FM_BLOCK_RE);
221
+ if (!m || m.index !== 0) return null;
222
+ return {
223
+ start: top + leadLen,
224
+ end: top + leadLen + m[0].length,
225
+ inner: m[1],
226
+ };
227
+ }
228
+
229
+ export interface ParsedShellFrontmatter {
230
+ frontmatter: Record<string, Scalar>;
231
+ nested: Record<string, Record<string, Scalar>>;
232
+ lists: Record<string, string[]>;
233
+ }
234
+
235
+ export function parseShellFrontmatter(raw: string): ParsedShellFrontmatter {
236
+ const block = findShellFmBlock(raw);
237
+ if (!block) return { frontmatter: {}, nested: {}, lists: {} };
238
+ const inner = block.inner
239
+ .split(/\r?\n/)
240
+ // Strip the leading `# ` but preserve indentation after it so nested
241
+ // `# notify: x` lines come through as ` notify: x`.
242
+ .map((line) => line.replace(/^\s*#(?: |$)/, ""))
243
+ .join("\n");
244
+ const fake = `---\n${inner}\n---\n`;
245
+ const parsed = parseFrontmatter(fake);
246
+ return {
247
+ frontmatter: parsed.frontmatter,
248
+ nested: parsed.nested,
249
+ lists: parsed.lists,
250
+ };
251
+ }
252
+
253
+ // --- TS job config parser ---
254
+ //
255
+ // Reads `config = { ... }` from a TS source by hand-scanning brace depth so
256
+ // nested objects (e.g. `env: { FOO: "bar" }`) don't trip the matcher.
257
+
258
+ export interface TsJobConfigShape {
259
+ schedule?: string | number;
260
+ enabled?: boolean;
261
+ timeout?: number;
262
+ retries?: number;
263
+ concurrency?: "skip" | "queue";
264
+ model?: string;
265
+ description?: string;
266
+ missed_after?: string;
267
+ on_failure?: Record<string, Scalar>;
268
+ // scoped secrets / capability fence (string arrays)
269
+ env?: string[];
270
+ // one-time jobs (cron/one-time/)
271
+ run_at?: string | number;
272
+ grace_seconds?: number;
273
+ executed_at?: string;
274
+ }
275
+
276
+ function extractConfigBlock(source: string): string | null {
277
+ const re = /\bconfig\b\s*(?::\s*[^=]+)?=\s*\{/g;
278
+ const m = re.exec(source);
279
+ if (!m) return null;
280
+ const start = m.index + m[0].length;
281
+ let depth = 1;
282
+ let inStr: string | null = null;
283
+ for (let i = start; i < source.length; i++) {
284
+ const c = source[i];
285
+ const prev = source[i - 1];
286
+ if (inStr) {
287
+ if (c === inStr && prev !== "\\") inStr = null;
288
+ continue;
289
+ }
290
+ if (c === '"' || c === "'" || c === "`") {
291
+ inStr = c;
292
+ continue;
293
+ }
294
+ if (c === "{") depth++;
295
+ else if (c === "}") {
296
+ depth--;
297
+ if (depth === 0) return source.slice(start, i);
298
+ }
299
+ }
300
+ return null;
301
+ }
302
+
303
+ function pickFromConfig(body: string, key: string): string | undefined {
304
+ // Match `key:` only at the top level (depth 0) of the config block.
305
+ let depth = 0;
306
+ let inStr: string | null = null;
307
+ const re = new RegExp(`\\b${key}\\b`, "g");
308
+ let m: RegExpExecArray | null;
309
+ while ((m = re.exec(body))) {
310
+ let d = 0;
311
+ let s: string | null = null;
312
+ for (let i = 0; i < m.index; i++) {
313
+ const c = body[i];
314
+ const prev = body[i - 1];
315
+ if (s) {
316
+ if (c === s && prev !== "\\") s = null;
317
+ continue;
318
+ }
319
+ if (c === '"' || c === "'" || c === "`") {
320
+ s = c;
321
+ continue;
322
+ }
323
+ if (c === "{") d++;
324
+ else if (c === "}") d--;
325
+ }
326
+ if (d !== 0 || s !== null) continue;
327
+ // After the key, expect ":"
328
+ const after = body.slice(m.index + key.length).match(/^\s*:\s*([^,\n}]+)/);
329
+ if (!after) continue;
330
+ return after[1]
331
+ .trim()
332
+ .replace(/[,;]+$/, "")
333
+ .replace(/\s+as\s+(?:const|[A-Za-z_$][\w$.]*)\s*$/, "")
334
+ .trim();
335
+ }
336
+ return undefined;
337
+ void depth;
338
+ void inStr;
339
+ }
340
+
341
+ export function parseTsJobConfig(source: string): TsJobConfigShape {
342
+ const body = extractConfigBlock(source);
343
+ if (!body) return {};
344
+ const cfg: TsJobConfigShape = {};
345
+
346
+ const sched = pickFromConfig(body, "schedule");
347
+ if (pickFromConfig(body, "every") !== undefined) {
348
+ throw new FrontmatterError(
349
+ `key "every" is no longer supported — rename to "schedule"`,
350
+ );
351
+ }
352
+ if (sched !== undefined) {
353
+ const u = sched.replace(/^['"`]|['"`]$/g, "");
354
+ cfg.schedule = /^-?\d+$/.test(u) ? parseInt(u, 10) : u;
355
+ }
356
+ const enabled = pickFromConfig(body, "enabled");
357
+ if (enabled === "true") cfg.enabled = true;
358
+ else if (enabled === "false") cfg.enabled = false;
359
+ else if (enabled !== undefined) {
360
+ throw new FrontmatterError(
361
+ `enabled must be true or false, got: ${enabled}`,
362
+ );
363
+ }
364
+ const timeout = pickFromConfig(body, "timeout");
365
+ if (timeout !== undefined) {
366
+ if (!/^\d+$/.test(timeout)) {
367
+ throw new FrontmatterError(
368
+ `timeout must be a positive integer, got: ${timeout}`,
369
+ );
370
+ }
371
+ cfg.timeout = parseInt(timeout, 10);
372
+ }
373
+ const retries = pickFromConfig(body, "retries");
374
+ if (retries !== undefined) {
375
+ if (!/^\d+$/.test(retries)) {
376
+ throw new FrontmatterError(
377
+ `retries must be a non-negative integer, got: ${retries}`,
378
+ );
379
+ }
380
+ cfg.retries = parseInt(retries, 10);
381
+ }
382
+ const concurrency = pickFromConfig(body, "concurrency");
383
+ if (concurrency !== undefined) {
384
+ const c = concurrency.replace(/^['"`]|['"`]$/g, "");
385
+ if (c !== "skip" && c !== "queue") {
386
+ throw new FrontmatterError(
387
+ `concurrency must be "skip" or "queue", got: ${c}`,
388
+ );
389
+ }
390
+ cfg.concurrency = c;
391
+ }
392
+ const model = pickFromConfig(body, "model");
393
+ if (model !== undefined) {
394
+ cfg.model = model.replace(/^['"`]|['"`]$/g, "");
395
+ }
396
+ const description = pickFromConfig(body, "description");
397
+ if (description !== undefined) {
398
+ cfg.description = description.replace(/^['"`]|['"`]$/g, "");
399
+ }
400
+ const missedAfter = pickFromConfig(body, "missed_after");
401
+ if (missedAfter !== undefined) {
402
+ cfg.missed_after = missedAfter.replace(/^['"`]|['"`]$/g, "");
403
+ }
404
+ const onFailure = pickNestedObjectFromConfig(body, "on_failure");
405
+ if (onFailure !== undefined) cfg.on_failure = onFailure;
406
+ const env = pickListFromConfig(body, "env");
407
+ if (env !== undefined) cfg.env = env;
408
+ const runAt = pickFromConfig(body, "run_at");
409
+ if (runAt !== undefined) {
410
+ const u = runAt.replace(/^['"`]|['"`]$/g, "");
411
+ cfg.run_at = /^-?\d+$/.test(u) ? parseInt(u, 10) : u;
412
+ }
413
+ const graceSeconds = pickFromConfig(body, "grace_seconds");
414
+ if (graceSeconds !== undefined) {
415
+ if (!/^\d+$/.test(graceSeconds)) {
416
+ throw new FrontmatterError(
417
+ `grace_seconds must be a non-negative integer, got: ${graceSeconds}`,
418
+ );
419
+ }
420
+ cfg.grace_seconds = parseInt(graceSeconds, 10);
421
+ }
422
+ const executedAt = pickFromConfig(body, "executed_at");
423
+ if (executedAt !== undefined) {
424
+ cfg.executed_at = executedAt.replace(/^['"`]|['"`]$/g, "");
425
+ }
426
+ return cfg;
427
+ }
428
+
429
+ // Find `key: { ... }` at the top level of the config block and return the
430
+ // inner k/v pairs as scalars. Only supports flat objects with string / number /
431
+ // boolean values — same surface as the YAML nested block.
432
+ function pickNestedObjectFromConfig(
433
+ body: string,
434
+ key: string,
435
+ ): Record<string, Scalar> | undefined {
436
+ const re = new RegExp(`\\b${key}\\b\\s*:\\s*\\{`, "g");
437
+ let m: RegExpExecArray | null;
438
+ while ((m = re.exec(body))) {
439
+ // Verify top-level
440
+ let depth = 0;
441
+ let s: string | null = null;
442
+ for (let i = 0; i < m.index; i++) {
443
+ const c = body[i];
444
+ const prev = body[i - 1];
445
+ if (s) {
446
+ if (c === s && prev !== "\\") s = null;
447
+ continue;
448
+ }
449
+ if (c === '"' || c === "'" || c === "`") {
450
+ s = c;
451
+ continue;
452
+ }
453
+ if (c === "{") depth++;
454
+ else if (c === "}") depth--;
455
+ }
456
+ if (depth !== 0 || s !== null) continue;
457
+ const start = m.index + m[0].length;
458
+ let d = 1;
459
+ let str: string | null = null;
460
+ for (let i = start; i < body.length; i++) {
461
+ const c = body[i];
462
+ const prev = body[i - 1];
463
+ if (str) {
464
+ if (c === str && prev !== "\\") str = null;
465
+ continue;
466
+ }
467
+ if (c === '"' || c === "'" || c === "`") {
468
+ str = c;
469
+ continue;
470
+ }
471
+ if (c === "{") d++;
472
+ else if (c === "}") {
473
+ d--;
474
+ if (d === 0) {
475
+ const inner = body.slice(start, i);
476
+ return parseInlineObject(inner);
477
+ }
478
+ }
479
+ }
480
+ return undefined;
481
+ }
482
+ return undefined;
483
+ }
484
+
485
+ // Find `key: [ ... ]` at the top level of the config block and return the
486
+ // items as a string[]. Mirrors pickNestedObjectFromConfig but for arrays.
487
+ function pickListFromConfig(
488
+ body: string,
489
+ key: string,
490
+ ): string[] | undefined {
491
+ const re = new RegExp(`\\b${key}\\b\\s*:\\s*\\[`, "g");
492
+ let m: RegExpExecArray | null;
493
+ while ((m = re.exec(body))) {
494
+ // Verify top-level (depth 0, not inside a string).
495
+ let depth = 0;
496
+ let s: string | null = null;
497
+ for (let i = 0; i < m.index; i++) {
498
+ const c = body[i];
499
+ const prev = body[i - 1];
500
+ if (s) {
501
+ if (c === s && prev !== "\\") s = null;
502
+ continue;
503
+ }
504
+ if (c === '"' || c === "'" || c === "`") {
505
+ s = c;
506
+ continue;
507
+ }
508
+ if (c === "{" || c === "[") depth++;
509
+ else if (c === "}" || c === "]") depth--;
510
+ }
511
+ if (depth !== 0 || s !== null) continue;
512
+ const start = m.index + m[0].length;
513
+ let d = 1;
514
+ let str: string | null = null;
515
+ for (let i = start; i < body.length; i++) {
516
+ const c = body[i];
517
+ const prev = body[i - 1];
518
+ if (str) {
519
+ if (c === str && prev !== "\\") str = null;
520
+ continue;
521
+ }
522
+ if (c === '"' || c === "'" || c === "`") {
523
+ str = c;
524
+ continue;
525
+ }
526
+ if (c === "[" || c === "{") d++;
527
+ else if (c === "]" || c === "}") {
528
+ d--;
529
+ if (d === 0) {
530
+ const inner = body.slice(start, i);
531
+ return splitTopLevelCommas(inner)
532
+ .map((p) => p.trim().replace(/^['"`]|['"`]$/g, "").trim())
533
+ .filter((p) => p.length > 0);
534
+ }
535
+ }
536
+ }
537
+ return undefined;
538
+ }
539
+ return undefined;
540
+ }
541
+
542
+ function parseInlineObject(inner: string): Record<string, Scalar> {
543
+ const out: Record<string, Scalar> = {};
544
+ // Split on commas at depth 0.
545
+ const parts: string[] = [];
546
+ let buf = "";
547
+ let s: string | null = null;
548
+ let d = 0;
549
+ for (let i = 0; i < inner.length; i++) {
550
+ const c = inner[i];
551
+ const prev = inner[i - 1];
552
+ if (s) {
553
+ buf += c;
554
+ if (c === s && prev !== "\\") s = null;
555
+ continue;
556
+ }
557
+ if (c === '"' || c === "'" || c === "`") {
558
+ s = c;
559
+ buf += c;
560
+ continue;
561
+ }
562
+ if (c === "{" || c === "[") d++;
563
+ else if (c === "}" || c === "]") d--;
564
+ if (c === "," && d === 0) {
565
+ parts.push(buf);
566
+ buf = "";
567
+ } else {
568
+ buf += c;
569
+ }
570
+ }
571
+ if (buf.trim()) parts.push(buf);
572
+ for (const p of parts) {
573
+ const idx = p.indexOf(":");
574
+ if (idx < 0) continue;
575
+ const k = p.slice(0, idx).trim().replace(/^['"`]|['"`]$/g, "");
576
+ const v = p.slice(idx + 1).trim().replace(/,$/, "").trim();
577
+ if (!k) continue;
578
+ if (v === "true") out[k] = true;
579
+ else if (v === "false") out[k] = false;
580
+ else if (/^-?\d+$/.test(v)) out[k] = parseInt(v, 10);
581
+ else out[k] = v.replace(/^['"`]|['"`]$/g, "");
582
+ }
583
+ return out;
584
+ }
585
+
586
+ // Update or insert a key in a shell job's comment-block frontmatter. If no
587
+ // block exists, one is inserted after the shebang (or at the top if no
588
+ // shebang). Mirrors setFrontmatterKey but operates on `# key: value` lines
589
+ // inside `# ---` / `# ---` fences.
590
+ export function setShellFrontmatterKey(
591
+ raw: string,
592
+ key: string,
593
+ value: Scalar,
594
+ ): string {
595
+ const rendered =
596
+ typeof value === "string"
597
+ ? value
598
+ : value === true
599
+ ? "true"
600
+ : value === false
601
+ ? "false"
602
+ : String(value);
603
+ const block = findShellFmBlock(raw);
604
+ if (!block) {
605
+ const inserted = `# ---\n# ${key}: ${rendered}\n# ---\n`;
606
+ const top = topOffset(raw);
607
+ return `${raw.slice(0, top)}${inserted}${raw.slice(top)}`;
608
+ }
609
+ const innerLines = block.inner.split(/\r?\n/);
610
+ let found = false;
611
+ const next = innerLines.map((line) => {
612
+ const stripped = line.replace(/^\s*#\s?/, "");
613
+ const idx = stripped.indexOf(":");
614
+ if (idx < 0) return line;
615
+ const k = stripped.slice(0, idx).trim();
616
+ if (k !== key) return line;
617
+ found = true;
618
+ return `# ${k}: ${rendered}`;
619
+ });
620
+ if (!found) next.push(`# ${key}: ${rendered}`);
621
+ const replacement = `# ---\n${next.join("\n")}\n# ---\n`;
622
+ return `${raw.slice(0, block.start)}${replacement}${raw.slice(block.end)}`;
623
+ }
624
+
625
+ export function setFrontmatterKey(
626
+ raw: string,
627
+ key: string,
628
+ value: Scalar,
629
+ ): string {
630
+ const m = raw.match(FM_RE);
631
+ const rendered =
632
+ typeof value === "string"
633
+ ? value
634
+ : value === true
635
+ ? "true"
636
+ : value === false
637
+ ? "false"
638
+ : String(value);
639
+ if (!m) {
640
+ return `---\n${key}: ${rendered}\n---\n${raw}`;
641
+ }
642
+ const lines = m[1].split(/\r?\n/);
643
+ let found = false;
644
+ const next = lines.map((line) => {
645
+ const idx = line.indexOf(":");
646
+ if (idx < 0) return line;
647
+ const k = line.slice(0, idx).trim();
648
+ if (k !== key) return line;
649
+ found = true;
650
+ return `${k}: ${rendered}`;
651
+ });
652
+ if (!found) next.push(`${key}: ${rendered}`);
653
+ return raw.replace(FM_RE, `---\n${next.join("\n")}\n---\n${m[2] ?? ""}`);
654
+ }