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/oneTime.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// One-shot scheduled jobs — files under `cron/one-time/`. Each file fires
|
|
2
|
+
// exactly once at its `run_at` frontmatter timestamp, then archives itself
|
|
3
|
+
// to ~/Library/Application Support/cronfish/done/.
|
|
4
|
+
//
|
|
5
|
+
// Discovery, install, runner guard, and archive all funnel through this
|
|
6
|
+
// module so the failure semantics live in one place: past-grace stale files
|
|
7
|
+
// are refused with a sentinel; re-fires are blocked by flock + executed_at;
|
|
8
|
+
// archive lives outside the repo so the audit trail can't bloat git.
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
closeSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
openSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
statSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
writeSync,
|
|
20
|
+
fsyncSync,
|
|
21
|
+
} from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { basename, join } from "node:path";
|
|
24
|
+
import { dlopen, FFIType, suffix } from "bun:ffi";
|
|
25
|
+
|
|
26
|
+
// --- Constants ---
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_GRACE_SECONDS = 300; // 5 min
|
|
29
|
+
export const ONE_TIME_DIR = "one-time"; // relative to cron/
|
|
30
|
+
export const ERRORS_DIR = ".errors"; // relative to cron/
|
|
31
|
+
|
|
32
|
+
export function archiveDir(): string {
|
|
33
|
+
return join(homedir(), "Library", "Application Support", "cronfish", "done");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function errorsDir(cronDir: string): string {
|
|
37
|
+
return join(cronDir, ERRORS_DIR);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isOneTimePath(cronDir: string, absPath: string): boolean {
|
|
41
|
+
const prefix = join(cronDir, ONE_TIME_DIR) + "/";
|
|
42
|
+
return absPath.startsWith(prefix);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- run_at parsing ---
|
|
46
|
+
|
|
47
|
+
// Absolute ISO (any Date-parseable string) OR relative `+N{s,m,h,d}` against
|
|
48
|
+
// the file's mtime. Returns epoch ms.
|
|
49
|
+
export function parseRunAt(input: unknown, mtimeMs: number): number {
|
|
50
|
+
if (typeof input === "number") {
|
|
51
|
+
if (!Number.isFinite(input) || input <= 0) {
|
|
52
|
+
throw new Error(`run_at: numeric value must be a positive unix-seconds timestamp, got ${input}`);
|
|
53
|
+
}
|
|
54
|
+
return input * 1000;
|
|
55
|
+
}
|
|
56
|
+
if (typeof input !== "string") {
|
|
57
|
+
throw new Error(`run_at: must be an ISO timestamp string or "+N{s,m,h,d}", got ${typeof input}`);
|
|
58
|
+
}
|
|
59
|
+
const s = input.trim();
|
|
60
|
+
if (!s) throw new Error(`run_at: empty value`);
|
|
61
|
+
const rel = s.match(/^\+(\d+)([smhd])$/);
|
|
62
|
+
if (rel) {
|
|
63
|
+
const n = parseInt(rel[1], 10);
|
|
64
|
+
const unit: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
65
|
+
return mtimeMs + n * unit[rel[2]];
|
|
66
|
+
}
|
|
67
|
+
const t = Date.parse(s);
|
|
68
|
+
if (Number.isNaN(t)) {
|
|
69
|
+
throw new Error(`run_at: cannot parse "${s}" — use ISO timestamp or "+N{s,m,h,d}"`);
|
|
70
|
+
}
|
|
71
|
+
return t;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- Status resolution ---
|
|
75
|
+
|
|
76
|
+
export type OneTimeStatus =
|
|
77
|
+
| { kind: "executed" } // executed_at already set → skip install
|
|
78
|
+
| { kind: "fire-now" } // within grace, fire on bootstrap
|
|
79
|
+
| { kind: "scheduled"; minute: number; hour: number; day: number; month: number } // future calendar match
|
|
80
|
+
| { kind: "past-grace"; reason: string };
|
|
81
|
+
|
|
82
|
+
export function resolveOneTime(
|
|
83
|
+
runAtMs: number,
|
|
84
|
+
graceSeconds: number,
|
|
85
|
+
nowMs: number,
|
|
86
|
+
executedAt: string | undefined,
|
|
87
|
+
): OneTimeStatus {
|
|
88
|
+
if (executedAt) return { kind: "executed" };
|
|
89
|
+
const ageMs = nowMs - runAtMs;
|
|
90
|
+
if (ageMs > graceSeconds * 1000) {
|
|
91
|
+
return {
|
|
92
|
+
kind: "past-grace",
|
|
93
|
+
reason:
|
|
94
|
+
`run_at=${new Date(runAtMs).toISOString()} + grace=${graceSeconds}s` +
|
|
95
|
+
` elapsed at ${new Date(runAtMs + graceSeconds * 1000).toISOString()},` +
|
|
96
|
+
` now=${new Date(nowMs).toISOString()}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (ageMs >= -1000) {
|
|
100
|
+
return { kind: "fire-now" };
|
|
101
|
+
}
|
|
102
|
+
const d = new Date(runAtMs);
|
|
103
|
+
return {
|
|
104
|
+
kind: "scheduled",
|
|
105
|
+
minute: d.getMinutes(),
|
|
106
|
+
hour: d.getHours(),
|
|
107
|
+
day: d.getDate(),
|
|
108
|
+
month: d.getMonth() + 1,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- Error sentinel surface ---
|
|
113
|
+
//
|
|
114
|
+
// Any sync-time failure for a one-time job writes a sentinel here. The
|
|
115
|
+
// heartbeat cron (Phase 2) sweeps this folder and alerts on non-empty.
|
|
116
|
+
|
|
117
|
+
export function writeSentinel(cronDir: string, slug: string, reason: string): string {
|
|
118
|
+
const dir = errorsDir(cronDir);
|
|
119
|
+
mkdirSync(dir, { recursive: true });
|
|
120
|
+
const ts = Date.now();
|
|
121
|
+
const safeSlug = slug.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
122
|
+
const path = join(dir, `${ts}-${safeSlug}.txt`);
|
|
123
|
+
const body =
|
|
124
|
+
`slug: ${slug}\n` +
|
|
125
|
+
`at: ${new Date(ts).toISOString()}\n` +
|
|
126
|
+
`reason:\n${reason}\n`;
|
|
127
|
+
writeFileSync(path, body, "utf-8");
|
|
128
|
+
return path;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- flock-based re-fire guard ---
|
|
132
|
+
//
|
|
133
|
+
// Real OS-level advisory lock via libc flock(2). Returns the fd on success
|
|
134
|
+
// so the caller can hold the lock for the lifetime of the run, or null
|
|
135
|
+
// when another process holds it. Pair every success with releaseFlock().
|
|
136
|
+
|
|
137
|
+
const LOCK_EX = 2;
|
|
138
|
+
const LOCK_NB = 4;
|
|
139
|
+
const LOCK_UN = 8;
|
|
140
|
+
|
|
141
|
+
const libc = dlopen(`libc.${suffix}`, {
|
|
142
|
+
flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
export interface FlockHandle {
|
|
146
|
+
fd: number;
|
|
147
|
+
path: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function tryFlockExclusive(path: string): FlockHandle | null {
|
|
151
|
+
let fd: number;
|
|
152
|
+
try {
|
|
153
|
+
fd = openSync(path, "r+");
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
const r = libc.symbols.flock(fd, LOCK_EX | LOCK_NB);
|
|
158
|
+
if (r !== 0) {
|
|
159
|
+
try {
|
|
160
|
+
closeSync(fd);
|
|
161
|
+
} catch {}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
return { fd, path };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function releaseFlock(h: FlockHandle): void {
|
|
168
|
+
try {
|
|
169
|
+
libc.symbols.flock(h.fd, LOCK_UN);
|
|
170
|
+
} catch {}
|
|
171
|
+
try {
|
|
172
|
+
closeSync(h.fd);
|
|
173
|
+
} catch {}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- Frontmatter mutation helpers (executed_at) ---
|
|
177
|
+
//
|
|
178
|
+
// Both .md and .sh use the existing setFrontmatterKey / setShellFrontmatterKey
|
|
179
|
+
// helpers. For .ts we patch the `executed_at:` field inside the top-level
|
|
180
|
+
// `config = { ... }` block by hand-scan (same approach as rewriteTsEnabled).
|
|
181
|
+
|
|
182
|
+
export function setTsExecutedAt(source: string, iso: string): string {
|
|
183
|
+
const open = source.search(/\bconfig\b\s*(?::\s*[^=]+)?=\s*\{/);
|
|
184
|
+
if (open < 0) {
|
|
185
|
+
throw new Error("TS one-time job has no top-level `config = { ... }` block");
|
|
186
|
+
}
|
|
187
|
+
const startBody = source.indexOf("{", open) + 1;
|
|
188
|
+
let depth = 1;
|
|
189
|
+
let inStr: string | null = null;
|
|
190
|
+
let endBody = -1;
|
|
191
|
+
for (let i = startBody; i < source.length; i++) {
|
|
192
|
+
const c = source[i];
|
|
193
|
+
const prev = source[i - 1];
|
|
194
|
+
if (inStr) {
|
|
195
|
+
if (c === inStr && prev !== "\\") inStr = null;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
199
|
+
inStr = c;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (c === "{") depth++;
|
|
203
|
+
else if (c === "}") {
|
|
204
|
+
depth--;
|
|
205
|
+
if (depth === 0) {
|
|
206
|
+
endBody = i;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (endBody < 0) throw new Error("TS config block is unbalanced");
|
|
212
|
+
const head = source.slice(0, startBody);
|
|
213
|
+
const body = source.slice(startBody, endBody);
|
|
214
|
+
const tail = source.slice(endBody);
|
|
215
|
+
const re = /\bexecuted_at\s*:\s*(?:"[^"]*"|'[^']*'|`[^`]*`)/;
|
|
216
|
+
const next = re.test(body)
|
|
217
|
+
? body.replace(re, `executed_at: "${iso}"`)
|
|
218
|
+
: `\n executed_at: "${iso}",${body}`;
|
|
219
|
+
return head + next + tail;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --- Archive ---
|
|
223
|
+
//
|
|
224
|
+
// Move the source file to ~/Library/Application Support/cronfish/done/ with a
|
|
225
|
+
// timestamp prefix so multiple fires of the same template don't collide.
|
|
226
|
+
|
|
227
|
+
export function archiveOneTime(srcPath: string): string {
|
|
228
|
+
const dir = archiveDir();
|
|
229
|
+
mkdirSync(dir, { recursive: true });
|
|
230
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
231
|
+
const name = `${ts}-${basename(srcPath)}`;
|
|
232
|
+
const dest = join(dir, name);
|
|
233
|
+
renameSync(srcPath, dest);
|
|
234
|
+
return dest;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- fsync helper for executed_at writes ---
|
|
238
|
+
|
|
239
|
+
export function writeAndFsync(path: string, contents: string): void {
|
|
240
|
+
const fd = openSync(path, "w");
|
|
241
|
+
try {
|
|
242
|
+
writeSync(fd, contents);
|
|
243
|
+
fsyncSync(fd);
|
|
244
|
+
} finally {
|
|
245
|
+
closeSync(fd);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function fileMtimeMs(path: string): number {
|
|
250
|
+
return statSync(path).mtimeMs;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function fileExists(path: string): boolean {
|
|
254
|
+
return existsSync(path);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function readUtf8(path: string): string {
|
|
258
|
+
return readFileSync(path, "utf-8");
|
|
259
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Human-string schedule parser, inspired by `friendly-cron` (npm, ISC, v0.0.2)
|
|
2
|
+
// but adapted for launchd: `StartCalendarInterval` accepts only single
|
|
3
|
+
// integers per field, so we can't express `*/N` calendar intervals. Every
|
|
4
|
+
// "every N <unit>" form therefore lowers to a seconds interval — which maps
|
|
5
|
+
// directly to launchd `StartInterval`.
|
|
6
|
+
//
|
|
7
|
+
// Supported inputs:
|
|
8
|
+
// "every second" → seconds(1)
|
|
9
|
+
// "every minute" → seconds(60)
|
|
10
|
+
// "every hour" → seconds(3600)
|
|
11
|
+
// "every N seconds" → seconds(N)
|
|
12
|
+
// "every N minutes" → seconds(N*60)
|
|
13
|
+
// "every N hours" → seconds(N*3600)
|
|
14
|
+
// "every N days" → seconds(N*86400)
|
|
15
|
+
//
|
|
16
|
+
// For specific times of day / day-of-week, use the 5-field cron form
|
|
17
|
+
// directly (e.g. `schedule: "0 9 * * 1"`).
|
|
18
|
+
|
|
19
|
+
export type FriendlyResult = { kind: "seconds"; value: number };
|
|
20
|
+
|
|
21
|
+
const SINGULAR_SECONDS: Record<string, number> = {
|
|
22
|
+
second: 1,
|
|
23
|
+
minute: 60,
|
|
24
|
+
hour: 3600,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const UNIT_SECONDS: Record<string, number> = {
|
|
28
|
+
second: 1,
|
|
29
|
+
minute: 60,
|
|
30
|
+
hour: 3600,
|
|
31
|
+
day: 86400,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function parseFriendly(input: string): FriendlyResult | null {
|
|
35
|
+
const s = input.trim().toLowerCase();
|
|
36
|
+
if (!s.startsWith("every ")) return null;
|
|
37
|
+
const rest = s.slice("every ".length).trim();
|
|
38
|
+
|
|
39
|
+
if (SINGULAR_SECONDS[rest] !== undefined) {
|
|
40
|
+
return { kind: "seconds", value: SINGULAR_SECONDS[rest] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const m = rest.match(
|
|
44
|
+
/^(\d+)\s+(second|seconds|minute|minutes|hour|hours|day|days)$/,
|
|
45
|
+
);
|
|
46
|
+
if (!m) return null;
|
|
47
|
+
const n = parseInt(m[1], 10);
|
|
48
|
+
if (!Number.isFinite(n) || n < 1) return null;
|
|
49
|
+
const unit = m[2].replace(/s$/, "");
|
|
50
|
+
const sec = UNIT_SECONDS[unit];
|
|
51
|
+
if (!sec) return null;
|
|
52
|
+
return { kind: "seconds", value: n * sec };
|
|
53
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Platform selector. v0.x ships launchd only; this seam exists so systemd /
|
|
2
|
+
// Task Scheduler backends can hang off the same interface without touching
|
|
3
|
+
// cli.ts.
|
|
4
|
+
|
|
5
|
+
import * as launchd from "./launchd.ts";
|
|
6
|
+
|
|
7
|
+
export type Platform = typeof launchd;
|
|
8
|
+
|
|
9
|
+
export function platform(): Platform {
|
|
10
|
+
if (process.platform === "darwin") return launchd;
|
|
11
|
+
throw new Error(
|
|
12
|
+
`cronfish currently supports macOS only (got ${process.platform}). Linux/Windows are on the roadmap.`,
|
|
13
|
+
);
|
|
14
|
+
}
|