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.
- package/LICENSE +21 -0
- package/README.md +541 -0
- package/examples/.cronfish.json +17 -0
- package/examples/README.md +28 -0
- package/examples/disk-space.sh +25 -0
- package/examples/healthcheck.ts +27 -0
- package/examples/hello.md +18 -0
- package/examples/one-time/cleanup.ts +25 -0
- package/examples/one-time/reminder.md +22 -0
- package/package.json +44 -0
- package/src/alerts/dispatch.ts +134 -0
- package/src/alerts/index.ts +21 -0
- package/src/alerts/registry.ts +34 -0
- package/src/alerts/safe.ts +26 -0
- package/src/alerts/shell.ts +63 -0
- package/src/alerts/slack.ts +98 -0
- package/src/alerts/types.ts +34 -0
- package/src/cli.ts +1097 -0
- package/src/db.ts +365 -0
- package/src/frontmatter.ts +654 -0
- package/src/jobs.ts +536 -0
- package/src/models.ts +54 -0
- package/src/oneTime.ts +259 -0
- package/src/parsers/friendly.ts +53 -0
- package/src/platform/index.ts +14 -0
- package/src/platform/launchd.ts +564 -0
- package/src/prune.ts +155 -0
- package/src/result.ts +111 -0
- package/src/runner.ts +827 -0
- package/src/schedule.ts +188 -0
- package/src/state.ts +55 -0
- package/src/ts-shim.ts +44 -0
- package/src/ui/server.ts +469 -0
- package/src/watchdog.ts +201 -0
- package/templates/_examples/one-time/echo-at.md +10 -0
- package/templates/plist.template +35 -0
- package/ui/README.md +73 -0
- package/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/ui/dist/assets/index-DNE046Zp.js +9 -0
- package/ui/dist/assets/index-DmWTmu9X.css +2 -0
- package/ui/dist/favicon.svg +1 -0
- package/ui/dist/icons.svg +24 -0
- 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
|
+
});
|