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/schedule.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Single-key schedule dispatcher. Accepts every input shape supported by
|
|
2
|
+
// `schedule:` and returns one of:
|
|
3
|
+
// - { kind: "cron", expr } → launchd StartCalendarInterval
|
|
4
|
+
// - { kind: "seconds", value } → launchd StartInterval
|
|
5
|
+
// - { kind: "manual" } → no plist install (job exists, never auto-fires)
|
|
6
|
+
//
|
|
7
|
+
// Accepted inputs:
|
|
8
|
+
// "M H DOM MON DOW" → cron (each field "*" or a single integer in range)
|
|
9
|
+
// "every ..." → human → seconds
|
|
10
|
+
// bare number 60 → seconds
|
|
11
|
+
// "60s" "5m" "2h" "1d" → seconds
|
|
12
|
+
// "manual" → manual (no autoschedule)
|
|
13
|
+
//
|
|
14
|
+
// launchd `StartCalendarInterval` only accepts single ints per field, so we
|
|
15
|
+
// never emit `*/N` cron expressions — those become seconds intervals instead.
|
|
16
|
+
|
|
17
|
+
import { parseFriendly } from "./parsers/friendly.ts";
|
|
18
|
+
|
|
19
|
+
export type Dispatched =
|
|
20
|
+
| { kind: "cron"; expr: string }
|
|
21
|
+
| { kind: "seconds"; value: number }
|
|
22
|
+
| { kind: "manual" };
|
|
23
|
+
|
|
24
|
+
const UNIT_SECONDS: Record<string, number> = {
|
|
25
|
+
s: 1,
|
|
26
|
+
m: 60,
|
|
27
|
+
h: 3600,
|
|
28
|
+
d: 86400,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Per-field ranges for the standard 5-field cron form.
|
|
32
|
+
const CRON_RANGES: { name: string; min: number; max: number }[] = [
|
|
33
|
+
{ name: "minute", min: 0, max: 59 },
|
|
34
|
+
{ name: "hour", min: 0, max: 23 },
|
|
35
|
+
{ name: "day-of-month", min: 1, max: 31 },
|
|
36
|
+
{ name: "month", min: 1, max: 12 },
|
|
37
|
+
{ name: "day-of-week", min: 0, max: 7 },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function validateCronExpr(expr: string): string {
|
|
41
|
+
const parts = expr.split(/\s+/);
|
|
42
|
+
if (parts.length !== 5) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`cron expression must have 5 fields, got ${parts.length}: "${expr}"`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
for (let i = 0; i < 5; i++) {
|
|
48
|
+
const p = parts[i];
|
|
49
|
+
const { name, min, max } = CRON_RANGES[i];
|
|
50
|
+
if (p === "*") continue;
|
|
51
|
+
if (!/^\d+$/.test(p)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`cron ${name} must be "*" or a non-negative integer, got "${p}"`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const n = parseInt(p, 10);
|
|
57
|
+
if (n < min || n > max) {
|
|
58
|
+
throw new Error(`cron ${name} out of range [${min}-${max}]: ${n}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return parts.join(" ");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Next fire time strictly after `from`, in UTC. Returns null for manual or
|
|
65
|
+
// unparseable. For our constrained cron form (each field "*" or a single
|
|
66
|
+
// integer, no `*/N`) we walk minute-by-minute up to 7 days; that's fine for
|
|
67
|
+
// the watchdog's needs and avoids pulling in cronosjs.
|
|
68
|
+
export function nextFireAfter(
|
|
69
|
+
schedule: string | number | undefined,
|
|
70
|
+
from: Date,
|
|
71
|
+
): Date | null {
|
|
72
|
+
let d: Dispatched;
|
|
73
|
+
try {
|
|
74
|
+
d = dispatchSchedule(schedule);
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (d.kind === "manual") return null;
|
|
79
|
+
if (d.kind === "seconds") {
|
|
80
|
+
return new Date(from.getTime() + d.value * 1000);
|
|
81
|
+
}
|
|
82
|
+
const parts = d.expr.split(/\s+/);
|
|
83
|
+
const [m, h, dom, mon, dow] = parts;
|
|
84
|
+
// Start at the next whole minute boundary strictly after `from`.
|
|
85
|
+
const start = new Date(from.getTime());
|
|
86
|
+
start.setUTCSeconds(0, 0);
|
|
87
|
+
start.setUTCMinutes(start.getUTCMinutes() + 1);
|
|
88
|
+
const limit = new Date(from.getTime() + 7 * 86400 * 1000);
|
|
89
|
+
for (let t = start; t <= limit; t = new Date(t.getTime() + 60_000)) {
|
|
90
|
+
if (m !== "*" && t.getUTCMinutes() !== parseInt(m, 10)) continue;
|
|
91
|
+
if (h !== "*" && t.getUTCHours() !== parseInt(h, 10)) continue;
|
|
92
|
+
if (dom !== "*" && t.getUTCDate() !== parseInt(dom, 10)) continue;
|
|
93
|
+
if (mon !== "*" && t.getUTCMonth() + 1 !== parseInt(mon, 10)) continue;
|
|
94
|
+
if (dow !== "*") {
|
|
95
|
+
const want = parseInt(dow, 10) % 7; // 0 == 7 == Sunday
|
|
96
|
+
if (t.getUTCDay() !== want) continue;
|
|
97
|
+
}
|
|
98
|
+
return t;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Approximate the configured interval. For seconds schedule this is exact.
|
|
104
|
+
// For cron we use (next_after_expected - expected). Returns null when
|
|
105
|
+
// indeterminate (manual / unparseable).
|
|
106
|
+
export function intervalSecondsAt(
|
|
107
|
+
schedule: string | number | undefined,
|
|
108
|
+
anchor: Date,
|
|
109
|
+
): number | null {
|
|
110
|
+
let d: Dispatched;
|
|
111
|
+
try {
|
|
112
|
+
d = dispatchSchedule(schedule);
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
if (d.kind === "manual") return null;
|
|
117
|
+
if (d.kind === "seconds") return d.value;
|
|
118
|
+
const a = nextFireAfter(schedule, anchor);
|
|
119
|
+
if (!a) return null;
|
|
120
|
+
const b = nextFireAfter(schedule, a);
|
|
121
|
+
if (!b) return null;
|
|
122
|
+
return Math.round((b.getTime() - a.getTime()) / 1000);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Parse a `missed_after:` override. Accepts the same compact units as
|
|
126
|
+
// `schedule:` (`30m`, `2h`, `90s`, `1d`, or a bare seconds integer).
|
|
127
|
+
// Returns null when not parseable.
|
|
128
|
+
export function parseMissedAfter(input: string | number | undefined): number | null {
|
|
129
|
+
if (input === undefined || input === null || input === "") return null;
|
|
130
|
+
try {
|
|
131
|
+
const d = dispatchSchedule(input);
|
|
132
|
+
if (d.kind === "seconds") return d.value;
|
|
133
|
+
} catch {}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function dispatchSchedule(
|
|
138
|
+
input: string | number | undefined,
|
|
139
|
+
): Dispatched {
|
|
140
|
+
if (input === undefined || input === null || input === "") {
|
|
141
|
+
throw new Error(
|
|
142
|
+
'schedule: required (cron, "every N units", seconds, or "manual")',
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (typeof input === "number") {
|
|
146
|
+
if (!Number.isFinite(input) || input < 1) {
|
|
147
|
+
throw new Error(`schedule: seconds value must be >= 1, got ${input}`);
|
|
148
|
+
}
|
|
149
|
+
return { kind: "seconds", value: Math.floor(input) };
|
|
150
|
+
}
|
|
151
|
+
const s = input.trim();
|
|
152
|
+
if (!s) throw new Error("schedule: empty string");
|
|
153
|
+
|
|
154
|
+
if (s.toLowerCase() === "manual") return { kind: "manual" };
|
|
155
|
+
|
|
156
|
+
const compact = s.match(/^(\d+)([smhd])$/);
|
|
157
|
+
if (compact) {
|
|
158
|
+
const n = parseInt(compact[1], 10);
|
|
159
|
+
if (n < 1) throw new Error(`schedule: ${s} resolves to <1s`);
|
|
160
|
+
return { kind: "seconds", value: n * UNIT_SECONDS[compact[2]] };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (/^\d+$/.test(s)) {
|
|
164
|
+
const n = parseInt(s, 10);
|
|
165
|
+
if (n < 1)
|
|
166
|
+
throw new Error(`schedule: seconds value must be >= 1, got ${n}`);
|
|
167
|
+
return { kind: "seconds", value: n };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (/^every\b/i.test(s)) {
|
|
171
|
+
const f = parseFriendly(s);
|
|
172
|
+
if (!f) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`schedule: unrecognized human form "${input}" — try "every N seconds|minutes|hours|days"`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return f;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Cron form — must look like 5 space-separated fields before we try to validate.
|
|
181
|
+
if (/^[\d*\s]+$/.test(s)) {
|
|
182
|
+
return { kind: "cron", expr: validateCronExpr(s) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
throw new Error(
|
|
186
|
+
`schedule: unsupported "${input}" — use cron "M H DOM MON DOW", "every N <unit>", seconds (60, "5m"), or "manual"`,
|
|
187
|
+
);
|
|
188
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Persistent cronfish state. Today tracks `seen_prefixes` so we can clean up
|
|
2
|
+
// orphaned plists when `bundle_prefix` changes.
|
|
3
|
+
//
|
|
4
|
+
// Location: <consumer>/tmp/.cronfish/state.json. Lives under tmp/ on purpose
|
|
5
|
+
// — losing it is recoverable (worst case: orphan plists remain until manual
|
|
6
|
+
// bootout). Treating it as durable would mean .gitignore plumbing on every
|
|
7
|
+
// consumer.
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
export interface CronfishState {
|
|
13
|
+
seen_prefixes: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const EMPTY: CronfishState = { seen_prefixes: [] };
|
|
17
|
+
|
|
18
|
+
function statePath(consumerRoot: string): string {
|
|
19
|
+
return join(consumerRoot, "tmp", ".cronfish", "state.json");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadState(consumerRoot: string): CronfishState {
|
|
23
|
+
const p = statePath(consumerRoot);
|
|
24
|
+
if (!existsSync(p)) return { ...EMPTY };
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(
|
|
27
|
+
readFileSync(p, "utf-8"),
|
|
28
|
+
) as Partial<CronfishState>;
|
|
29
|
+
return {
|
|
30
|
+
seen_prefixes: Array.isArray(parsed.seen_prefixes)
|
|
31
|
+
? parsed.seen_prefixes.filter((x) => typeof x === "string")
|
|
32
|
+
: [],
|
|
33
|
+
};
|
|
34
|
+
} catch {
|
|
35
|
+
return { ...EMPTY };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function saveState(consumerRoot: string, state: CronfishState): void {
|
|
40
|
+
const p = statePath(consumerRoot);
|
|
41
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
42
|
+
writeFileSync(p, JSON.stringify(state, null, 2), "utf-8");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function rememberPrefix(
|
|
46
|
+
consumerRoot: string,
|
|
47
|
+
prefix: string,
|
|
48
|
+
): CronfishState {
|
|
49
|
+
const state = loadState(consumerRoot);
|
|
50
|
+
if (!state.seen_prefixes.includes(prefix)) {
|
|
51
|
+
state.seen_prefixes.push(prefix);
|
|
52
|
+
saveState(consumerRoot, state);
|
|
53
|
+
}
|
|
54
|
+
return state;
|
|
55
|
+
}
|
package/src/ts-shim.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Tiny shim: import a cronfish TS job by absolute path, validate its shape,
|
|
3
|
+
// then await its default export. Stdout/stderr go to the parent runner's log
|
|
4
|
+
// file via FD redirection.
|
|
5
|
+
//
|
|
6
|
+
// If the default export returns a non-null/non-undefined value, the shim
|
|
7
|
+
// emits a `__CRONFISH_RESULT_V1__::<json>` sentinel line so the runner can
|
|
8
|
+
// persist it to the ledger. Void return = no sentinel (back-compat).
|
|
9
|
+
|
|
10
|
+
import { SENTINEL_PREFIX } from "./result.ts";
|
|
11
|
+
|
|
12
|
+
interface TsJobModule {
|
|
13
|
+
config: Record<string, unknown>;
|
|
14
|
+
default: () => Promise<unknown> | unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function main(): Promise<void> {
|
|
18
|
+
const jobPath = process.argv[2];
|
|
19
|
+
if (!jobPath) {
|
|
20
|
+
console.error("ts-shim: missing job path");
|
|
21
|
+
process.exit(2);
|
|
22
|
+
}
|
|
23
|
+
const mod = (await import(jobPath)) as TsJobModule;
|
|
24
|
+
if (!mod.config || typeof mod.default !== "function") {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`${jobPath}: must export \`config\` and a default async function`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
const ret = await mod.default();
|
|
30
|
+
if (ret !== null && ret !== undefined) {
|
|
31
|
+
try {
|
|
32
|
+
console.log(SENTINEL_PREFIX + JSON.stringify(ret));
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.error(
|
|
35
|
+
`ts-shim: failed to serialize result: ${(e as Error).message}`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
main().catch((e) => {
|
|
42
|
+
console.error(`ts-shim ERROR: ${(e as Error).stack ?? (e as Error).message}`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
});
|