@cronvello/sdk 0.1.4 → 0.2.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/dist/cli.js CHANGED
@@ -1,9 +1,880 @@
1
1
  #!/usr/bin/env node
2
+ import http from 'http';
2
3
  import { createRequire } from 'module';
3
4
  import { fileURLToPath, pathToFileURL } from 'url';
4
5
  import { resolve } from 'path';
5
6
  import { realpathSync, existsSync } from 'fs';
6
7
 
8
+ var __defProp = Object.defineProperty;
9
+ var __getOwnPropNames = Object.getOwnPropertyNames;
10
+ var __esm = (fn, res) => function __init() {
11
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
12
+ };
13
+ var __export = (target, all) => {
14
+ for (var name in all)
15
+ __defProp(target, name, { get: all[name], enumerable: true });
16
+ };
17
+
18
+ // src/internal/cron-schedule.ts
19
+ function parseCron(expr) {
20
+ if (typeof expr !== "string" || !expr.trim()) throw new Error("cron expression is empty");
21
+ let trimmed = expr.trim();
22
+ if (trimmed.startsWith("@")) {
23
+ const macro = trimmed.toLowerCase();
24
+ if (macro === "@reboot") {
25
+ return {
26
+ seconds: /* @__PURE__ */ new Set([0]),
27
+ minutes: /* @__PURE__ */ new Set(),
28
+ hours: /* @__PURE__ */ new Set(),
29
+ daysOfMonth: /* @__PURE__ */ new Set(),
30
+ months: /* @__PURE__ */ new Set(),
31
+ daysOfWeek: /* @__PURE__ */ new Set(),
32
+ domRestricted: false,
33
+ dowRestricted: false,
34
+ reboot: true
35
+ };
36
+ }
37
+ const expanded = MACRO_EXPANSIONS[macro];
38
+ if (!expanded) throw new Error(`unknown cron macro "${trimmed}" (try @daily, @hourly, \u2026)`);
39
+ trimmed = expanded;
40
+ }
41
+ const parts = trimmed.split(/\s+/);
42
+ if (parts.length !== 5 && parts.length !== 6) {
43
+ throw new Error(`expected 5 fields (min hour dom month dow) or 6 with seconds, got ${parts.length}: "${trimmed}"`);
44
+ }
45
+ const hasSeconds = parts.length === 6;
46
+ const [secRaw, minRaw, hourRaw, domRaw, monthRaw, dowRaw] = hasSeconds ? parts : ["0", ...parts];
47
+ return {
48
+ seconds: expandField(secRaw, SECOND_SPEC),
49
+ minutes: expandField(minRaw, MINUTE_SPEC),
50
+ hours: expandField(hourRaw, HOUR_SPEC),
51
+ daysOfMonth: expandField(domRaw, DOM_SPEC),
52
+ months: expandField(monthRaw, MONTH_SPEC),
53
+ daysOfWeek: expandField(dowRaw, DOW_SPEC),
54
+ domRestricted: isRestricted(domRaw),
55
+ dowRestricted: isRestricted(dowRaw),
56
+ reboot: false
57
+ };
58
+ }
59
+ function isRestricted(field) {
60
+ const f = field.trim();
61
+ return f !== "*" && f !== "?";
62
+ }
63
+ function expandField(raw, spec) {
64
+ const set = /* @__PURE__ */ new Set();
65
+ for (const term of raw.split(",")) {
66
+ if (term === "") throw new Error(`empty term in ${spec.label} field "${raw}"`);
67
+ let base = term;
68
+ let step = 1;
69
+ const slash = term.indexOf("/");
70
+ if (slash >= 0) {
71
+ base = term.slice(0, slash);
72
+ const stepStr = term.slice(slash + 1);
73
+ if (!/^\d+$/.test(stepStr) || Number(stepStr) === 0) {
74
+ throw new Error(`invalid step "${stepStr}" in ${spec.label} field "${raw}"`);
75
+ }
76
+ step = Number(stepStr);
77
+ }
78
+ let lo;
79
+ let hi;
80
+ if (base === "*" || base === "?") {
81
+ lo = spec.min;
82
+ hi = spec.max;
83
+ } else {
84
+ const dash = base.indexOf("-");
85
+ if (dash > 0) {
86
+ lo = resolveValue(base.slice(0, dash), spec);
87
+ hi = resolveValue(base.slice(dash + 1), spec);
88
+ } else {
89
+ lo = resolveValue(base, spec);
90
+ hi = step > 1 ? spec.max : lo;
91
+ }
92
+ }
93
+ if (lo === null || hi === null) {
94
+ throw new Error(`value out of range in ${spec.label} field "${raw}" (${spec.min}-${spec.max})`);
95
+ }
96
+ if (lo > hi) throw new Error(`range start ${lo} is greater than end ${hi} in ${spec.label} field "${raw}"`);
97
+ for (let v = lo; v <= hi; v += step) set.add(spec.fold ? spec.fold(v) : v);
98
+ }
99
+ return set;
100
+ }
101
+ function resolveValue(token, spec) {
102
+ const t = token.trim();
103
+ if (/^\d+$/.test(t)) {
104
+ const n = Number(t);
105
+ return n >= spec.min && n <= spec.max ? n : null;
106
+ }
107
+ if (spec.names) {
108
+ const idx = spec.names.indexOf(t.toLowerCase());
109
+ if (idx >= 0) return idx + spec.nameOffset;
110
+ }
111
+ return null;
112
+ }
113
+ function nextOccurrence(expr, opts = {}) {
114
+ const parsed = typeof expr === "string" ? parseCron(expr) : expr;
115
+ if (parsed.reboot) throw new Error("@reboot has no scheduled next occurrence (the engine fires it once at start)");
116
+ const tz = opts.timeZone ?? "UTC";
117
+ const fromMs = opts.from === void 0 ? Date.now() : typeof opts.from === "number" ? opts.from : opts.from.getTime();
118
+ const cursor = Math.floor(fromMs / 1e3) * 1e3 + 1e3;
119
+ const p = getZonedParts(cursor, tz);
120
+ const startYear = p.y;
121
+ const hourArr = sorted(parsed.hours);
122
+ const minuteArr = sorted(parsed.minutes);
123
+ const secondArr = sorted(parsed.seconds);
124
+ let guard = 0;
125
+ while (guard++ < 1e6) {
126
+ if (p.y > startYear + SEARCH_HORIZON_YEARS) return null;
127
+ if (!parsed.months.has(p.mo)) {
128
+ bumpMonth(p);
129
+ continue;
130
+ }
131
+ if (p.d > daysInMonth(p.y, p.mo)) {
132
+ bumpMonth(p);
133
+ continue;
134
+ }
135
+ if (!dayMatches(parsed, p.y, p.mo, p.d)) {
136
+ bumpDay(p);
137
+ continue;
138
+ }
139
+ const nh = firstAtLeast(hourArr, p.h);
140
+ if (nh === null) {
141
+ bumpDay(p);
142
+ continue;
143
+ }
144
+ if (nh !== p.h) {
145
+ p.h = nh;
146
+ p.mi = 0;
147
+ p.s = 0;
148
+ }
149
+ const nmi = firstAtLeast(minuteArr, p.mi);
150
+ if (nmi === null) {
151
+ p.h += 1;
152
+ p.mi = 0;
153
+ p.s = 0;
154
+ continue;
155
+ }
156
+ if (nmi !== p.mi) {
157
+ p.mi = nmi;
158
+ p.s = 0;
159
+ }
160
+ const ns = firstAtLeast(secondArr, p.s);
161
+ if (ns === null) {
162
+ p.mi += 1;
163
+ p.s = 0;
164
+ continue;
165
+ }
166
+ p.s = ns;
167
+ const epoch = zonedWallToEpoch(p, tz);
168
+ if (epoch <= fromMs) {
169
+ p.s += 1;
170
+ continue;
171
+ }
172
+ return new Date(epoch);
173
+ }
174
+ throw new Error(`nextOccurrence: search exceeded its iteration bound for "${typeof expr === "string" ? expr : "(parsed)"}"`);
175
+ }
176
+ function dayMatches(parsed, y, mo, d) {
177
+ const domOk = parsed.daysOfMonth.has(d);
178
+ const dowOk = parsed.daysOfWeek.has(weekdayOf(y, mo, d));
179
+ if (parsed.domRestricted && parsed.dowRestricted) return domOk || dowOk;
180
+ if (parsed.domRestricted) return domOk;
181
+ if (parsed.dowRestricted) return dowOk;
182
+ return true;
183
+ }
184
+ function bumpMonth(p) {
185
+ p.mo += 1;
186
+ if (p.mo > 12) {
187
+ p.mo = 1;
188
+ p.y += 1;
189
+ }
190
+ p.d = 1;
191
+ p.h = 0;
192
+ p.mi = 0;
193
+ p.s = 0;
194
+ }
195
+ function bumpDay(p) {
196
+ p.d += 1;
197
+ p.h = 0;
198
+ p.mi = 0;
199
+ p.s = 0;
200
+ }
201
+ function sorted(set) {
202
+ return [...set].sort((a, b) => a - b);
203
+ }
204
+ function firstAtLeast(arr, v) {
205
+ for (const x of arr) if (x >= v) return x;
206
+ return null;
207
+ }
208
+ function daysInMonth(year, month) {
209
+ return new Date(Date.UTC(year, month, 0)).getUTCDate();
210
+ }
211
+ function weekdayOf(year, month, day) {
212
+ return new Date(Date.UTC(year, month - 1, day)).getUTCDay();
213
+ }
214
+ function formatterFor(timeZone) {
215
+ let fmt = FORMATTER_CACHE.get(timeZone);
216
+ if (!fmt) {
217
+ fmt = new Intl.DateTimeFormat("en-US", {
218
+ timeZone,
219
+ year: "numeric",
220
+ month: "2-digit",
221
+ day: "2-digit",
222
+ hour: "2-digit",
223
+ minute: "2-digit",
224
+ second: "2-digit",
225
+ hour12: false
226
+ });
227
+ FORMATTER_CACHE.set(timeZone, fmt);
228
+ }
229
+ return fmt;
230
+ }
231
+ function getZonedParts(epochMs, timeZone) {
232
+ const parts = formatterFor(timeZone).formatToParts(new Date(epochMs));
233
+ const m = {};
234
+ for (const part of parts) if (part.type !== "literal") m[part.type] = part.value;
235
+ let h = Number(m["hour"]);
236
+ if (h === 24) h = 0;
237
+ return { y: Number(m["year"]), mo: Number(m["month"]), d: Number(m["day"]), h, mi: Number(m["minute"]), s: Number(m["second"]) };
238
+ }
239
+ function offsetAt(epochMs, timeZone) {
240
+ const p = getZonedParts(epochMs, timeZone);
241
+ const asNaive = Date.UTC(p.y, p.mo - 1, p.d, p.h, p.mi, p.s);
242
+ return asNaive - epochMs;
243
+ }
244
+ function zonedWallToEpoch(p, timeZone) {
245
+ const asUTC = Date.UTC(p.y, p.mo - 1, p.d, p.h, p.mi, p.s);
246
+ const o1 = offsetAt(asUTC, timeZone);
247
+ let epoch = asUTC - o1;
248
+ const o2 = offsetAt(epoch, timeZone);
249
+ if (o2 !== o1) epoch = asUTC - o2;
250
+ return epoch;
251
+ }
252
+ function previewSchedule(expr, opts = {}) {
253
+ const parsed = parseCron(expr);
254
+ const timeZone = opts.timeZone ?? localTimeZone();
255
+ const count = Math.min(Math.max(Math.trunc(opts.count ?? 5), 1), 100);
256
+ const out2 = [];
257
+ let from = opts.from === void 0 ? Date.now() : typeof opts.from === "number" ? opts.from : opts.from.getTime();
258
+ for (let i = 0; i < count; i++) {
259
+ const next = nextOccurrence(parsed, { from, timeZone });
260
+ if (!next) break;
261
+ out2.push(next);
262
+ from = next.getTime();
263
+ }
264
+ return out2;
265
+ }
266
+ function upcomingFires(jobs, opts = {}) {
267
+ const from = opts.from === void 0 ? Date.now() : typeof opts.from === "number" ? opts.from : opts.from.getTime();
268
+ const withinMs = opts.withinMs ?? 60 * 60 * 1e3;
269
+ const maxPerJob = Math.max(1, opts.maxPerJob ?? 50);
270
+ const horizon = from + withinMs;
271
+ const fires = [];
272
+ for (const job of jobs) {
273
+ let parsed;
274
+ try {
275
+ parsed = parseCron(job.schedule);
276
+ } catch {
277
+ continue;
278
+ }
279
+ if (parsed.reboot) continue;
280
+ let cursor = from;
281
+ for (let i = 0; i < maxPerJob; i++) {
282
+ const next = nextOccurrence(parsed, { from: cursor, timeZone: job.timeZone });
283
+ if (!next || next.getTime() > horizon) break;
284
+ fires.push({ key: job.key, time: next, schedule: job.schedule, timeZone: job.timeZone });
285
+ cursor = next.getTime();
286
+ }
287
+ }
288
+ fires.sort((a, b) => a.time.getTime() - b.time.getTime());
289
+ return fires;
290
+ }
291
+ function localTimeZone() {
292
+ try {
293
+ return new Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
294
+ } catch {
295
+ return "UTC";
296
+ }
297
+ }
298
+ var MACRO_EXPANSIONS, MONTH_NAMES, DOW_NAMES, SECOND_SPEC, MINUTE_SPEC, HOUR_SPEC, DOM_SPEC, MONTH_SPEC, DOW_SPEC, SEARCH_HORIZON_YEARS, FORMATTER_CACHE;
299
+ var init_cron_schedule = __esm({
300
+ "src/internal/cron-schedule.ts"() {
301
+ MACRO_EXPANSIONS = {
302
+ "@yearly": "0 0 1 1 *",
303
+ "@annually": "0 0 1 1 *",
304
+ "@monthly": "0 0 1 * *",
305
+ "@weekly": "0 0 * * 0",
306
+ "@daily": "0 0 * * *",
307
+ "@midnight": "0 0 * * *",
308
+ "@hourly": "0 * * * *"
309
+ };
310
+ MONTH_NAMES = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
311
+ DOW_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
312
+ SECOND_SPEC = { min: 0, max: 59, nameOffset: 0, label: "second" };
313
+ MINUTE_SPEC = { min: 0, max: 59, nameOffset: 0, label: "minute" };
314
+ HOUR_SPEC = { min: 0, max: 23, nameOffset: 0, label: "hour" };
315
+ DOM_SPEC = { min: 1, max: 31, nameOffset: 0, label: "day-of-month" };
316
+ MONTH_SPEC = { min: 1, max: 12, names: MONTH_NAMES, nameOffset: 1, label: "month" };
317
+ DOW_SPEC = { min: 0, max: 7, names: DOW_NAMES, nameOffset: 0, fold: (v) => v % 7, label: "day-of-week" };
318
+ SEARCH_HORIZON_YEARS = 5;
319
+ FORMATTER_CACHE = /* @__PURE__ */ new Map();
320
+ }
321
+ });
322
+
323
+ // src/dev/dashboard-assets.ts
324
+ var DASHBOARD_HTML;
325
+ var init_dashboard_assets = __esm({
326
+ "src/dev/dashboard-assets.ts"() {
327
+ DASHBOARD_HTML = `<!doctype html>
328
+ <html lang="en">
329
+ <head>
330
+ <meta charset="utf-8" />
331
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
332
+ <meta name="robots" content="noindex" />
333
+ <title>Cronvello \u2014 local dashboard</title>
334
+ <style>
335
+ :root {
336
+ --indigo: #4F46E5;
337
+ --indigo-soft: #6366F1;
338
+ --teal: #14B8A6;
339
+ --green: #10B981;
340
+ --amber: #F59E0B;
341
+ --red: #EF4444;
342
+ --bg: #f8fafc;
343
+ --panel: #ffffff;
344
+ --panel-2: #f1f5f9;
345
+ --border: #e2e8f0;
346
+ --text: #0f172a;
347
+ --muted: #64748b;
348
+ --shadow: 0 1px 3px rgba(15, 23, 42, 0.08), 0 1px 2px rgba(15, 23, 42, 0.04);
349
+ }
350
+ [data-theme="dark"] {
351
+ --bg: #0b1120;
352
+ --panel: #111827;
353
+ --panel-2: #0f172a;
354
+ --border: #1f2937;
355
+ --text: #e5e7eb;
356
+ --muted: #94a3b8;
357
+ --shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 1px 2px rgba(0, 0, 0, 0.4);
358
+ }
359
+ /* Dark-mode-first: follow the OS preference unless the user has explicitly chosen light. */
360
+ @media (prefers-color-scheme: dark) {
361
+ :root:not([data-theme="light"]) {
362
+ --bg: #0b1120;
363
+ --panel: #111827;
364
+ --panel-2: #0f172a;
365
+ --border: #1f2937;
366
+ --text: #e5e7eb;
367
+ --muted: #94a3b8;
368
+ --shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 1px 2px rgba(0, 0, 0, 0.4);
369
+ }
370
+ }
371
+ * { box-sizing: border-box; }
372
+ html, body { margin: 0; padding: 0; }
373
+ body {
374
+ background: var(--bg);
375
+ color: var(--text);
376
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Inter, Roboto, sans-serif;
377
+ font-size: 14px;
378
+ line-height: 1.5;
379
+ }
380
+ code, .mono { font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; }
381
+ header {
382
+ display: flex; align-items: center; gap: 12px;
383
+ padding: 14px 20px;
384
+ border-bottom: 1px solid var(--border);
385
+ background: var(--panel);
386
+ position: sticky; top: 0; z-index: 10;
387
+ }
388
+ .logo {
389
+ width: 28px; height: 28px; border-radius: 8px;
390
+ display: grid; place-items: center;
391
+ background: linear-gradient(135deg, var(--indigo), var(--teal));
392
+ color: #fff; font-size: 17px; font-weight: 700;
393
+ }
394
+ .brand { font-weight: 700; letter-spacing: -0.01em; }
395
+ .brand small { color: var(--muted); font-weight: 500; margin-left: 6px; }
396
+ .spacer { flex: 1; }
397
+ .pill {
398
+ display: inline-flex; align-items: center; gap: 6px;
399
+ padding: 4px 10px; border-radius: 999px;
400
+ font-size: 12px; font-weight: 600;
401
+ border: 1px solid var(--border); background: var(--panel-2); color: var(--muted);
402
+ }
403
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
404
+ .pill.live .dot { background: var(--green); box-shadow: 0 0 0 0 rgba(16,185,129,0.5); animation: pulse 1.8s infinite; }
405
+ .pill.down .dot { background: var(--red); }
406
+ @keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(16,185,129,0.5); } 70% { box-shadow: 0 0 0 6px rgba(16,185,129,0); } 100% { box-shadow: 0 0 0 0 rgba(16,185,129,0); } }
407
+ button {
408
+ font: inherit; cursor: pointer; color: var(--text);
409
+ background: var(--panel-2); border: 1px solid var(--border);
410
+ border-radius: 8px; padding: 6px 12px;
411
+ }
412
+ button:hover { border-color: var(--indigo-soft); }
413
+ button.primary { background: var(--indigo); color: #fff; border-color: var(--indigo); }
414
+ button.primary:hover { background: var(--indigo-soft); }
415
+ .layout { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; padding: 16px 20px; align-items: start; }
416
+ @media (max-width: 860px) { .layout { grid-template-columns: 1fr; } }
417
+ .card { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow); overflow: hidden; }
418
+ .card > h2 { margin: 0; padding: 12px 16px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); border-bottom: 1px solid var(--border); }
419
+ table { width: 100%; border-collapse: collapse; }
420
+ th, td { text-align: left; padding: 10px 16px; border-bottom: 1px solid var(--border); vertical-align: middle; }
421
+ th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); font-weight: 600; }
422
+ tr:last-child td { border-bottom: none; }
423
+ tbody tr { cursor: pointer; }
424
+ tbody tr:hover { background: var(--panel-2); }
425
+ .jobkey { font-weight: 600; }
426
+ .jobdesc { color: var(--muted); font-size: 12px; }
427
+ .count { font-variant-numeric: tabular-nums; }
428
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 700; }
429
+ .badge.success { background: rgba(16,185,129,0.15); color: var(--green); }
430
+ .badge.error, .badge.timed_out { background: rgba(239,68,68,0.15); color: var(--red); }
431
+ .badge.skipped { background: rgba(100,116,139,0.18); color: var(--muted); }
432
+ .badge.running { background: rgba(79,70,229,0.15); color: var(--indigo-soft); }
433
+ .badge.none { background: var(--panel-2); color: var(--muted); }
434
+ .feed { max-height: 70vh; overflow-y: auto; }
435
+ .ev { display: flex; gap: 10px; padding: 8px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
436
+ .ev .glyph { width: 16px; text-align: center; flex: none; }
437
+ .ev .body { flex: 1; min-width: 0; }
438
+ .ev .when { color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
439
+ .ev.success .glyph { color: var(--green); }
440
+ .ev.error .glyph, .ev.timeout .glyph { color: var(--red); }
441
+ .ev.retry .glyph { color: var(--amber); }
442
+ .ev.fire .glyph { color: var(--indigo-soft); }
443
+ .ev.skipped .glyph, .ev.scheduled .glyph, .ev.engine .glyph { color: var(--muted); }
444
+ .empty { padding: 24px 16px; color: var(--muted); text-align: center; }
445
+ footer { padding: 12px 20px 28px; color: var(--muted); font-size: 12px; }
446
+ .overlay { position: fixed; inset: 0; background: rgba(15,23,42,0.45); display: none; align-items: stretch; justify-content: flex-end; z-index: 30; }
447
+ .overlay.open { display: flex; }
448
+ .drawer { width: min(520px, 92vw); background: var(--panel); border-left: 1px solid var(--border); height: 100%; overflow-y: auto; box-shadow: var(--shadow); }
449
+ .drawer header { background: var(--panel); }
450
+ .drawer .pad { padding: 16px; }
451
+ .kv { display: flex; gap: 8px; margin: 4px 0; }
452
+ .kv span:first-child { color: var(--muted); min-width: 90px; }
453
+ .section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); margin: 18px 0 8px; }
454
+ ul.plain { list-style: none; margin: 0; padding: 0; }
455
+ ul.plain li { padding: 6px 0; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; gap: 8px; }
456
+ </style>
457
+ </head>
458
+ <body>
459
+ <header>
460
+ <div class="logo">&#8635;</div>
461
+ <div class="brand">Cronvello <small>local dashboard</small></div>
462
+ <div class="spacer"></div>
463
+ <span id="conn" class="pill"><span class="dot"></span><span id="conn-text">connecting&hellip;</span></span>
464
+ <button id="theme-btn" title="Toggle theme">&#9788;</button>
465
+ </header>
466
+
467
+ <div class="layout">
468
+ <section class="card">
469
+ <h2>Jobs &mdash; next fire &amp; last status</h2>
470
+ <table>
471
+ <thead><tr><th>Job</th><th>Schedule</th><th>Timezone</th><th>Next fire</th><th>Last</th></tr></thead>
472
+ <tbody id="jobs"><tr><td colspan="5" class="empty">Loading jobs&hellip;</td></tr></tbody>
473
+ </table>
474
+ </section>
475
+
476
+ <section class="card">
477
+ <h2>Live run feed</h2>
478
+ <div id="feed" class="feed"><div class="empty">Waiting for runs&hellip;</div></div>
479
+ </section>
480
+ </div>
481
+
482
+ <footer>Local &middot; no account &middot; no cloud &middot; no external network. The engine on this machine is the only source of truth.</footer>
483
+
484
+ <div id="overlay" class="overlay">
485
+ <div class="drawer">
486
+ <header>
487
+ <div class="brand" id="drawer-title">Job</div>
488
+ <div class="spacer"></div>
489
+ <button id="drawer-close">Close</button>
490
+ </header>
491
+ <div class="pad" id="drawer-body"></div>
492
+ </div>
493
+ </div>
494
+
495
+ <script>
496
+ (function () {
497
+ "use strict";
498
+ var MAX_FEED = 200;
499
+ var jobsByKey = {};
500
+
501
+ function el(id) { return document.getElementById(id); }
502
+ function esc(s) {
503
+ return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
504
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
505
+ });
506
+ }
507
+ function pad(n) { return n < 10 ? "0" + n : "" + n; }
508
+ function clockTime(ms) {
509
+ var d = new Date(ms);
510
+ return pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
511
+ }
512
+ function fmtDuration(ms) {
513
+ if (ms == null) return "";
514
+ if (ms < 1000) return ms + "ms";
515
+ return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + "s";
516
+ }
517
+ function countdown(iso) {
518
+ if (!iso) return "&mdash;";
519
+ var diff = new Date(iso).getTime() - Date.now();
520
+ if (diff <= 0) return "due now";
521
+ var s = Math.floor(diff / 1000);
522
+ var d = Math.floor(s / 86400); s -= d * 86400;
523
+ var h = Math.floor(s / 3600); s -= h * 3600;
524
+ var m = Math.floor(s / 60); s -= m * 60;
525
+ var out = "in ";
526
+ if (d) out += d + "d ";
527
+ if (d || h) out += h + "h ";
528
+ if (d || h || m) out += m + "m ";
529
+ out += s + "s";
530
+ return out;
531
+ }
532
+
533
+ // \u2500\u2500 Theme \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
534
+ function applyTheme(t) {
535
+ if (t === "dark" || t === "light") document.documentElement.setAttribute("data-theme", t);
536
+ else document.documentElement.removeAttribute("data-theme");
537
+ }
538
+ function resolvedTheme() {
539
+ var t = document.documentElement.getAttribute("data-theme");
540
+ if (t) return t;
541
+ return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
542
+ }
543
+ try { applyTheme(localStorage.getItem("cronvello-theme")); } catch (e) {}
544
+ el("theme-btn").addEventListener("click", function () {
545
+ var next = resolvedTheme() === "dark" ? "light" : "dark";
546
+ applyTheme(next);
547
+ try { localStorage.setItem("cronvello-theme", next); } catch (e) {}
548
+ });
549
+
550
+ // \u2500\u2500 Jobs table \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
551
+ function statusBadge(s) {
552
+ if (!s) return '<span class="badge none">&mdash;</span>';
553
+ return '<span class="badge ' + esc(s) + '">' + esc(s) + "</span>";
554
+ }
555
+ function renderJobs(jobs) {
556
+ jobsByKey = {};
557
+ if (!jobs.length) { el("jobs").innerHTML = '<tr><td colspan="5" class="empty">No jobs registered.</td></tr>'; return; }
558
+ var rows = "";
559
+ for (var i = 0; i < jobs.length; i++) {
560
+ var j = jobs[i];
561
+ jobsByKey[j.key] = j;
562
+ var last = j.running ? "running" : (j.lastStatus || "");
563
+ rows +=
564
+ '<tr data-key="' + esc(j.key) + '">' +
565
+ "<td><div class=\\"jobkey\\">" + esc(j.key) + "</div>" + (j.description ? '<div class="jobdesc">' + esc(j.description) + "</div>" : "") + "</td>" +
566
+ '<td><code>' + esc(j.schedule) + "</code></td>" +
567
+ "<td>" + esc(j.timeZone) + "</td>" +
568
+ '<td class="count" data-next="' + esc(j.nextFire || "") + '">' + countdown(j.nextFire) + "</td>" +
569
+ "<td>" + (j.running ? '<span class="badge running">running</span>' : statusBadge(j.lastStatus)) + "</td>" +
570
+ "</tr>";
571
+ }
572
+ el("jobs").innerHTML = rows;
573
+ var trs = el("jobs").querySelectorAll("tr[data-key]");
574
+ for (var k = 0; k < trs.length; k++) {
575
+ trs[k].addEventListener("click", function () { openDrawer(this.getAttribute("data-key")); });
576
+ }
577
+ }
578
+ function tickCountdowns() {
579
+ var cells = el("jobs").querySelectorAll("td[data-next]");
580
+ for (var i = 0; i < cells.length; i++) {
581
+ var iso = cells[i].getAttribute("data-next");
582
+ cells[i].innerHTML = iso ? countdown(iso) : "&mdash;";
583
+ }
584
+ }
585
+
586
+ function loadJobs() {
587
+ return fetch("/api/jobs").then(function (r) { return r.json(); }).then(function (jobs) {
588
+ // Fold in each job's last run so the table can show a status without a per-row fetch.
589
+ return fetch("/api/runs").then(function (r) { return r.json(); }).then(function (runs) {
590
+ var lastByKey = {};
591
+ for (var i = 0; i < runs.length; i++) { if (!lastByKey[runs[i].key]) lastByKey[runs[i].key] = runs[i]; }
592
+ for (var j = 0; j < jobs.length; j++) {
593
+ var lr = lastByKey[jobs[j].key];
594
+ jobs[j].lastStatus = lr ? lr.status : "";
595
+ }
596
+ renderJobs(jobs);
597
+ });
598
+ }).catch(function () {});
599
+ }
600
+
601
+ // \u2500\u2500 Live feed \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
602
+ var glyphs = { fire: "&#9655;", success: "&#10003;", error: "&#10007;", timeout: "&#9203;", retry: "&#8635;", skipped: "&#8866;", scheduled: "&#128337;", "engine-start": "&#9889;", "engine-stop": "&#9632;" };
603
+ function describe(ev) {
604
+ switch (ev.type) {
605
+ case "fire": return "<strong>" + esc(ev.key) + "</strong> fired" + (ev.attempt > 1 ? " (attempt " + ev.attempt + ")" : "");
606
+ case "success": return "<strong>" + esc(ev.key) + "</strong> succeeded in " + fmtDuration(ev.durationMs) + (ev.attempts > 1 ? " after " + ev.attempts + " attempts" : "");
607
+ case "error": return "<strong>" + esc(ev.key) + "</strong> errored: " + esc(ev.error) + (ev.willRetry ? " \u2014 will retry" : "");
608
+ case "timeout": return "<strong>" + esc(ev.key) + "</strong> timed out after " + fmtDuration(ev.durationMs) + (ev.willRetry ? " \u2014 will retry" : "");
609
+ case "retry": return "<strong>" + esc(ev.key) + "</strong> retrying (attempt " + ev.attempt + ") in " + fmtDuration(ev.delayMs);
610
+ case "skipped": return "<strong>" + esc(ev.key) + "</strong> skipped (overlap)";
611
+ case "scheduled": return "<strong>" + esc(ev.key) + "</strong> scheduled";
612
+ case "engine-start": return "Engine started \u2014 " + ev.jobs + " job(s)";
613
+ case "engine-stop": return "Engine stopped";
614
+ default: return esc(ev.type);
615
+ }
616
+ }
617
+ function feedClass(type) {
618
+ if (type === "engine-start" || type === "engine-stop" || type === "scheduled") return "engine";
619
+ return type;
620
+ }
621
+ function pushEvent(ev) {
622
+ var feed = el("feed");
623
+ if (feed.firstChild && feed.firstChild.className === "empty") feed.innerHTML = "";
624
+ var div = document.createElement("div");
625
+ div.className = "ev " + feedClass(ev.type);
626
+ div.innerHTML =
627
+ '<div class="glyph">' + (glyphs[ev.type] || "&#8226;") + "</div>" +
628
+ '<div class="body">' + describe(ev) + "</div>" +
629
+ '<div class="when">' + clockTime(ev.at || Date.now()) + "</div>";
630
+ feed.insertBefore(div, feed.firstChild);
631
+ while (feed.childNodes.length > MAX_FEED) feed.removeChild(feed.lastChild);
632
+
633
+ // A terminal event changes a job's last status / running flag \u2014 refresh the table.
634
+ if (ev.type === "success" || ev.type === "error" || ev.type === "timeout" || ev.type === "skipped" || ev.type === "scheduled") {
635
+ loadJobs();
636
+ }
637
+ }
638
+
639
+ function setConn(state) {
640
+ var pill = el("conn"), text = el("conn-text");
641
+ pill.className = "pill " + state;
642
+ text.textContent = state === "live" ? "live" : (state === "down" ? "disconnected" : "connecting\u2026");
643
+ }
644
+
645
+ function connect() {
646
+ setConn("connecting");
647
+ var src = new EventSource("/api/events");
648
+ src.onopen = function () { setConn("live"); };
649
+ src.onmessage = function (m) {
650
+ try { pushEvent(JSON.parse(m.data)); } catch (e) {}
651
+ };
652
+ src.onerror = function () { setConn("down"); };
653
+ }
654
+
655
+ // \u2500\u2500 Job drawer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
656
+ function openDrawer(key) {
657
+ var job = jobsByKey[key];
658
+ if (!job) return;
659
+ el("drawer-title").textContent = key;
660
+ el("drawer-body").innerHTML = '<div class="empty">Loading&hellip;</div>';
661
+ el("overlay").classList.add("open");
662
+
663
+ Promise.all([
664
+ fetch("/api/preview/" + encodeURIComponent(key) + "?n=5").then(function (r) { return r.json(); }).catch(function () { return []; }),
665
+ fetch("/api/runs/" + encodeURIComponent(key)).then(function (r) { return r.json(); }).catch(function () { return []; })
666
+ ]).then(function (res) {
667
+ var fires = res[0] || [], runs = res[1] || [];
668
+ var html =
669
+ '<div class="kv"><span>Schedule</span><code>' + esc(job.schedule) + "</code></div>" +
670
+ '<div class="kv"><span>Timezone</span><span>' + esc(job.timeZone) + "</span></div>" +
671
+ (job.description ? '<div class="kv"><span>Description</span><span>' + esc(job.description) + "</span></div>" : "") +
672
+ '<div style="margin-top:14px"><button class="primary" id="run-now">Run now</button></div>' +
673
+ '<div class="section-title">Next fire times</div>';
674
+ if (fires.length) {
675
+ html += '<ul class="plain">';
676
+ for (var i = 0; i < fires.length; i++) html += "<li><span>" + esc(new Date(fires[i]).toLocaleString()) + "</span><span class=\\"count\\">" + countdown(fires[i]) + "</span></li>";
677
+ html += "</ul>";
678
+ } else { html += '<div class="empty">No upcoming fires.</div>'; }
679
+
680
+ html += '<div class="section-title">Recent runs</div>';
681
+ if (runs.length) {
682
+ html += '<ul class="plain">';
683
+ for (var j = 0; j < runs.length && j < 20; j++) {
684
+ var run = runs[j];
685
+ html += "<li><span>" + statusBadge(run.status) + " <span class=\\"when\\">" + esc(new Date(run.startedAt).toLocaleString()) + "</span></span><span>" + fmtDuration(run.durationMs) + "</span></li>";
686
+ }
687
+ html += "</ul>";
688
+ } else { html += '<div class="empty">No runs yet.</div>'; }
689
+
690
+ el("drawer-body").innerHTML = html;
691
+ el("run-now").addEventListener("click", function () { runNow(key, this); });
692
+ });
693
+ }
694
+ function closeDrawer() { el("overlay").classList.remove("open"); }
695
+ el("drawer-close").addEventListener("click", closeDrawer);
696
+ el("overlay").addEventListener("click", function (e) { if (e.target === el("overlay")) closeDrawer(); });
697
+
698
+ function runNow(key, btn) {
699
+ btn.disabled = true; btn.textContent = "Running\u2026";
700
+ fetch("/api/trigger/" + encodeURIComponent(key), { method: "POST", headers: { "content-type": "application/json" } })
701
+ .then(function (r) { return r.json(); })
702
+ .then(function () { btn.textContent = "Ran \u2713"; setTimeout(function () { openDrawer(key); }, 600); loadJobs(); })
703
+ .catch(function () { btn.disabled = false; btn.textContent = "Run now"; });
704
+ }
705
+
706
+ // \u2500\u2500 Boot \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
707
+ loadJobs();
708
+ connect();
709
+ setInterval(tickCountdowns, 1000);
710
+ setInterval(loadJobs, 15000);
711
+ })();
712
+ </script>
713
+ </body>
714
+ </html>`;
715
+ }
716
+ });
717
+
718
+ // src/dev/dashboard.ts
719
+ var dashboard_exports = {};
720
+ __export(dashboard_exports, {
721
+ startDashboard: () => startDashboard
722
+ });
723
+ function startDashboard(engine, options = {}) {
724
+ const host = options.host ?? DEFAULT_HOST;
725
+ const port = options.port ?? DEFAULT_PORT;
726
+ const sseClients = /* @__PURE__ */ new Set();
727
+ const server = http.createServer((req, res) => handleRequest(engine, sseClients, req, res));
728
+ return new Promise((resolve2, reject) => {
729
+ const onError = (err) => {
730
+ server.removeListener("error", onError);
731
+ if (err.code === "EADDRINUSE") {
732
+ reject(new Error(`Cronvello dashboard: port ${port} on ${host} is already in use \u2014 pass a different --port.`));
733
+ } else {
734
+ reject(err);
735
+ }
736
+ };
737
+ server.once("error", onError);
738
+ server.listen(port, host, () => {
739
+ server.removeListener("error", onError);
740
+ const actualPort = server.address().port;
741
+ const shown = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
742
+ const url = `http://${shown}:${actualPort}`;
743
+ let closed = false;
744
+ const close = () => {
745
+ if (closed) return Promise.resolve();
746
+ closed = true;
747
+ for (const client of sseClients) client.end();
748
+ sseClients.clear();
749
+ return new Promise((res2) => {
750
+ server.closeAllConnections?.();
751
+ server.close(() => res2());
752
+ });
753
+ };
754
+ resolve2({ url, port: actualPort, close });
755
+ });
756
+ });
757
+ }
758
+ function handleRequest(engine, sseClients, req, res) {
759
+ const url = new URL(req.url ?? "/", "http://localhost");
760
+ const path = url.pathname;
761
+ const method = req.method ?? "GET";
762
+ if (method === "GET" && path === "/") {
763
+ return sendHtml(res, DASHBOARD_HTML);
764
+ }
765
+ if (method === "GET" && path === "/api/events") {
766
+ return openEventStream(engine, sseClients, req, res);
767
+ }
768
+ if (method === "GET" && path === "/api/health") {
769
+ return sendJson(res, 200, { ok: true, jobs: engine.jobs().length, activeRuns: engine.activeRuns });
770
+ }
771
+ if (method === "GET" && path === "/api/runs.ndjson") {
772
+ res.writeHead(200, { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store" });
773
+ const body = engine.toNdjson();
774
+ return void res.end(body.length ? body + "\n" : "");
775
+ }
776
+ if (method === "GET" && path === "/api/runs") {
777
+ const key = url.searchParams.get("key");
778
+ return sendJson(res, 200, key ? engine.runsFor(key) : engine.runs());
779
+ }
780
+ if (method === "GET" && path.startsWith("/api/runs/")) {
781
+ return sendJson(res, 200, engine.runsFor(decodeKey(path, "/api/runs/")));
782
+ }
783
+ if (method === "GET" && path.startsWith("/api/preview/")) {
784
+ const key = decodeKey(path, "/api/preview/");
785
+ const job = engine.snapshot().find((j) => j.key === key);
786
+ if (!job) return sendJson(res, 404, { error: `unknown job '${key}'` });
787
+ const n = clampCount(url.searchParams.get("n"));
788
+ try {
789
+ const fires = previewSchedule(job.schedule, { timeZone: job.timeZone, count: n });
790
+ return sendJson(res, 200, fires.map((d) => d.toISOString()));
791
+ } catch {
792
+ return sendJson(res, 200, []);
793
+ }
794
+ }
795
+ if (method === "GET" && path === "/api/jobs") {
796
+ const jobs = engine.snapshot().map((j) => ({
797
+ key: j.key,
798
+ schedule: j.schedule,
799
+ timeZone: j.timeZone,
800
+ description: j.description,
801
+ nextFire: j.nextFire ? j.nextFire.toISOString() : null,
802
+ running: j.running
803
+ }));
804
+ return sendJson(res, 200, jobs);
805
+ }
806
+ if (method === "POST" && path.startsWith("/api/trigger/")) {
807
+ if (!isSameOrigin(req)) return sendJson(res, 403, { error: "cross-origin request rejected" });
808
+ const key = decodeKey(path, "/api/trigger/");
809
+ return void engine.trigger(key).then((record) => sendJson(res, 200, record)).catch((err) => {
810
+ const status = /unknown job/.test(err.message) ? 404 : 400;
811
+ sendJson(res, status, { error: err.message });
812
+ });
813
+ }
814
+ sendJson(res, 404, { error: "not found" });
815
+ }
816
+ function openEventStream(engine, sseClients, req, res) {
817
+ res.writeHead(200, {
818
+ "content-type": "text/event-stream; charset=utf-8",
819
+ "cache-control": "no-store",
820
+ connection: "keep-alive",
821
+ "x-accel-buffering": "no"
822
+ });
823
+ res.write(": connected\n\n");
824
+ sseClients.add(res);
825
+ const unsubscribe = engine.subscribe((event) => {
826
+ res.write(`data: ${JSON.stringify(event)}
827
+
828
+ `);
829
+ });
830
+ const heartbeat = setInterval(() => res.write(": ping\n\n"), HEARTBEAT_MS);
831
+ if (typeof heartbeat.unref === "function") heartbeat.unref();
832
+ const cleanup = () => {
833
+ clearInterval(heartbeat);
834
+ unsubscribe();
835
+ sseClients.delete(res);
836
+ };
837
+ res.on("close", cleanup);
838
+ req.on("close", cleanup);
839
+ }
840
+ function sendJson(res, status, body) {
841
+ const payload = JSON.stringify(body);
842
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
843
+ res.end(payload);
844
+ }
845
+ function sendHtml(res, html) {
846
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
847
+ res.end(html);
848
+ }
849
+ function decodeKey(path, prefix) {
850
+ return decodeURIComponent(path.slice(prefix.length));
851
+ }
852
+ function clampCount(raw) {
853
+ const n = raw ? Number.parseInt(raw, 10) : 5;
854
+ if (!Number.isFinite(n) || n < 1) return 5;
855
+ return Math.min(n, MAX_PREVIEW);
856
+ }
857
+ function isSameOrigin(req) {
858
+ const origin = req.headers.origin;
859
+ if (!origin) return true;
860
+ try {
861
+ return new URL(origin).host === req.headers.host;
862
+ } catch {
863
+ return false;
864
+ }
865
+ }
866
+ var DEFAULT_PORT, DEFAULT_HOST, HEARTBEAT_MS, MAX_PREVIEW;
867
+ var init_dashboard = __esm({
868
+ "src/dev/dashboard.ts"() {
869
+ init_cron_schedule();
870
+ init_dashboard_assets();
871
+ DEFAULT_PORT = 4747;
872
+ DEFAULT_HOST = "127.0.0.1";
873
+ HEARTBEAT_MS = 15e3;
874
+ MAX_PREVIEW = 50;
875
+ }
876
+ });
877
+
7
878
  // src/internal/errors.ts
8
879
  var CronvelloError = class extends Error {
9
880
  constructor(message) {
@@ -403,8 +1274,8 @@ function formatSyncResult(result, options = {}) {
403
1274
  `${paint("Cronvello", "cyan")} ${verb} ${paint(`"${result.jobName}"`, "bold")} ${paint(`(${result.jobId})`, "gray")}`
404
1275
  );
405
1276
  const order = ["created", "updated", "deleted", "skipped", "unchanged"];
406
- const sorted = [...result.changes].sort((a, b) => order.indexOf(a.action) - order.indexOf(b.action));
407
- for (const c2 of sorted) {
1277
+ const sorted2 = [...result.changes].sort((a, b) => order.indexOf(a.action) - order.indexOf(b.action));
1278
+ for (const c2 of sorted2) {
408
1279
  const g = GLYPH[c2.action];
409
1280
  const detail = c2.changedFields && c2.changedFields.length ? paint(` (${c2.changedFields.join(", ")})`, "gray") : c2.reason ? paint(` (${c2.reason})`, "gray") : "";
410
1281
  lines.push(` ${paint(g.sign, g.color)} ${paint(c2.action.padEnd(9), g.color)} ${c2.key}${detail}`);
@@ -429,6 +1300,9 @@ function generateDispatchSecret(bytes = 32) {
429
1300
  return Array.from(buf).map((b) => b.toString(16).padStart(2, "0")).join("");
430
1301
  }
431
1302
 
1303
+ // src/cli/index.ts
1304
+ init_cron_schedule();
1305
+
432
1306
  // src/cli/ui.ts
433
1307
  var E = String.fromCharCode(27);
434
1308
  var ENV = typeof process !== "undefined" && process.env ? process.env : {};
@@ -513,6 +1387,8 @@ var VERSION = (() => {
513
1387
  return "0.0.0";
514
1388
  }
515
1389
  })();
1390
+ var VALUE_FLAGS = ["limit", "status", "job", "type", "runType", "tz", "window", "count", "n", "port", "host"];
1391
+ var SHORT_VALUE_FLAGS = ["n", "c"];
516
1392
  function parseArgs(argv) {
517
1393
  const positionals = [];
518
1394
  const flags = {};
@@ -521,14 +1397,21 @@ function parseArgs(argv) {
521
1397
  if (a.startsWith("--")) {
522
1398
  const key = a.slice(2);
523
1399
  const next = argv[i + 1];
524
- if (next !== void 0 && !next.startsWith("-") && ["limit", "status", "job", "type", "runType"].includes(key)) {
1400
+ if (next !== void 0 && !next.startsWith("-") && VALUE_FLAGS.includes(key)) {
525
1401
  flags[key] = next;
526
1402
  i++;
527
1403
  } else {
528
1404
  flags[key] = true;
529
1405
  }
530
1406
  } else if (a.startsWith("-") && a.length > 1) {
531
- flags[a.slice(1)] = true;
1407
+ const key = a.slice(1);
1408
+ const next = argv[i + 1];
1409
+ if (next !== void 0 && !next.startsWith("-") && SHORT_VALUE_FLAGS.includes(key)) {
1410
+ flags[key] = next;
1411
+ i++;
1412
+ } else {
1413
+ flags[key] = true;
1414
+ }
532
1415
  } else {
533
1416
  positionals.push(a);
534
1417
  }
@@ -705,10 +1588,81 @@ async function cmdSync(positionals, flags, useJson) {
705
1588
  if (useJson) return json(result);
706
1589
  out(formatSyncResult(result, { color: true, dryRun }));
707
1590
  }
708
- async function cmdDev(positionals, useJson) {
1591
+ async function cmdDevEngine(positionals, flags, useJson) {
1592
+ const app = await loadApp(positionals[0]);
1593
+ const dryRun = !!flags["dry-run"] || !!flags["dry"];
1594
+ if (dryRun) {
1595
+ const engine2 = app.dev({ autoStart: false, installSignalHandlers: false });
1596
+ const windowMs = parseDurationFlag(flags["window"], 60 * 60 * 1e3);
1597
+ const maxPerJob = 50;
1598
+ const fires = upcomingFires(
1599
+ engine2.jobs().map((j) => ({ key: j.key, schedule: j.schedule, timeZone: j.timeZone })),
1600
+ { withinMs: windowMs, maxPerJob }
1601
+ );
1602
+ const cappedJobs = [...new Set(fires.map((f) => f.key))].filter(
1603
+ (key) => fires.filter((f) => f.key === key).length >= maxPerJob
1604
+ );
1605
+ if (useJson) {
1606
+ return json({
1607
+ window: { ms: windowMs },
1608
+ capped: cappedJobs,
1609
+ fires: fires.map((f) => ({ key: f.key, time: f.time.toISOString(), schedule: f.schedule, timeZone: f.timeZone }))
1610
+ });
1611
+ }
1612
+ header(`dev \xB7 dry-run \xB7 next ${formatDuration(windowMs)}`);
1613
+ out(renderUpcoming(fires));
1614
+ out();
1615
+ out(c.gray(fires.length ? `${fires.length} fire(s) would run \u2014 no handlers were executed` : "nothing scheduled in this window"));
1616
+ if (cappedJobs.length) {
1617
+ out(c.yellow(`\u25B2 output capped at ${maxPerJob} per job (${cappedJobs.join(", ")}) \u2014 high-frequency schedule, not the full window`));
1618
+ }
1619
+ return;
1620
+ }
1621
+ const engine = app.dev({ autoStart: false, installSignalHandlers: false, onEvent: (e) => renderEvent(e, useJson) });
1622
+ engine.start();
1623
+ let dashboardUrl;
1624
+ if (flags["dashboard"]) {
1625
+ const { startDashboard: startDashboard2 } = await Promise.resolve().then(() => (init_dashboard(), dashboard_exports));
1626
+ const port = typeof flags["port"] === "string" ? Number.parseInt(flags["port"], 10) : void 0;
1627
+ const host = typeof flags["host"] === "string" ? flags["host"] : void 0;
1628
+ try {
1629
+ const dash = await startDashboard2(engine, { port, host });
1630
+ dashboardUrl = dash.url;
1631
+ engine.onStop(() => dash.close());
1632
+ } catch (err) {
1633
+ await engine.stop();
1634
+ throw err;
1635
+ }
1636
+ }
1637
+ if (!useJson) {
1638
+ header(`dev \xB7 local engine ${c.gray("\xB7 no account, no cloud")}`);
1639
+ out(renderSchedule(engine.snapshot()));
1640
+ out();
1641
+ if (dashboardUrl) out(`${sym.ok} dashboard \u2192 ${c.cyan(dashboardUrl)}`);
1642
+ out(c.gray(`watching ${engine.jobs().length} job(s) \u2014 press Ctrl-C to stop`));
1643
+ out();
1644
+ }
1645
+ await new Promise((resolve2) => {
1646
+ const onSignal = () => {
1647
+ process.removeListener("SIGINT", onSignal);
1648
+ process.removeListener("SIGTERM", onSignal);
1649
+ void engine.stop().then(resolve2);
1650
+ };
1651
+ process.once("SIGINT", onSignal);
1652
+ process.once("SIGTERM", onSignal);
1653
+ });
1654
+ if (!useJson) {
1655
+ out();
1656
+ const runs = engine.runs();
1657
+ const ok = runs.filter((r) => r.status === "success").length;
1658
+ const bad = runs.filter((r) => r.status === "error" || r.status === "timed_out").length;
1659
+ out(c.gray(`stopped \u2014 ${runs.length} run(s) this session (${ok} ok${bad ? `, ${c.red(`${bad} failed`)}` : ""})`));
1660
+ }
1661
+ }
1662
+ async function cmdTrigger(positionals, useJson) {
709
1663
  const jobKey = positionals[positionals.length - 1];
710
1664
  const configPath = positionals.length >= 2 ? positionals[0] : void 0;
711
- if (!jobKey) throw new CronvelloConfigError("Usage: cronvello dev [configPath] <jobKey>");
1665
+ if (!jobKey) throw new CronvelloConfigError("Usage: cronvello trigger [configPath] <jobKey>");
712
1666
  const app = await loadApp(configPath);
713
1667
  if (!app.keys().includes(jobKey)) {
714
1668
  throw new CronvelloConfigError(`Unknown job "${jobKey}". Known: ${app.keys().join(", ") || "(none)"}`);
@@ -723,6 +1677,123 @@ async function cmdDev(positionals, useJson) {
723
1677
  out(JSON.stringify(result, null, 2).split("\n").map((l) => " " + l).join("\n"));
724
1678
  }
725
1679
  }
1680
+ function cmdPreview(positionals, flags, useJson) {
1681
+ const expr = positionals[0];
1682
+ if (!expr) throw new CronvelloConfigError('Usage: cronvello preview "<cron>" [--tz <IANA>] [-n 5]');
1683
+ const tz = typeof flags["tz"] === "string" ? flags["tz"] : localTimeZone();
1684
+ const count = clampLimit(flags["n"] ?? flags["count"] ?? flags["limit"]);
1685
+ let times;
1686
+ try {
1687
+ times = previewSchedule(expr, { timeZone: tz, count });
1688
+ } catch (err) {
1689
+ throw new CronvelloConfigError(err instanceof Error ? err.message : String(err));
1690
+ }
1691
+ if (useJson) return json({ schedule: expr, timeZone: tz, next: times.map((t) => t.toISOString()) });
1692
+ header(`preview ${c.cyan(expr)} ${c.gray(`\xB7 ${tz}`)}`);
1693
+ if (!times.length) {
1694
+ out(c.gray("no upcoming fire times (does this expression ever match?)"));
1695
+ return;
1696
+ }
1697
+ const now = Date.now();
1698
+ out(
1699
+ table(
1700
+ [{ header: "#", align: "right" }, { header: "LOCAL TIME" }, { header: "WHEN" }, { header: "UTC" }],
1701
+ times.map((t, i) => [
1702
+ c.gray(String(i + 1)),
1703
+ c.bold(formatInZone(t, tz)),
1704
+ relativeTime(t.toISOString(), now),
1705
+ c.gray(t.toISOString())
1706
+ ])
1707
+ )
1708
+ );
1709
+ }
1710
+ function renderSchedule(snapshot) {
1711
+ const now = Date.now();
1712
+ return table(
1713
+ [{ header: "JOB" }, { header: "SCHEDULE" }, { header: "TZ" }, { header: "NEXT RUN" }, { header: "WHEN" }],
1714
+ snapshot.map((s) => [
1715
+ c.bold(s.key),
1716
+ c.cyan(s.schedule),
1717
+ c.gray(s.timeZone),
1718
+ s.nextFire ? formatInZone(s.nextFire, s.timeZone) : c.gray("\u2014"),
1719
+ s.nextFire ? relativeTime(s.nextFire.toISOString(), now) : c.gray("\u2014")
1720
+ ])
1721
+ );
1722
+ }
1723
+ function renderUpcoming(fires) {
1724
+ const now = Date.now();
1725
+ return table(
1726
+ [{ header: "WHEN" }, { header: "LOCAL TIME" }, { header: "JOB" }, { header: "SCHEDULE" }],
1727
+ fires.map((f) => [
1728
+ relativeTime(f.time.toISOString(), now),
1729
+ c.bold(formatInZone(f.time, f.timeZone)),
1730
+ c.cyan(f.key),
1731
+ c.gray(f.schedule)
1732
+ ])
1733
+ );
1734
+ }
1735
+ function renderEvent(event, useJson) {
1736
+ if (useJson) return out(JSON.stringify(event));
1737
+ const ts = c.gray(clockStamp(Date.now()));
1738
+ switch (event.type) {
1739
+ case "fire":
1740
+ out(`${ts} ${sym.arrow} ${c.bold(event.key)} ${c.gray(event.attempt > 1 ? `(attempt ${event.attempt})` : "fired")}`);
1741
+ break;
1742
+ case "success":
1743
+ out(`${ts} ${sym.ok} ${c.bold(event.key)} ${c.gray(`${event.durationMs}ms${event.attempts > 1 ? ` \xB7 ${event.attempts} attempts` : ""}`)}`);
1744
+ break;
1745
+ case "error":
1746
+ out(`${ts} ${sym.fail} ${c.bold(event.key)} ${c.red(event.error)} ${c.gray(event.willRetry ? "\xB7 will retry" : "\xB7 gave up")}`);
1747
+ break;
1748
+ case "timeout":
1749
+ out(`${ts} ${sym.fail} ${c.bold(event.key)} ${c.red("timed out")} ${c.gray(`after ${event.durationMs}ms${event.willRetry ? " \xB7 will retry" : " \xB7 gave up"}`)}`);
1750
+ break;
1751
+ case "retry":
1752
+ out(`${ts} ${sym.warn} ${c.bold(event.key)} ${c.gray(`retrying in ${event.delayMs}ms (attempt ${event.attempt})`)}`);
1753
+ break;
1754
+ case "skipped":
1755
+ out(`${ts} ${sym.warn} ${c.bold(event.key)} ${c.gray("skipped \u2014 previous run still in flight")}`);
1756
+ break;
1757
+ }
1758
+ }
1759
+ function formatInZone(date, timeZone) {
1760
+ try {
1761
+ const parts = new Intl.DateTimeFormat("en-CA", {
1762
+ timeZone,
1763
+ year: "numeric",
1764
+ month: "2-digit",
1765
+ day: "2-digit",
1766
+ hour: "2-digit",
1767
+ minute: "2-digit",
1768
+ second: "2-digit",
1769
+ hour12: false
1770
+ }).formatToParts(date);
1771
+ const m = {};
1772
+ for (const p of parts) if (p.type !== "literal") m[p.type] = p.value;
1773
+ const hour = m["hour"] === "24" ? "00" : m["hour"];
1774
+ return `${m["year"]}-${m["month"]}-${m["day"]} ${hour}:${m["minute"]}:${m["second"]}`;
1775
+ } catch {
1776
+ return date.toISOString();
1777
+ }
1778
+ }
1779
+ function clockStamp(nowMs) {
1780
+ return formatInZone(new Date(nowMs), localTimeZone()).slice(11);
1781
+ }
1782
+ function parseDurationFlag(value, fallback) {
1783
+ if (typeof value !== "string") return fallback;
1784
+ const m = /^(\d+)\s*(s|m|h|d)?$/i.exec(value.trim());
1785
+ if (!m) return fallback;
1786
+ const n = Number(m[1]);
1787
+ const unit = (m[2] ?? "m").toLowerCase();
1788
+ const mult = unit === "s" ? 1e3 : unit === "h" ? 36e5 : unit === "d" ? 864e5 : 6e4;
1789
+ return n * mult;
1790
+ }
1791
+ function formatDuration(ms) {
1792
+ if (ms % 864e5 === 0) return `${ms / 864e5}d`;
1793
+ if (ms % 36e5 === 0) return `${ms / 36e5}h`;
1794
+ if (ms % 6e4 === 0) return `${ms / 6e4}m`;
1795
+ return `${Math.round(ms / 1e3)}s`;
1796
+ }
726
1797
  function cmdSecret(useJson) {
727
1798
  const secret = generateDispatchSecret();
728
1799
  if (useJson) return json({ dispatchSecret: secret });
@@ -784,7 +1855,10 @@ function printHelp() {
784
1855
  ["run <taskId>", "trigger a task now (or: run <jobName> <taskName>)"],
785
1856
  ["status", "health overview + recent failures"],
786
1857
  ["sync [path] [--dry]", "reconcile a code registry (loads your module)"],
787
- ["dev [path] <jobKey>", "run a job handler locally, no deploy"],
1858
+ ["dev [path] [--dry-run]", "start the local engine \u2014 run jobs locally, no account"],
1859
+ ["dev --dashboard [--port n]", "\u2026plus a local web dashboard (127.0.0.1, live runs + run-now)"],
1860
+ ['preview "<cron>" [-n 5]', "print the next N fire times (--tz <IANA>)"],
1861
+ ["trigger [path] <jobKey>", "run a single job handler once, locally"],
788
1862
  ["secret", "generate a strong dispatch secret"]
789
1863
  ];
790
1864
  for (const [name, desc] of cmds) out(` ${c.cyan(name.padEnd(30))} ${c.gray(desc)}`);
@@ -841,7 +1915,13 @@ async function run(argv) {
841
1915
  await cmdSync(args.positionals, args.flags, useJson);
842
1916
  break;
843
1917
  case "dev":
844
- await cmdDev(args.positionals, useJson);
1918
+ await cmdDevEngine(args.positionals, args.flags, useJson);
1919
+ break;
1920
+ case "preview":
1921
+ cmdPreview(args.positionals, args.flags, useJson);
1922
+ break;
1923
+ case "trigger":
1924
+ await cmdTrigger(args.positionals, useJson);
845
1925
  break;
846
1926
  case "secret":
847
1927
  cmdSecret(useJson);