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
package/src/cli.ts ADDED
@@ -0,0 +1,1097 @@
1
+ #!/usr/bin/env bun
2
+ // cronfish CLI. Verbs are thin wrappers; discovery lives in jobs.ts, plist
3
+ // I/O lives in platform/launchd.ts, schedule parsing in schedule.ts.
4
+
5
+ import {
6
+ existsSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ readdirSync,
10
+ rmSync,
11
+ statSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { basename, join } from "node:path";
15
+ import { setFrontmatterKey, setShellFrontmatterKey } from "./frontmatter.ts";
16
+ import { discoverJobs, findJobFile, loadJob, type JobMeta } from "./jobs.ts";
17
+ import { dispatchSchedule, type Dispatched } from "./schedule.ts";
18
+ import { resolveOneTime, writeSentinel } from "./oneTime.ts";
19
+ import { platform } from "./platform/index.ts";
20
+ import { loadState, rememberPrefix } from "./state.ts";
21
+ import { dbPath, markDeleted, openDb, upsertJob } from "./db.ts";
22
+ import {
23
+ formatBytes,
24
+ pruneLogs,
25
+ type PruneReport,
26
+ type SlugRetention,
27
+ } from "./prune.ts";
28
+ import { Database } from "bun:sqlite";
29
+ import { startUiServer } from "./ui/server.ts";
30
+
31
+ const VERSION = "0.11.0";
32
+
33
+ const CONSUMER_ROOT = process.env.CRONFISH_CONSUMER_ROOT || process.cwd();
34
+ const CRON_DIR = join(CONSUMER_ROOT, "cron");
35
+
36
+ // --- Consumer config ---
37
+
38
+ interface RetentionConfig {
39
+ max_age_days?: number;
40
+ max_runs?: number;
41
+ per_slug?: Record<string, { max_age_days?: number; max_runs?: number }>;
42
+ }
43
+
44
+ interface CronfishConfig {
45
+ bundle_prefix: string;
46
+ bun_path?: string;
47
+ retention?: RetentionConfig;
48
+ }
49
+
50
+ // Default retention for a manual `cronfish prune` when nothing is configured.
51
+ // Auto-prune on sync does NOT use this — it only runs when retention is set
52
+ // explicitly, so an unconfigured repo never silently loses logs.
53
+ const DEFAULT_PRUNE_AGE_DAYS = 30;
54
+
55
+ function asRetentionInt(label: string, val: unknown): number | undefined {
56
+ if (val === undefined) return undefined;
57
+ if (typeof val !== "number" || !Number.isInteger(val) || val < 1) {
58
+ throw new Error(`.cronfish.json: ${label} must be a positive integer`);
59
+ }
60
+ return val;
61
+ }
62
+
63
+ function parseRetention(raw: unknown): RetentionConfig | undefined {
64
+ if (raw === undefined) return undefined;
65
+ if (typeof raw !== "object" || raw === null) {
66
+ throw new Error(`.cronfish.json: retention must be an object`);
67
+ }
68
+ const r = raw as Record<string, unknown>;
69
+ const out: RetentionConfig = {
70
+ max_age_days: asRetentionInt("retention.max_age_days", r.max_age_days),
71
+ max_runs: asRetentionInt("retention.max_runs", r.max_runs),
72
+ };
73
+ if (r.per_slug !== undefined) {
74
+ if (typeof r.per_slug !== "object" || r.per_slug === null) {
75
+ throw new Error(`.cronfish.json: retention.per_slug must be an object`);
76
+ }
77
+ out.per_slug = {};
78
+ for (const [slug, v] of Object.entries(r.per_slug as object)) {
79
+ const o = (v ?? {}) as Record<string, unknown>;
80
+ out.per_slug[slug] = {
81
+ max_age_days: asRetentionInt(
82
+ `retention.per_slug.${slug}.max_age_days`,
83
+ o.max_age_days,
84
+ ),
85
+ max_runs: asRetentionInt(
86
+ `retention.per_slug.${slug}.max_runs`,
87
+ o.max_runs,
88
+ ),
89
+ };
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+
95
+ function loadConfig(): CronfishConfig {
96
+ const path = join(CONSUMER_ROOT, ".cronfish.json");
97
+ const defaultPrefix = `com.cronfish.${basename(CONSUMER_ROOT)}`;
98
+ if (!existsSync(path)) return { bundle_prefix: defaultPrefix };
99
+ let parsed: Partial<CronfishConfig>;
100
+ try {
101
+ parsed = JSON.parse(readFileSync(path, "utf-8")) as Partial<CronfishConfig>;
102
+ } catch (e) {
103
+ throw new Error(`.cronfish.json: ${(e as Error).message}`);
104
+ }
105
+ const prefix = (parsed.bundle_prefix ?? "").trim() || defaultPrefix;
106
+ if (!/^[a-zA-Z0-9_.-]+$/.test(prefix)) {
107
+ throw new Error(
108
+ `.cronfish.json: bundle_prefix "${prefix}" — must match [a-zA-Z0-9_.-]+`,
109
+ );
110
+ }
111
+ const bunPath = (parsed.bun_path ?? "").trim() || undefined;
112
+ if (bunPath !== undefined) {
113
+ if (!bunPath.startsWith("/")) {
114
+ throw new Error(
115
+ `.cronfish.json: bun_path "${bunPath}" — must be an absolute path`,
116
+ );
117
+ }
118
+ if (!existsSync(bunPath)) {
119
+ throw new Error(
120
+ `.cronfish.json: bun_path "${bunPath}" — file does not exist`,
121
+ );
122
+ }
123
+ }
124
+ const retention = parseRetention(parsed.retention);
125
+ return { bundle_prefix: prefix, bun_path: bunPath, retention };
126
+ }
127
+
128
+ const CONFIG = loadConfig();
129
+ const PREFIX = CONFIG.bundle_prefix;
130
+ const BUN_PATH = CONFIG.bun_path;
131
+
132
+ function safeDispatch(
133
+ input: string | number | undefined,
134
+ ): Dispatched | { kind: "error"; msg: string } {
135
+ try {
136
+ return dispatchSchedule(input);
137
+ } catch (e) {
138
+ return { kind: "error", msg: (e as Error).message };
139
+ }
140
+ }
141
+
142
+ // --- Verbs ---
143
+
144
+ interface LastResult {
145
+ summary: string | null;
146
+ finished_at: string | null;
147
+ }
148
+
149
+ function loadLastResults(slugs: string[]): Map<string, LastResult> {
150
+ const out = new Map<string, LastResult>();
151
+ if (slugs.length === 0) return out;
152
+ const path = dbPath(CONSUMER_ROOT);
153
+ if (!existsSync(path)) return out;
154
+ let db: Database;
155
+ try {
156
+ db = new Database(path, { readonly: true });
157
+ } catch {
158
+ return out;
159
+ }
160
+ try {
161
+ const rows = db
162
+ .query<
163
+ {
164
+ slug: string;
165
+ result_summary: string | null;
166
+ finished_at: string | null;
167
+ },
168
+ []
169
+ >(
170
+ `SELECT j.slug AS slug, i.result_summary AS result_summary, i.finished_at AS finished_at
171
+ FROM cron_invocations i
172
+ JOIN cron_jobs j ON j.id = i.job_id
173
+ WHERE i.id IN (
174
+ SELECT MAX(id) FROM cron_invocations GROUP BY job_id
175
+ )`,
176
+ )
177
+ .all();
178
+ for (const r of rows) {
179
+ out.set(r.slug, {
180
+ summary: r.result_summary,
181
+ finished_at: r.finished_at,
182
+ });
183
+ }
184
+ } catch {
185
+ // table may not have new columns yet; ignore
186
+ } finally {
187
+ db.close();
188
+ }
189
+ return out;
190
+ }
191
+
192
+ function relativeTime(iso: string | null): string {
193
+ if (!iso) return "—";
194
+ const t = Date.parse(iso);
195
+ if (Number.isNaN(t)) return "—";
196
+ const dSec = Math.max(0, Math.round((Date.now() - t) / 1000));
197
+ if (dSec < 60) return `${dSec}s ago`;
198
+ const dMin = Math.round(dSec / 60);
199
+ if (dMin < 60) return `${dMin}m ago`;
200
+ const dHr = Math.round(dMin / 60);
201
+ if (dHr < 48) return `${dHr}h ago`;
202
+ const dDay = Math.round(dHr / 24);
203
+ return `${dDay}d ago`;
204
+ }
205
+
206
+ function truncate(s: string, max: number): string {
207
+ if (s.length <= max) return s;
208
+ return s.slice(0, Math.max(1, max - 1)) + "…";
209
+ }
210
+
211
+ function cmdList(): void {
212
+ const { jobs, errors } = discoverJobs(CRON_DIR);
213
+ for (const e of errors) console.error(`[cronfish] ${e.path}: ${e.message}`);
214
+ if (jobs.length === 0 && errors.length === 0) {
215
+ console.log(
216
+ "(no jobs in cron/) — run `cronfish init` to scaffold examples.",
217
+ );
218
+ return;
219
+ }
220
+ const p = platform();
221
+ const installed = new Set(p.listInstalled(PREFIX));
222
+ const isInstalled = (slug: string): boolean =>
223
+ installed.has(p.labelSuffixOf(slug));
224
+ const lastResults = loadLastResults(jobs.map((j) => j.slug));
225
+ const cols = Math.max(80, Number(process.stdout.columns) || 120);
226
+ // Reserve 80 chars for the leading columns, give the rest to "last result".
227
+ const resultBudget = Math.max(20, cols - 80);
228
+ const headers = [
229
+ "slug",
230
+ "kind",
231
+ "schedule",
232
+ "model",
233
+ "enabled",
234
+ "loaded",
235
+ "retries",
236
+ "concurrency",
237
+ "last result",
238
+ ];
239
+ console.log(headers.join("\t"));
240
+ for (const j of jobs) {
241
+ const d = safeDispatch(j.schedule);
242
+ let sched: string;
243
+ if (d.kind === "error") {
244
+ sched = j.enabled ? `BAD(${d.msg})` : "—";
245
+ } else if (d.kind === "manual") {
246
+ sched = "manual";
247
+ } else if (d.kind === "cron") {
248
+ sched = d.expr;
249
+ } else {
250
+ sched = `every ${d.value}s`;
251
+ }
252
+ const lr = lastResults.get(j.slug);
253
+ let resultCell = "—";
254
+ if (lr) {
255
+ const when = relativeTime(lr.finished_at);
256
+ const summary = lr.summary ?? "(no summary)";
257
+ resultCell = truncate(`${summary} (${when})`, resultBudget);
258
+ }
259
+ console.log(
260
+ [
261
+ j.slug,
262
+ j.kind,
263
+ sched,
264
+ j.model ?? "—",
265
+ j.enabled ? "yes" : "no",
266
+ isInstalled(j.slug) ? "yes" : "no",
267
+ String(j.retries ?? 0),
268
+ j.concurrency ?? "—",
269
+ resultCell,
270
+ ].join("\t"),
271
+ );
272
+ }
273
+ }
274
+
275
+ function shouldInstall(job: JobMeta): {
276
+ ok: boolean;
277
+ reason?: string;
278
+ dispatched?: Dispatched;
279
+ } {
280
+ if (!job.enabled) return { ok: false, reason: "disabled" };
281
+ if (job.oneTime) {
282
+ if (job.runAtMs === undefined) {
283
+ return { ok: false, reason: "one-time: missing run_at" };
284
+ }
285
+ const status = resolveOneTime(
286
+ job.runAtMs,
287
+ job.graceSeconds ?? 0,
288
+ Date.now(),
289
+ job.executedAt,
290
+ );
291
+ if (status.kind === "executed") {
292
+ return { ok: false, reason: "one-time: already executed" };
293
+ }
294
+ if (status.kind === "past-grace") {
295
+ return { ok: false, reason: `one-time past grace: ${status.reason}` };
296
+ }
297
+ // fire-now and scheduled are both installable; launchd.render handles
298
+ // the plist shape via the JobMeta.oneTime fields directly.
299
+ return { ok: true };
300
+ }
301
+ let d: Dispatched;
302
+ try {
303
+ d = dispatchSchedule(job.schedule);
304
+ } catch (e) {
305
+ return { ok: false, reason: (e as Error).message };
306
+ }
307
+ if (d.kind === "manual") return { ok: false, reason: "manual" };
308
+ return { ok: true, dispatched: d };
309
+ }
310
+
311
+ function loadRunnerNames(): Set<string> {
312
+ const cfgPath = join(CONSUMER_ROOT, ".cronfish.json");
313
+ if (!existsSync(cfgPath)) return new Set();
314
+ try {
315
+ const raw = JSON.parse(readFileSync(cfgPath, "utf-8")) as {
316
+ runners?: Record<string, { path?: string }>;
317
+ };
318
+ return new Set(Object.keys(raw.runners ?? {}));
319
+ } catch {
320
+ return new Set();
321
+ }
322
+ }
323
+
324
+ function cmdSync(): void {
325
+ const p = platform();
326
+ const { jobs, errors } = discoverJobs(CRON_DIR);
327
+ for (const e of errors) {
328
+ console.error(`[cronfish] ${e.path}: ${e.message}`);
329
+ // Bad YAML / invalid run_at on a one-time file → sentinel. Any other
330
+ // discovery error lands in the .errors folder too if it's under
331
+ // cron/one-time/ since silent-skip is the failure mode we're killing.
332
+ if (e.path.includes(`/${"one-time"}/`)) {
333
+ const slug = e.path.split("/").pop() ?? "unknown";
334
+ writeSentinel(CRON_DIR, slug, `discovery error: ${e.message}`);
335
+ }
336
+ }
337
+
338
+ // Warn loudly when a .md job declares a runner that isn't registered in
339
+ // .cronfish.json#runners. Runtime hard-fails anyway (see runner.ts), but
340
+ // catching the typo at sync time is friendlier than at 3am.
341
+ const knownRunners = loadRunnerNames();
342
+ for (const j of jobs) {
343
+ if (j.runner && !knownRunners.has(j.runner)) {
344
+ const known = [...knownRunners].join(", ") || "(none)";
345
+ console.error(
346
+ `[cronfish] WARN ${j.slug}: runner "${j.runner}" not in .cronfish.json#runners — known: ${known}`,
347
+ );
348
+ }
349
+ }
350
+
351
+ const state = rememberPrefix(CONSUMER_ROOT, PREFIX);
352
+ const desired = new Map<string, JobMeta>();
353
+ const desiredLabels = new Set<string>();
354
+ for (const j of jobs) {
355
+ const decision = shouldInstall(j);
356
+ if (decision.ok) {
357
+ desired.set(j.slug, j);
358
+ desiredLabels.add(p.labelSuffixOf(j.slug));
359
+ } else if (
360
+ decision.reason &&
361
+ decision.reason !== "disabled" &&
362
+ decision.reason !== "manual" &&
363
+ decision.reason !== "one-time: already executed"
364
+ ) {
365
+ console.error(`[cronfish] ${j.slug}: ${decision.reason}`);
366
+ if (j.oneTime && decision.reason?.startsWith("one-time past grace:")) {
367
+ writeSentinel(CRON_DIR, j.slug, decision.reason);
368
+ }
369
+ }
370
+ }
371
+
372
+ // Walk every historical prefix and bootout label-suffixes no longer desired
373
+ // under the current prefix. This is the stale-prefix fix.
374
+ for (const prefix of state.seen_prefixes) {
375
+ for (const labelSuffix of p.listInstalled(prefix)) {
376
+ const stillDesired = prefix === PREFIX && desiredLabels.has(labelSuffix);
377
+ if (stillDesired) continue;
378
+ console.log(`[cronfish] bootout ${prefix}.${labelSuffix}`);
379
+ p.uninstall(prefix, labelSuffix);
380
+ }
381
+ }
382
+
383
+ for (const [slug, job] of desired) {
384
+ try {
385
+ const r = p.install(job, {
386
+ bundlePrefix: PREFIX,
387
+ consumerRoot: CONSUMER_ROOT,
388
+ bunPath: BUN_PATH,
389
+ });
390
+ console.log(
391
+ r.changed
392
+ ? `[cronfish] bootstrap ${slug}`
393
+ : `[cronfish] up-to-date ${slug}`,
394
+ );
395
+ } catch (e) {
396
+ console.error(
397
+ `[cronfish] install ${slug} failed: ${(e as Error).message}`,
398
+ );
399
+ }
400
+ }
401
+
402
+ // Ledger sync — record every discovered job (even disabled/manual) and
403
+ // soft-delete anything no longer on disk. Failure-safe: a broken DB warns
404
+ // once and does not abort the sync.
405
+ try {
406
+ const db = openDb(CONSUMER_ROOT);
407
+ const presentSlugs: string[] = [];
408
+ for (const j of jobs) {
409
+ try {
410
+ upsertJob(db, j);
411
+ presentSlugs.push(j.slug);
412
+ } catch (e) {
413
+ console.error(
414
+ `[cronfish] ledger upsert ${j.slug} failed: ${(e as Error).message}`,
415
+ );
416
+ }
417
+ }
418
+ markDeleted(db, presentSlugs);
419
+ db.close();
420
+ } catch (e) {
421
+ console.error(`[cronfish] ledger sync skipped: ${(e as Error).message}`);
422
+ }
423
+
424
+ // Auto-prune logs — opt-in. Only runs when retention is configured, so an
425
+ // unconfigured repo never silently loses logs on sync. Failure-safe.
426
+ if (CONFIG.retention) {
427
+ try {
428
+ const { global, perSlug } = retentionToPruneInput();
429
+ const report = pruneLogs({
430
+ consumerRoot: CONSUMER_ROOT,
431
+ global,
432
+ perSlug,
433
+ });
434
+ if (report.totalDeleted > 0) printPruneReport(report, false);
435
+ } catch (e) {
436
+ console.error(`[cronfish] auto-prune skipped: ${(e as Error).message}`);
437
+ }
438
+ }
439
+
440
+ console.log("[cronfish] sync complete");
441
+ }
442
+
443
+ function flipEnabled(slug: string, enabled: boolean): void {
444
+ const path = findJobFile(CRON_DIR, slug);
445
+ if (!path) throw new Error(`no job file for slug "${slug}"`);
446
+ const raw = readFileSync(path, "utf-8");
447
+ if (path.endsWith(".md")) {
448
+ writeFileSync(path, setFrontmatterKey(raw, "enabled", enabled), "utf-8");
449
+ } else if (path.endsWith(".sh")) {
450
+ writeFileSync(
451
+ path,
452
+ setShellFrontmatterKey(raw, "enabled", enabled),
453
+ "utf-8",
454
+ );
455
+ } else {
456
+ writeFileSync(path, rewriteTsEnabled(raw, enabled), "utf-8");
457
+ }
458
+ console.log(`[cronfish] ${enabled ? "enabled" : "disabled"} ${slug}`);
459
+ cmdSync();
460
+ }
461
+
462
+ function rewriteTsEnabled(source: string, enabled: boolean): string {
463
+ // Scoped rewrite: only the first top-level `enabled:` inside the
464
+ // `config = { ... }` block. Avoids matching strings/comments outside.
465
+ const open = source.search(/\bconfig\b\s*(?::\s*[^=]+)?=\s*\{/);
466
+ if (open < 0) {
467
+ throw new Error("TS job has no top-level `config = { ... }` block");
468
+ }
469
+ const startBody = source.indexOf("{", open) + 1;
470
+ let depth = 1;
471
+ let i = startBody;
472
+ let endBody = -1;
473
+ let inStr: string | null = null;
474
+ for (; i < source.length; i++) {
475
+ const c = source[i];
476
+ const prev = source[i - 1];
477
+ if (inStr) {
478
+ if (c === inStr && prev !== "\\") inStr = null;
479
+ continue;
480
+ }
481
+ if (c === '"' || c === "'" || c === "`") {
482
+ inStr = c;
483
+ continue;
484
+ }
485
+ if (c === "{") depth++;
486
+ else if (c === "}") {
487
+ depth--;
488
+ if (depth === 0) {
489
+ endBody = i;
490
+ break;
491
+ }
492
+ }
493
+ }
494
+ if (endBody < 0) throw new Error("TS job `config` block is unbalanced");
495
+ const head = source.slice(0, startBody);
496
+ const body = source.slice(startBody, endBody);
497
+ const tail = source.slice(endBody);
498
+ const re = /\benabled\s*:\s*(true|false)/;
499
+ const next = re.test(body)
500
+ ? body.replace(re, `enabled: ${enabled}`)
501
+ : `\n enabled: ${enabled},${body}`;
502
+ return head + next + tail;
503
+ }
504
+
505
+ function cmdDelete(slug: string, yes: boolean): void {
506
+ const path = findJobFile(CRON_DIR, slug);
507
+ if (!path) throw new Error(`no job file for slug "${slug}"`);
508
+ if (!yes) {
509
+ console.error(
510
+ `refusing to delete without --yes. would delete: plist + ${path}`,
511
+ );
512
+ process.exit(2);
513
+ }
514
+ const p = platform();
515
+ p.uninstall(PREFIX, slug);
516
+ rmSync(path);
517
+ console.log(`[cronfish] deleted ${slug} (plist + job file)`);
518
+ }
519
+
520
+ function cmdStatus(slug?: string): void {
521
+ const p = platform();
522
+ const { jobs } = discoverJobs(CRON_DIR);
523
+ const targets = slug ? jobs.filter((j) => j.slug === slug) : jobs;
524
+ if (!slug) {
525
+ cmdList();
526
+ return;
527
+ }
528
+ for (const j of targets) {
529
+ console.log(`\n=== ${j.slug} (${j.kind}) ===`);
530
+ console.log(p.statusOf(PREFIX, j.slug));
531
+ const logDir = join(CONSUMER_ROOT, ".cronfish", "logs", j.slug);
532
+ if (existsSync(logDir)) {
533
+ const logs = readdirSync(logDir)
534
+ .filter((f) => f.endsWith(".log"))
535
+ .map((f) => ({ f, m: statSync(join(logDir, f)).mtimeMs }))
536
+ .sort((a, b) => b.m - a.m);
537
+ if (logs[0]) {
538
+ console.log(`--- latest log: ${logs[0].f} ---`);
539
+ console.log(
540
+ readFileSync(join(logDir, logs[0].f), "utf-8").slice(-2000),
541
+ );
542
+ }
543
+ }
544
+ }
545
+ }
546
+
547
+ async function cmdRun(slug: string): Promise<void> {
548
+ const path = findJobFile(CRON_DIR, slug);
549
+ if (!path) throw new Error(`no job file for slug "${slug}"`);
550
+ // Validate before spawning.
551
+ loadJob(path, undefined, CRON_DIR);
552
+ const runnerTs = new URL("./runner.ts", import.meta.url).pathname;
553
+ const proc = Bun.spawn(["bun", runnerTs, path], {
554
+ stdout: "inherit",
555
+ stderr: "inherit",
556
+ cwd: CONSUMER_ROOT,
557
+ env: {
558
+ ...process.env,
559
+ CRONFISH_CONSUMER_ROOT: CONSUMER_ROOT,
560
+ CRONFISH_TRIGGER: "manual",
561
+ },
562
+ });
563
+ process.exit(await proc.exited);
564
+ }
565
+
566
+ interface UiOptions {
567
+ port: number;
568
+ host: string;
569
+ open: boolean;
570
+ }
571
+
572
+ const UI_USAGE = "usage: cronfish ui [--port N] [--host ADDR] [--no-open]";
573
+
574
+ function parseUiArgs(rest: string[]): UiOptions {
575
+ let port = 4747;
576
+ let host = "127.0.0.1";
577
+ let open = true;
578
+ for (let i = 0; i < rest.length; i++) {
579
+ const arg = rest[i];
580
+ if (arg === "--no-open") open = false;
581
+ else if (arg === "--port") {
582
+ const next = rest[++i];
583
+ if (!next || !/^\d+$/.test(next)) throw new Error(UI_USAGE);
584
+ port = parseInt(next, 10);
585
+ } else if (arg.startsWith("--port=")) {
586
+ const v = arg.slice("--port=".length);
587
+ if (!/^\d+$/.test(v)) throw new Error(UI_USAGE);
588
+ port = parseInt(v, 10);
589
+ } else if (arg === "--host") {
590
+ const next = rest[++i];
591
+ if (!next) throw new Error(UI_USAGE);
592
+ host = next;
593
+ } else if (arg.startsWith("--host=")) {
594
+ host = arg.slice("--host=".length);
595
+ if (!host) throw new Error(UI_USAGE);
596
+ } else {
597
+ throw new Error(`cronfish ui: unknown flag "${arg}"`);
598
+ }
599
+ }
600
+ return { port, host, open };
601
+ }
602
+
603
+ async function cmdUi(rest: string[]): Promise<void> {
604
+ const opts = parseUiArgs(rest);
605
+ const url = await startUiServer({
606
+ consumerRoot: CONSUMER_ROOT,
607
+ port: opts.port,
608
+ hostname: opts.host,
609
+ });
610
+ console.log(`[cronfish] ui at ${url}`);
611
+ if (opts.open) {
612
+ try {
613
+ Bun.spawn(["open", url], { stdout: "ignore", stderr: "ignore" });
614
+ } catch {
615
+ // ignore — `open` is macOS-only, dev may be on Linux
616
+ }
617
+ }
618
+ // Keep the process alive — server has its own lifecycle.
619
+ await new Promise(() => {});
620
+ }
621
+
622
+ const UI_INSTALL_USAGE = "usage: cronfish ui install [--port N] [--host ADDR]";
623
+
624
+ function parseUiInstallArgs(rest: string[]): { port: number; host: string } {
625
+ let port = 4747;
626
+ let host = "127.0.0.1";
627
+ for (let i = 0; i < rest.length; i++) {
628
+ const arg = rest[i];
629
+ if (arg === "--port") {
630
+ const next = rest[++i];
631
+ if (!next || !/^\d+$/.test(next)) throw new Error(UI_INSTALL_USAGE);
632
+ port = parseInt(next, 10);
633
+ } else if (arg.startsWith("--port=")) {
634
+ const v = arg.slice("--port=".length);
635
+ if (!/^\d+$/.test(v)) throw new Error(UI_INSTALL_USAGE);
636
+ port = parseInt(v, 10);
637
+ } else if (arg === "--host") {
638
+ const next = rest[++i];
639
+ if (!next) throw new Error(UI_INSTALL_USAGE);
640
+ host = next;
641
+ } else if (arg.startsWith("--host=")) {
642
+ host = arg.slice("--host=".length);
643
+ if (!host) throw new Error(UI_INSTALL_USAGE);
644
+ } else {
645
+ throw new Error(`cronfish ui install: unknown flag "${arg}"`);
646
+ }
647
+ }
648
+ return { port, host };
649
+ }
650
+
651
+ function cmdUiInstall(rest: string[]): void {
652
+ const { port, host } = parseUiInstallArgs(rest);
653
+ const r = platform().installUi({
654
+ bundlePrefix: PREFIX,
655
+ consumerRoot: CONSUMER_ROOT,
656
+ port,
657
+ host,
658
+ bunPath: BUN_PATH,
659
+ });
660
+ if (r.changed) {
661
+ console.log(`[cronfish] ui installed: ${r.label}`);
662
+ console.log(` plist: ${r.plistPath}`);
663
+ console.log(` log: ${r.logPath}`);
664
+ console.log(` url: ${r.url}`);
665
+ } else {
666
+ console.log(`[cronfish] ui already up-to-date: ${r.url}`);
667
+ }
668
+ }
669
+
670
+ function cmdUiUninstall(): void {
671
+ const r = platform().uninstallUi(PREFIX);
672
+ if (r.existed) {
673
+ console.log(`[cronfish] ui uninstalled: ${r.label}`);
674
+ } else {
675
+ console.log(`[cronfish] ui not installed (${r.label})`);
676
+ }
677
+ }
678
+
679
+ function cmdUiStatus(): void {
680
+ const s = platform().uiStatus(PREFIX);
681
+ if (!s.installed && !s.loaded) {
682
+ console.log("[cronfish] ui not installed");
683
+ console.log(" run: cronfish ui install");
684
+ return;
685
+ }
686
+ console.log(
687
+ `[cronfish] ui ${s.loaded ? "running" : "installed (not loaded)"}`,
688
+ );
689
+ console.log(` label: ${s.label}`);
690
+ console.log(` plist: ${s.plistPath}`);
691
+ if (s.pid !== null) console.log(` pid: ${s.pid}`);
692
+ if (s.url) console.log(` url: ${s.url}`);
693
+ }
694
+
695
+ async function cmdWatchdog(): Promise<void> {
696
+ const { runWatchdog } = await import("./watchdog.ts");
697
+ const decisions = await runWatchdog({ consumerRoot: CONSUMER_ROOT });
698
+ let fired = 0;
699
+ let errors = 0;
700
+ for (const d of decisions) {
701
+ if (d.outcome === "fired") {
702
+ fired++;
703
+ console.log(`[watchdog] FIRED ${d.slug} (expected ${d.expected_at})`);
704
+ } else if (d.outcome === "fire-failed") {
705
+ errors++;
706
+ console.error(
707
+ `[watchdog] FAIL ${d.slug}: ${d.error ?? "unknown"} (expected ${d.expected_at})`,
708
+ );
709
+ }
710
+ }
711
+ if (fired === 0 && errors === 0) {
712
+ // Silent: spec says "no missed jobs and exits 0 silently".
713
+ return;
714
+ }
715
+ if (errors > 0) process.exit(1);
716
+ }
717
+
718
+ async function cmdAlertsTest(adapterName?: string): Promise<void> {
719
+ const { loadConsumerAlertsConfig, buildRegistry, safeNotify } =
720
+ await import("./alerts/index.ts");
721
+ const cfg = loadConsumerAlertsConfig(CONSUMER_ROOT);
722
+ const name = adapterName ?? cfg.alerts?.default;
723
+ if (!name) {
724
+ console.error(
725
+ `[cronfish] alerts test: no adapter given and alerts.default not set in .cronfish.json`,
726
+ );
727
+ process.exit(2);
728
+ }
729
+ const registry = buildRegistry(cfg.alerts);
730
+ if (!registry.has(name)) {
731
+ console.error(`[cronfish] alerts test: unknown adapter "${name}"`);
732
+ process.exit(2);
733
+ }
734
+ const outcome = await safeNotify(registry.get(name), {
735
+ slug: "cronfish-alerts-test",
736
+ status: "test",
737
+ exit_code: 0,
738
+ duration_ms: 0,
739
+ started_at: new Date().toISOString(),
740
+ log_tail: "This is a cronfish alerts test ping.",
741
+ ui_url: cfg.ui?.public_url ?? null,
742
+ });
743
+ if (outcome.status === "sent") {
744
+ console.log(`[cronfish] alerts test: ${name} OK`);
745
+ return;
746
+ }
747
+ console.error(`[cronfish] alerts test: ${name} FAILED — ${outcome.error}`);
748
+ process.exit(1);
749
+ }
750
+
751
+ function cmdNext(slug?: string, n = 5): void {
752
+ const { jobs } = discoverJobs(CRON_DIR);
753
+ const targets = slug ? jobs.filter((j) => j.slug === slug) : jobs;
754
+ for (const j of targets) {
755
+ try {
756
+ const d = dispatchSchedule(j.schedule);
757
+ if (d.kind === "manual") {
758
+ console.log(`${j.slug}\tmanual (no autoschedule)`);
759
+ continue;
760
+ }
761
+ if (d.kind === "seconds") {
762
+ const now = Date.now();
763
+ const fires = Array.from({ length: n }, (_, i) =>
764
+ new Date(now + (i + 1) * d.value * 1000).toISOString(),
765
+ );
766
+ console.log(`${j.slug}\tevery ${d.value}s\t→\t${fires.join(", ")}`);
767
+ continue;
768
+ }
769
+ console.log(
770
+ `${j.slug}\tcron "${d.expr}" (preview not implemented for cron)`,
771
+ );
772
+ } catch (e) {
773
+ console.error(`${j.slug}: ${(e as Error).message}`);
774
+ }
775
+ }
776
+ }
777
+
778
+ // --- cronfish init ---
779
+
780
+ const INIT_MD = `---
781
+ description: "Demo markdown cron — prints the time"
782
+ schedule: "every 5 minutes"
783
+ model: haiku
784
+ enabled: false
785
+ timeout: 120
786
+ ---
787
+
788
+ You are a cronfish demo job. Print one short sentence summarizing the current time.
789
+ This file is wired off (\`enabled: false\`) — flip it on with \`cronfish enable hello\`.
790
+ `;
791
+
792
+ const INIT_TS = `// cronfish demo job — programmable side. Disabled by default.
793
+ // Flip on with \`cronfish enable touch-ts\`.
794
+ export const config = {
795
+ description: "Demo TS cron — appends a timestamp to tmp/touched.txt",
796
+ schedule: "every 5 minutes",
797
+ enabled: false,
798
+ timeout: 60,
799
+ };
800
+
801
+ export default async function run(): Promise<void> {
802
+ const fs = await import("node:fs/promises");
803
+ const path = await import("node:path");
804
+ const root = process.env.CRONFISH_CONSUMER_ROOT || process.cwd();
805
+ const out = path.join(root, "tmp", "touched.txt");
806
+ await fs.mkdir(path.dirname(out), { recursive: true });
807
+ await fs.writeFile(out, new Date().toISOString() + "\\n", { flag: "a" });
808
+ console.log("[touch] wrote", out);
809
+ }
810
+ `;
811
+
812
+ const INIT_SH = `#!/bin/bash
813
+ # cronfish demo job — bash side. Disabled by default.
814
+ # Flip on with \`cronfish enable ping-sh\`.
815
+ # ---
816
+ # description: "Demo bash cron — pings hello to the log"
817
+ # schedule: "every 5 minutes"
818
+ # enabled: false
819
+ # timeout: 30
820
+ # ---
821
+ set -euo pipefail
822
+ echo "[ping] hello from bash at $(date -u +%FT%TZ)"
823
+ `;
824
+
825
+ const INIT_WATCHDOG_SH = `#!/bin/bash
826
+ # Cronfish watchdog — detects missed schedules and pings the configured alert
827
+ # adapter. Safe to run frequently; one alert per missed window.
828
+ # ---
829
+ # description: "Cronfish missed-schedule watchdog"
830
+ # schedule: "every 5 minutes"
831
+ # enabled: false
832
+ # timeout: 60
833
+ # ---
834
+ set -euo pipefail
835
+ exec cronfish watchdog
836
+ `;
837
+
838
+ const GITIGNORE_BLOCK = "# cronfish\n.cronfish/\n";
839
+
840
+ function ensureGitignoreBlock(): void {
841
+ const path = join(CONSUMER_ROOT, ".gitignore");
842
+ let existing = "";
843
+ if (existsSync(path)) {
844
+ existing = readFileSync(path, "utf-8");
845
+ if (/^\.cronfish\/?\s*$/m.test(existing)) {
846
+ console.log(`[cronfish] init: .gitignore already ignores .cronfish/`);
847
+ return;
848
+ }
849
+ }
850
+ const sep = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
851
+ const next =
852
+ existing + sep + (existing.length === 0 ? "" : "\n") + GITIGNORE_BLOCK;
853
+ writeFileSync(path, next, "utf-8");
854
+ console.log(`[cronfish] init: added .cronfish/ to ${path}`);
855
+ }
856
+
857
+ // Translate the .cronfish.json `retention` block into the prune core's shape.
858
+ function retentionToPruneInput(override?: SlugRetention): {
859
+ global: SlugRetention;
860
+ perSlug: Record<string, SlugRetention>;
861
+ } {
862
+ const r = CONFIG.retention;
863
+ const global: SlugRetention = override ?? {
864
+ maxAgeDays: r?.max_age_days,
865
+ maxRuns: r?.max_runs,
866
+ };
867
+ const perSlug: Record<string, SlugRetention> = {};
868
+ // CLI flag overrides win across every slug — ignore per_slug config then.
869
+ if (!override && r?.per_slug) {
870
+ for (const [slug, v] of Object.entries(r.per_slug)) {
871
+ perSlug[slug] = { maxAgeDays: v.max_age_days, maxRuns: v.max_runs };
872
+ }
873
+ }
874
+ return { global, perSlug };
875
+ }
876
+
877
+ function printPruneReport(report: PruneReport, dryRun: boolean): void {
878
+ const verb = dryRun ? "would prune" : "pruned";
879
+ if (report.totalDeleted === 0) {
880
+ console.log("[cronfish] prune: nothing to remove");
881
+ return;
882
+ }
883
+ for (const s of report.slugs) {
884
+ console.log(
885
+ `[cronfish] ${verb} ${s.slug}: ${s.deleted.length} log(s), ${formatBytes(s.bytesFreed)} (kept ${s.kept})`,
886
+ );
887
+ }
888
+ console.log(
889
+ `[cronfish] ${verb} ${report.totalDeleted} log(s) total, ${formatBytes(report.totalBytes)} freed`,
890
+ );
891
+ }
892
+
893
+ function cmdPrune(
894
+ slug: string | undefined,
895
+ flags: { dryRun: boolean; maxAgeDays?: number; maxRuns?: number },
896
+ ): void {
897
+ const hasFlagOverride =
898
+ flags.maxAgeDays !== undefined || flags.maxRuns !== undefined;
899
+ const override: SlugRetention | undefined = hasFlagOverride
900
+ ? { maxAgeDays: flags.maxAgeDays, maxRuns: flags.maxRuns }
901
+ : undefined;
902
+
903
+ let { global, perSlug } = retentionToPruneInput(override);
904
+ // Manual prune with neither config nor flags falls back to a safe default
905
+ // so `cronfish prune` does something useful out of the box.
906
+ if (
907
+ global.maxAgeDays === undefined &&
908
+ global.maxRuns === undefined &&
909
+ Object.keys(perSlug).length === 0
910
+ ) {
911
+ global = { maxAgeDays: DEFAULT_PRUNE_AGE_DAYS };
912
+ console.log(
913
+ `[cronfish] no retention configured — using default max_age_days=${DEFAULT_PRUNE_AGE_DAYS}`,
914
+ );
915
+ }
916
+
917
+ const report = pruneLogs({
918
+ consumerRoot: CONSUMER_ROOT,
919
+ global,
920
+ perSlug,
921
+ onlySlug: slug,
922
+ dryRun: flags.dryRun,
923
+ });
924
+ printPruneReport(report, flags.dryRun);
925
+ }
926
+
927
+ function cmdInit(): void {
928
+ mkdirSync(CRON_DIR, { recursive: true });
929
+ for (const [name, content] of [
930
+ ["hello.md", INIT_MD],
931
+ ["touch.ts", INIT_TS],
932
+ ["ping.sh", INIT_SH],
933
+ ["watchdog.sh", INIT_WATCHDOG_SH],
934
+ ] as const) {
935
+ const p = join(CRON_DIR, name);
936
+ if (existsSync(p)) {
937
+ console.log(`[cronfish] init: ${p} exists — leaving alone`);
938
+ continue;
939
+ }
940
+ writeFileSync(p, content, "utf-8");
941
+ console.log(`[cronfish] init: wrote ${p}`);
942
+ }
943
+ ensureGitignoreBlock();
944
+ console.log(
945
+ "\nNext: edit cron/hello.md, cron/touch.ts, or cron/ping.sh, flip `enabled: true`, run `cronfish sync`.",
946
+ );
947
+ }
948
+
949
+ function usage(): void {
950
+ console.log(
951
+ `cronfish ${VERSION} — drop a file, schedule it.
952
+
953
+ usage:
954
+ cronfish init scaffold cron/hello.md + cron/touch.ts + cron/ping.sh
955
+ cronfish list show every job + state
956
+ cronfish next [slug] [N] preview the next N fire times (default 5)
957
+ cronfish sync reconcile cron/ ↔ launchd (auto-prunes logs if retention is set)
958
+ cronfish prune [slug] [--dry-run] delete old per-run logs per retention config
959
+ [--max-age-days N] [--max-runs N] (override config; default max_age_days=30 if unset)
960
+ cronfish enable <slug> flip enabled, then sync
961
+ cronfish disable <slug> flip disabled, then sync
962
+ cronfish delete <slug> --yes bootout + remove plist + job file
963
+ cronfish status [slug] all jobs (no arg) or one slug's launchctl + log tail
964
+ cronfish run <slug> invoke runner directly (no launchd)
965
+ cronfish watchdog check enabled jobs for missed schedules → fire alerts
966
+ cronfish alerts test [adapter] send a test alert via the named (or default) adapter
967
+ cronfish ui [--port N] [--host ADDR] [--no-open] local web dashboard (default 127.0.0.1:4747)
968
+ cronfish ui install [--port N] [--host ADDR] install dashboard as a launchd daemon (auto-restart, runs at login)
969
+ cronfish ui uninstall bootout + remove dashboard daemon
970
+ cronfish ui status show dashboard daemon state
971
+ cronfish --version
972
+
973
+ config: <consumer>/.cronfish.json → { "bundle_prefix": "com.example.app",
974
+ "bun_path": "/opt/homebrew/bin/bun",
975
+ "retention": { "max_age_days": 30, "max_runs": 100 } }
976
+ docs: https://github.com/goldcaddy77/cronfish
977
+ `,
978
+ );
979
+ }
980
+
981
+ async function main(): Promise<void> {
982
+ const [verb, ...rest] = process.argv.slice(2);
983
+ switch (verb) {
984
+ case undefined:
985
+ case "--help":
986
+ case "-h":
987
+ case "help":
988
+ usage();
989
+ return;
990
+ case "--version":
991
+ case "-v":
992
+ console.log(VERSION);
993
+ return;
994
+ case "init":
995
+ cmdInit();
996
+ return;
997
+ case "list":
998
+ cmdList();
999
+ return;
1000
+ case "next": {
1001
+ const slug = rest[0] && /^\d+$/.test(rest[0]) ? undefined : rest[0];
1002
+ const nStr = slug ? rest[1] : rest[0];
1003
+ const n = nStr && /^\d+$/.test(nStr) ? parseInt(nStr, 10) : 5;
1004
+ cmdNext(slug, n);
1005
+ return;
1006
+ }
1007
+ case "sync":
1008
+ cmdSync();
1009
+ return;
1010
+ case "prune": {
1011
+ const valueFlags = new Set(["--max-age-days", "--max-runs"]);
1012
+ const flag = (name: string): number | undefined => {
1013
+ const i = rest.indexOf(name);
1014
+ if (i === -1) return undefined;
1015
+ const v = rest[i + 1];
1016
+ if (!v || !/^\d+$/.test(v)) {
1017
+ throw new Error(`usage: cronfish prune ${name} <positive integer>`);
1018
+ }
1019
+ return parseInt(v, 10);
1020
+ };
1021
+ // Slug = first bare arg that is neither a flag nor a flag's value.
1022
+ let slug: string | undefined;
1023
+ for (let i = 0; i < rest.length; i++) {
1024
+ const a = rest[i]!;
1025
+ if (valueFlags.has(a)) {
1026
+ i++; // skip the value
1027
+ continue;
1028
+ }
1029
+ if (a.startsWith("-")) continue;
1030
+ slug = a;
1031
+ break;
1032
+ }
1033
+ cmdPrune(slug, {
1034
+ dryRun: rest.includes("--dry-run"),
1035
+ maxAgeDays: flag("--max-age-days"),
1036
+ maxRuns: flag("--max-runs"),
1037
+ });
1038
+ return;
1039
+ }
1040
+ case "enable":
1041
+ if (!rest[0]) throw new Error("usage: cronfish enable <slug>");
1042
+ flipEnabled(rest[0], true);
1043
+ return;
1044
+ case "disable":
1045
+ if (!rest[0]) throw new Error("usage: cronfish disable <slug>");
1046
+ flipEnabled(rest[0], false);
1047
+ return;
1048
+ case "delete":
1049
+ if (!rest[0]) throw new Error("usage: cronfish delete <slug> [--yes]");
1050
+ cmdDelete(rest[0], rest.includes("--yes"));
1051
+ return;
1052
+ case "status":
1053
+ cmdStatus(rest[0]);
1054
+ return;
1055
+ case "run":
1056
+ if (!rest[0]) throw new Error("usage: cronfish run <slug>");
1057
+ await cmdRun(rest[0]);
1058
+ return;
1059
+ case "watchdog":
1060
+ await cmdWatchdog();
1061
+ return;
1062
+ case "alerts": {
1063
+ if (rest[0] !== "test") {
1064
+ console.error(`[cronfish] usage: cronfish alerts test [adapter]`);
1065
+ process.exit(2);
1066
+ }
1067
+ await cmdAlertsTest(rest[1]);
1068
+ return;
1069
+ }
1070
+ case "ui": {
1071
+ const sub = rest[0];
1072
+ if (sub === "install") {
1073
+ cmdUiInstall(rest.slice(1));
1074
+ return;
1075
+ }
1076
+ if (sub === "uninstall") {
1077
+ cmdUiUninstall();
1078
+ return;
1079
+ }
1080
+ if (sub === "status") {
1081
+ cmdUiStatus();
1082
+ return;
1083
+ }
1084
+ await cmdUi(rest);
1085
+ return;
1086
+ }
1087
+ default:
1088
+ console.error(`[cronfish] unknown verb: ${verb}`);
1089
+ usage();
1090
+ process.exit(2);
1091
+ }
1092
+ }
1093
+
1094
+ main().catch((e) => {
1095
+ console.error(`[cronfish] ${(e as Error).message}`);
1096
+ process.exit(1);
1097
+ });