crontick 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/dist/chunk-35FFLWP3.js +79 -0
  4. package/dist/chunk-35FFLWP3.js.map +1 -0
  5. package/dist/chunk-FMGZ3SSS.js +57 -0
  6. package/dist/chunk-FMGZ3SSS.js.map +1 -0
  7. package/dist/chunk-LPAFZNXK.js +214 -0
  8. package/dist/chunk-LPAFZNXK.js.map +1 -0
  9. package/dist/chunk-YMAT5MON.js +22 -0
  10. package/dist/chunk-YMAT5MON.js.map +1 -0
  11. package/dist/cli/index.cjs +879 -0
  12. package/dist/cli/index.cjs.map +1 -0
  13. package/dist/cli/index.d.cts +2 -0
  14. package/dist/cli/index.d.ts +2 -0
  15. package/dist/cli/index.js +554 -0
  16. package/dist/cli/index.js.map +1 -0
  17. package/dist/daemon/index.cjs +1655 -0
  18. package/dist/daemon/index.cjs.map +1 -0
  19. package/dist/daemon/index.d.cts +2 -0
  20. package/dist/daemon/index.d.ts +2 -0
  21. package/dist/daemon/index.js +1306 -0
  22. package/dist/daemon/index.js.map +1 -0
  23. package/dist/dashboard/.gitkeep +0 -0
  24. package/dist/dashboard/dashboard.css +192 -0
  25. package/dist/dashboard/dashboard.js +316 -0
  26. package/dist/dashboard/index.html +108 -0
  27. package/dist/index.cjs +180 -0
  28. package/dist/index.cjs.map +1 -0
  29. package/dist/index.d.cts +121 -0
  30. package/dist/index.d.ts +121 -0
  31. package/dist/index.js +32 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/job-JQGLDEJV.js +26 -0
  34. package/dist/job-JQGLDEJV.js.map +1 -0
  35. package/dist/mcp/index.cjs +1000 -0
  36. package/dist/mcp/index.cjs.map +1 -0
  37. package/dist/mcp/index.d.cts +13 -0
  38. package/dist/mcp/index.d.ts +13 -0
  39. package/dist/mcp/index.js +876 -0
  40. package/dist/mcp/index.js.map +1 -0
  41. package/package.json +81 -0
  42. package/plugin/.gitkeep +0 -0
  43. package/plugin/README.md +50 -0
  44. package/plugin/install.mjs +155 -0
  45. package/plugin/plugin.json +11 -0
  46. package/plugin/uninstall.mjs +68 -0
  47. package/src/skill/SKILL.md +227 -0
@@ -0,0 +1,1655 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __esm = (fn, res) => function __init() {
10
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
11
+ };
12
+ var __export = (target, all) => {
13
+ for (var name in all)
14
+ __defProp(target, name, { get: all[name], enumerable: true });
15
+ };
16
+ var __copyProps = (to, from, except, desc) => {
17
+ if (from && typeof from === "object" || typeof from === "function") {
18
+ for (let key of __getOwnPropNames(from))
19
+ if (!__hasOwnProp.call(to, key) && key !== except)
20
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
21
+ }
22
+ return to;
23
+ };
24
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
25
+ // If the importer is in node compatibility mode or this is not an ESM
26
+ // file that has been converted to a CommonJS file using a Babel-
27
+ // compatible transform (i.e. "__esModule" has not been set), then set
28
+ // "default" to the CommonJS "module.exports" for node compatibility.
29
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
30
+ mod
31
+ ));
32
+
33
+ // src/schemas/job.ts
34
+ var job_exports = {};
35
+ __export(job_exports, {
36
+ ActionSchema: () => ActionSchema,
37
+ BudgetsSchema: () => BudgetsSchema,
38
+ CronScheduleSchema: () => CronScheduleSchema,
39
+ ExecActionSchema: () => ExecActionSchema,
40
+ IntervalScheduleSchema: () => IntervalScheduleSchema,
41
+ JobSchema: () => JobSchema,
42
+ OneShotScheduleSchema: () => OneShotScheduleSchema,
43
+ RetrySchema: () => RetrySchema,
44
+ ScheduleSchema: () => ScheduleSchema,
45
+ ScriptActionSchema: () => ScriptActionSchema
46
+ });
47
+ var import_zod, CronScheduleSchema, IntervalScheduleSchema, OneShotScheduleSchema, ScheduleSchema, ScriptActionSchema, ExecActionSchema, ActionSchema, RetrySchema, BudgetsSchema, kebabCase, JobSchema;
48
+ var init_job = __esm({
49
+ "src/schemas/job.ts"() {
50
+ "use strict";
51
+ import_zod = require("zod");
52
+ CronScheduleSchema = import_zod.z.object({
53
+ kind: import_zod.z.literal("cron"),
54
+ cron: import_zod.z.string().min(1),
55
+ tz: import_zod.z.string().optional()
56
+ });
57
+ IntervalScheduleSchema = import_zod.z.object({
58
+ kind: import_zod.z.literal("interval"),
59
+ everySec: import_zod.z.number().positive(),
60
+ startAt: import_zod.z.string().optional()
61
+ // ISO-8601
62
+ });
63
+ OneShotScheduleSchema = import_zod.z.object({
64
+ kind: import_zod.z.literal("one-shot"),
65
+ runAt: import_zod.z.string().min(1)
66
+ // ISO-8601
67
+ });
68
+ ScheduleSchema = import_zod.z.discriminatedUnion("kind", [
69
+ CronScheduleSchema,
70
+ IntervalScheduleSchema,
71
+ OneShotScheduleSchema
72
+ ]);
73
+ ScriptActionSchema = import_zod.z.object({
74
+ kind: import_zod.z.literal("script"),
75
+ script: import_zod.z.string().min(1),
76
+ shell: import_zod.z.enum(["auto", "bash", "pwsh", "cmd"]).default("auto"),
77
+ cwd: import_zod.z.string().optional(),
78
+ env: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).optional(),
79
+ envFile: import_zod.z.string().optional(),
80
+ timeoutSec: import_zod.z.number().positive().optional()
81
+ });
82
+ ExecActionSchema = import_zod.z.object({
83
+ kind: import_zod.z.literal("exec"),
84
+ command: import_zod.z.string().min(1),
85
+ args: import_zod.z.array(import_zod.z.string()).default([]),
86
+ cwd: import_zod.z.string().optional(),
87
+ env: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).optional(),
88
+ envFile: import_zod.z.string().optional(),
89
+ timeoutSec: import_zod.z.number().positive().optional()
90
+ // shell is intentionally absent: exec always uses shell=false to prevent injection
91
+ });
92
+ ActionSchema = import_zod.z.discriminatedUnion("kind", [ScriptActionSchema, ExecActionSchema]);
93
+ RetrySchema = import_zod.z.object({
94
+ max: import_zod.z.number().int().min(0).default(0),
95
+ backoffSec: import_zod.z.number().positive().default(30)
96
+ });
97
+ BudgetsSchema = import_zod.z.object({
98
+ maxRunsPerDay: import_zod.z.number().int().positive().nullable().default(null),
99
+ maxTokensPerRun: import_zod.z.number().int().positive().nullable().default(null)
100
+ });
101
+ kebabCase = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
102
+ JobSchema = import_zod.z.object({
103
+ id: import_zod.z.string().regex(kebabCase, 'Job ID must be kebab-case (e.g. "my-job")'),
104
+ description: import_zod.z.string().optional(),
105
+ enabled: import_zod.z.boolean().default(true),
106
+ schedule: ScheduleSchema,
107
+ action: ActionSchema,
108
+ catchup: import_zod.z.enum(["run-once", "run-all", "skip"]).default("skip"),
109
+ overlap: import_zod.z.enum(["skip", "queue", "cancel-previous"]).default("skip"),
110
+ retry: RetrySchema.default({ max: 0, backoffSec: 30 }),
111
+ budgets: BudgetsSchema.default({ maxRunsPerDay: null, maxTokensPerRun: null })
112
+ });
113
+ }
114
+ });
115
+
116
+ // src/daemon/index.ts
117
+ var import_node_child_process3 = require("child_process");
118
+ var import_node_fs6 = require("fs");
119
+ var import_node_path6 = require("path");
120
+
121
+ // src/paths.ts
122
+ var import_env_paths = __toESM(require("env-paths"), 1);
123
+ var import_node_fs = require("fs");
124
+ var import_node_path = require("path");
125
+ function root() {
126
+ const override = process.env["CRONTICK_HOME"];
127
+ if (override) return override;
128
+ return (0, import_env_paths.default)("crontick", { suffix: "" }).data;
129
+ }
130
+ function dataDir() {
131
+ return root();
132
+ }
133
+ function jobsDir() {
134
+ return (0, import_node_path.join)(root(), "jobs");
135
+ }
136
+ function runsDbPath() {
137
+ return (0, import_node_path.join)(root(), "runs.db");
138
+ }
139
+ function logsDir() {
140
+ return (0, import_node_path.join)(root(), "logs");
141
+ }
142
+ function pidFilePath() {
143
+ return (0, import_node_path.join)(root(), "daemon.pid");
144
+ }
145
+ function portFilePath() {
146
+ return (0, import_node_path.join)(root(), "daemon.port");
147
+ }
148
+ function autostartDir() {
149
+ return (0, import_node_path.join)(root(), "autostart");
150
+ }
151
+ function ensureDirs() {
152
+ for (const dir of [dataDir(), jobsDir(), logsDir()]) {
153
+ (0, import_node_fs.mkdirSync)(dir, { recursive: true });
154
+ }
155
+ }
156
+
157
+ // src/daemon/store.ts
158
+ var import_node_sqlite = require("node:sqlite");
159
+ var import_node_crypto = require("crypto");
160
+ var import_node_fs2 = require("fs");
161
+ var import_node_path2 = require("path");
162
+ init_job();
163
+
164
+ // src/errors.ts
165
+ var CrontickError = class _CrontickError extends Error {
166
+ code;
167
+ details;
168
+ constructor(code, message, details) {
169
+ super(message);
170
+ this.name = "CrontickError";
171
+ this.code = code;
172
+ this.details = details;
173
+ Object.setPrototypeOf(this, _CrontickError.prototype);
174
+ }
175
+ toJSON() {
176
+ return { code: this.code, message: this.message, details: this.details };
177
+ }
178
+ };
179
+
180
+ // src/daemon/store.ts
181
+ var MIGRATIONS = [
182
+ {
183
+ name: "001_initial",
184
+ sql: `
185
+ CREATE TABLE IF NOT EXISTS jobs (
186
+ id TEXT PRIMARY KEY,
187
+ json TEXT NOT NULL,
188
+ updated_at INTEGER NOT NULL
189
+ );
190
+ CREATE TABLE IF NOT EXISTS runs (
191
+ id TEXT PRIMARY KEY,
192
+ job_id TEXT NOT NULL,
193
+ started_at INTEGER NOT NULL,
194
+ ended_at INTEGER,
195
+ status TEXT NOT NULL,
196
+ exit_code INTEGER,
197
+ error TEXT,
198
+ duration_ms INTEGER
199
+ );
200
+ CREATE TABLE IF NOT EXISTS run_logs (
201
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
202
+ run_id TEXT NOT NULL,
203
+ stream TEXT NOT NULL,
204
+ ts INTEGER NOT NULL,
205
+ chunk BLOB NOT NULL
206
+ );
207
+ CREATE INDEX IF NOT EXISTS idx_runs_job_id ON runs(job_id);
208
+ CREATE INDEX IF NOT EXISTS idx_runs_started_at ON runs(started_at);
209
+ CREATE INDEX IF NOT EXISTS idx_run_logs_run_id ON run_logs(run_id);
210
+ `
211
+ }
212
+ ];
213
+ var Store = class {
214
+ db;
215
+ dbPath;
216
+ jobsPath;
217
+ constructor(dbPath, jobsPath) {
218
+ this.dbPath = dbPath ?? runsDbPath();
219
+ this.jobsPath = jobsPath ?? jobsDir();
220
+ }
221
+ open() {
222
+ this.db = new import_node_sqlite.DatabaseSync(this.dbPath);
223
+ this.db.exec("PRAGMA journal_mode=WAL;");
224
+ this.db.exec("PRAGMA foreign_keys=ON;");
225
+ this.runMigrations();
226
+ }
227
+ close() {
228
+ try {
229
+ this.db.close();
230
+ } catch {
231
+ }
232
+ }
233
+ runMigrations() {
234
+ this.db.exec(`
235
+ CREATE TABLE IF NOT EXISTS migrations (
236
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
237
+ name TEXT NOT NULL UNIQUE,
238
+ applied_at INTEGER NOT NULL
239
+ );
240
+ `);
241
+ const applied = this.db.prepare("SELECT name FROM migrations").all();
242
+ const appliedSet = new Set(applied.map((r) => r.name));
243
+ for (const migration of MIGRATIONS) {
244
+ if (!appliedSet.has(migration.name)) {
245
+ this.db.exec(migration.sql);
246
+ this.db.prepare("INSERT INTO migrations (name, applied_at) VALUES (?, ?)").run(migration.name, Date.now());
247
+ }
248
+ }
249
+ }
250
+ // ── Job CRUD ────────────────────────────────────────────────────────────────
251
+ upsertJob(job) {
252
+ const json = JSON.stringify(job);
253
+ const now = Date.now();
254
+ this.db.prepare(
255
+ "INSERT INTO jobs (id, json, updated_at) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET json=excluded.json, updated_at=excluded.updated_at"
256
+ ).run(job.id, json, now);
257
+ const filePath = (0, import_node_path2.join)(this.jobsPath, `${job.id}.json`);
258
+ (0, import_node_fs2.writeFileSync)(filePath, json, "utf-8");
259
+ }
260
+ getJob(id) {
261
+ const row = this.db.prepare("SELECT json FROM jobs WHERE id = ?").get(id);
262
+ if (!row) return void 0;
263
+ return JSON.parse(row.json);
264
+ }
265
+ listJobs() {
266
+ const rows = this.db.prepare("SELECT json FROM jobs ORDER BY id").all();
267
+ return rows.map((r) => JSON.parse(r.json));
268
+ }
269
+ deleteJob(id) {
270
+ const changes = this.db.prepare("DELETE FROM jobs WHERE id = ?").run(id).changes;
271
+ const filePath = (0, import_node_path2.join)(this.jobsPath, `${id}.json`);
272
+ if ((0, import_node_fs2.existsSync)(filePath)) {
273
+ try {
274
+ (0, import_node_fs2.unlinkSync)(filePath);
275
+ } catch {
276
+ }
277
+ }
278
+ return changes > 0;
279
+ }
280
+ /** Load jobs from the jobs directory (disk is source of truth on daemon start). */
281
+ loadJobsFromDisk() {
282
+ if (!(0, import_node_fs2.existsSync)(this.jobsPath)) return;
283
+ const files = (0, import_node_fs2.readdirSync)(this.jobsPath).filter((f) => f.endsWith(".json"));
284
+ for (const file of files) {
285
+ try {
286
+ const raw = (0, import_node_fs2.readFileSync)((0, import_node_path2.join)(this.jobsPath, file), "utf-8");
287
+ const parsed = JobSchema.safeParse(JSON.parse(raw));
288
+ if (parsed.success) {
289
+ const json = JSON.stringify(parsed.data);
290
+ this.db.prepare(
291
+ "INSERT INTO jobs (id, json, updated_at) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET json=excluded.json, updated_at=excluded.updated_at"
292
+ ).run(parsed.data.id, json, Date.now());
293
+ }
294
+ } catch {
295
+ }
296
+ }
297
+ }
298
+ // ── Run CRUD ────────────────────────────────────────────────────────────────
299
+ insertRun(jobId, startedAt) {
300
+ const id = (0, import_node_crypto.randomUUID)();
301
+ const now = startedAt ?? Date.now();
302
+ this.db.prepare(
303
+ "INSERT INTO runs (id, job_id, started_at, status) VALUES (?, ?, ?, ?)"
304
+ ).run(id, jobId, now, "queued");
305
+ return { id, jobId, startedAt: now, status: "queued" };
306
+ }
307
+ updateRun(id, update) {
308
+ const run = this.getRun(id);
309
+ if (!run) throw new CrontickError("NOT_FOUND", `Run ${id} not found`);
310
+ const fields = [];
311
+ const values = [];
312
+ if (update.status !== void 0) {
313
+ fields.push("status = ?");
314
+ values.push(update.status);
315
+ }
316
+ if (update.exitCode !== void 0) {
317
+ fields.push("exit_code = ?");
318
+ values.push(update.exitCode ?? null);
319
+ }
320
+ if (update.error !== void 0) {
321
+ fields.push("error = ?");
322
+ values.push(update.error ?? null);
323
+ }
324
+ if (update.endedAt !== void 0) {
325
+ fields.push("ended_at = ?");
326
+ values.push(update.endedAt ?? null);
327
+ }
328
+ if (update.durationMs !== void 0) {
329
+ fields.push("duration_ms = ?");
330
+ values.push(update.durationMs ?? null);
331
+ }
332
+ if (fields.length === 0) return;
333
+ values.push(id);
334
+ this.db.prepare(`UPDATE runs SET ${fields.join(", ")} WHERE id = ?`).run(...values);
335
+ }
336
+ getRun(id) {
337
+ const row = this.db.prepare("SELECT * FROM runs WHERE id = ?").get(id);
338
+ return row ? rowToRun(row) : void 0;
339
+ }
340
+ listRuns(opts = {}) {
341
+ const conditions = [];
342
+ const params = [];
343
+ if (opts.jobId) {
344
+ conditions.push("job_id = ?");
345
+ params.push(opts.jobId);
346
+ }
347
+ if (opts.since !== void 0) {
348
+ conditions.push("started_at >= ?");
349
+ params.push(opts.since);
350
+ }
351
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
352
+ const limit = opts.limit !== void 0 ? `LIMIT ${opts.limit}` : "";
353
+ const rows = this.db.prepare(`SELECT * FROM runs ${where} ORDER BY started_at DESC ${limit}`).all(...params);
354
+ return rows.map(rowToRun);
355
+ }
356
+ /** Get the most recent successful run for a job (for catchup calculation). */
357
+ getLastRun(jobId) {
358
+ const row = this.db.prepare(
359
+ "SELECT * FROM runs WHERE job_id = ? AND status IN ('success','failed','timeout') ORDER BY started_at DESC LIMIT 1"
360
+ ).get(jobId);
361
+ return row ? rowToRun(row) : void 0;
362
+ }
363
+ /** Count runs for a job started after a given epoch ms. */
364
+ countRunsSince(jobId, since, excludeId) {
365
+ if (excludeId) {
366
+ const result2 = this.db.prepare("SELECT COUNT(*) as cnt FROM runs WHERE job_id = ? AND started_at >= ? AND id != ?").get(jobId, since, excludeId);
367
+ return result2.cnt;
368
+ }
369
+ const result = this.db.prepare("SELECT COUNT(*) as cnt FROM runs WHERE job_id = ? AND started_at >= ?").get(jobId, since);
370
+ return result.cnt;
371
+ }
372
+ // ── Log CRUD ────────────────────────────────────────────────────────────────
373
+ appendLog(runId, stream, chunk) {
374
+ this.db.prepare("INSERT INTO run_logs (run_id, stream, ts, chunk) VALUES (?, ?, ?, ?)").run(runId, stream, Date.now(), chunk);
375
+ }
376
+ getLogs(runId) {
377
+ const rows = this.db.prepare("SELECT * FROM run_logs WHERE run_id = ? ORDER BY id").all(runId);
378
+ return rows.map(rowToLog);
379
+ }
380
+ tailLogs(runId, sinceTs) {
381
+ const rows = this.db.prepare("SELECT * FROM run_logs WHERE run_id = ? AND ts > ? ORDER BY id").all(runId, sinceTs);
382
+ return rows.map(rowToLog);
383
+ }
384
+ /** On daemon startup, cancel any runs that were left in 'running' or 'queued' state (daemon crashed). */
385
+ reconcileOrphanRuns() {
386
+ const result = this.db.prepare(
387
+ "UPDATE runs SET status = 'canceled', error = 'daemon-restart', ended_at = ? WHERE status IN ('running', 'queued')"
388
+ ).run(Date.now());
389
+ return result.changes;
390
+ }
391
+ };
392
+ function rowToRun(row) {
393
+ const r = {
394
+ id: row.id,
395
+ jobId: row.job_id,
396
+ startedAt: row.started_at,
397
+ status: row.status
398
+ };
399
+ if (row.ended_at !== null) r.endedAt = row.ended_at;
400
+ if (row.exit_code !== null) r.exitCode = row.exit_code;
401
+ if (row.error !== null) r.error = row.error;
402
+ if (row.duration_ms !== null) r.durationMs = row.duration_ms;
403
+ return r;
404
+ }
405
+ function rowToLog(row) {
406
+ return {
407
+ runId: row.run_id,
408
+ stream: row.stream,
409
+ ts: row.ts,
410
+ chunk: Buffer.from(row.chunk)
411
+ };
412
+ }
413
+
414
+ // src/daemon/scheduler.ts
415
+ var import_node_events = require("events");
416
+ var import_croner = require("croner");
417
+ var CRON_ALIASES = {
418
+ "@yearly": "0 0 1 1 *",
419
+ "@annually": "0 0 1 1 *",
420
+ "@monthly": "0 0 1 * *",
421
+ "@weekly": "0 0 * * 0",
422
+ "@daily": "0 0 * * *",
423
+ "@midnight": "0 0 * * *",
424
+ "@noon": "0 12 * * *",
425
+ "@hourly": "0 * * * *",
426
+ "@every_minute": "* * * * *"
427
+ };
428
+ function expandCronAlias(expr) {
429
+ return CRON_ALIASES[expr.toLowerCase()] ?? expr;
430
+ }
431
+ var Scheduler = class extends import_node_events.EventEmitter {
432
+ entries = /* @__PURE__ */ new Map();
433
+ /**
434
+ * Schedule a job, handling catchup based on the last run time from the store.
435
+ * On daemon start pass `store` so catchup can be resolved; at runtime it's optional.
436
+ */
437
+ schedule(job, store) {
438
+ this.unschedule(job.id);
439
+ if (!job.enabled) return;
440
+ const { schedule } = job;
441
+ const lastRun = store?.getLastRun(job.id);
442
+ const lastRunAt = lastRun ? new Date(lastRun.startedAt) : void 0;
443
+ if (schedule.kind === "cron") {
444
+ this.scheduleCron(job, expandCronAlias(schedule.cron), schedule.tz, lastRunAt);
445
+ } else if (schedule.kind === "interval") {
446
+ this.scheduleInterval(job, schedule.everySec, schedule.startAt, lastRunAt);
447
+ } else if (schedule.kind === "one-shot") {
448
+ this.scheduleOneShot(job, schedule.runAt);
449
+ }
450
+ }
451
+ unschedule(jobId) {
452
+ const entry = this.entries.get(jobId);
453
+ if (entry) {
454
+ entry.stop();
455
+ this.entries.delete(jobId);
456
+ }
457
+ }
458
+ unscheduleAll() {
459
+ for (const jobId of [...this.entries.keys()]) {
460
+ this.unschedule(jobId);
461
+ }
462
+ }
463
+ // ── Preview / Validate ─────────────────────────────────────────────────────
464
+ previewNext(schedule, opts = {}) {
465
+ const n = opts.n ?? 5;
466
+ if (schedule.kind === "cron") {
467
+ return cronNextN(expandCronAlias(schedule.cron), opts.tz ?? schedule.tz, n);
468
+ }
469
+ if (schedule.kind === "interval") {
470
+ const now = Date.now();
471
+ const intervalMs = schedule.everySec * 1e3;
472
+ const results = [];
473
+ for (let i = 1; i <= n; i++) {
474
+ results.push(new Date(now + i * intervalMs).toISOString());
475
+ }
476
+ return results;
477
+ }
478
+ if (schedule.kind === "one-shot") {
479
+ const t = new Date(schedule.runAt);
480
+ if (isNaN(t.getTime())) return [];
481
+ return t > /* @__PURE__ */ new Date() ? [t.toISOString()] : [];
482
+ }
483
+ return [];
484
+ }
485
+ validateSchedule(schedule) {
486
+ if (schedule.kind === "cron") {
487
+ try {
488
+ const cron = new import_croner.Cron(expandCronAlias(schedule.cron), { paused: true });
489
+ cron.stop();
490
+ return { ok: true };
491
+ } catch (err) {
492
+ return { ok: false, error: String(err) };
493
+ }
494
+ }
495
+ if (schedule.kind === "interval") {
496
+ if (schedule.everySec <= 0) {
497
+ return { ok: false, error: "everySec must be positive" };
498
+ }
499
+ if (schedule.startAt && isNaN(new Date(schedule.startAt).getTime())) {
500
+ return { ok: false, error: "startAt is not a valid ISO-8601 date" };
501
+ }
502
+ return { ok: true };
503
+ }
504
+ if (schedule.kind === "one-shot") {
505
+ const t = new Date(schedule.runAt);
506
+ if (isNaN(t.getTime())) {
507
+ return { ok: false, error: "runAt is not a valid ISO-8601 date" };
508
+ }
509
+ return { ok: true };
510
+ }
511
+ return { ok: false, error: "Unknown schedule kind" };
512
+ }
513
+ // ── Private helpers ────────────────────────────────────────────────────────
514
+ fireTick(jobId, plannedAt) {
515
+ this.emit("tick", { jobId, plannedAt });
516
+ }
517
+ scheduleCron(job, pattern, tz, lastRunAt) {
518
+ this.handleCronCatchup(job, pattern, tz, lastRunAt);
519
+ const options = {};
520
+ if (tz) options.timezone = tz;
521
+ const cron = new import_croner.Cron(pattern, options, () => {
522
+ this.fireTick(job.id, /* @__PURE__ */ new Date());
523
+ });
524
+ this.entries.set(job.id, { stop: () => cron.stop() });
525
+ }
526
+ handleCronCatchup(job, pattern, tz, lastRunAt) {
527
+ if (job.catchup === "skip" || !lastRunAt) return;
528
+ const now = /* @__PURE__ */ new Date();
529
+ const missed = missedCronFires(pattern, tz, lastRunAt, now);
530
+ if (missed.length === 0) return;
531
+ if (job.catchup === "run-once") {
532
+ setImmediate(() => this.fireTick(job.id, missed[missed.length - 1]));
533
+ } else if (job.catchup === "run-all") {
534
+ for (const t of missed) {
535
+ const capturedT = t;
536
+ setImmediate(() => this.fireTick(job.id, capturedT));
537
+ }
538
+ }
539
+ }
540
+ scheduleInterval(job, everySec, startAt, lastRunAt) {
541
+ const intervalMs = everySec * 1e3;
542
+ if (lastRunAt && job.catchup !== "skip") {
543
+ const elapsed = Date.now() - lastRunAt.getTime();
544
+ const missedCount = Math.floor(elapsed / intervalMs);
545
+ if (missedCount > 0) {
546
+ if (job.catchup === "run-once") {
547
+ setImmediate(() => this.fireTick(job.id, /* @__PURE__ */ new Date()));
548
+ } else if (job.catchup === "run-all") {
549
+ for (let i = 0; i < missedCount; i++) {
550
+ setImmediate(() => this.fireTick(job.id, /* @__PURE__ */ new Date()));
551
+ }
552
+ }
553
+ }
554
+ }
555
+ let delay = intervalMs;
556
+ if (startAt) {
557
+ const startTime = new Date(startAt);
558
+ if (!isNaN(startTime.getTime())) {
559
+ const now = Date.now();
560
+ const startMs = startTime.getTime();
561
+ if (startMs > now) {
562
+ delay = startMs - now;
563
+ } else {
564
+ const elapsed = now - startMs;
565
+ delay = intervalMs - elapsed % intervalMs;
566
+ }
567
+ }
568
+ }
569
+ const timer = safeSetTimeout(() => {
570
+ this.fireTick(job.id, /* @__PURE__ */ new Date());
571
+ const interval = setInterval(() => this.fireTick(job.id, /* @__PURE__ */ new Date()), intervalMs);
572
+ this.entries.set(job.id, { stop: () => clearInterval(interval) });
573
+ }, delay);
574
+ this.entries.set(job.id, {
575
+ stop: () => timer.clear()
576
+ });
577
+ }
578
+ scheduleOneShot(job, runAt) {
579
+ const t = new Date(runAt);
580
+ if (isNaN(t.getTime())) return;
581
+ const delay = t.getTime() - Date.now();
582
+ if (delay <= 0) {
583
+ if (job.catchup !== "skip") {
584
+ setImmediate(() => {
585
+ this.fireTick(job.id, t);
586
+ this.entries.delete(job.id);
587
+ });
588
+ }
589
+ return;
590
+ }
591
+ const timer = safeSetTimeout(() => {
592
+ this.fireTick(job.id, t);
593
+ this.entries.delete(job.id);
594
+ }, delay);
595
+ this.entries.set(job.id, { stop: () => timer.clear() });
596
+ }
597
+ };
598
+ var MAX_SAFE_TIMEOUT_MS = 2e9;
599
+ function safeSetTimeout(cb, ms) {
600
+ if (ms <= MAX_SAFE_TIMEOUT_MS) {
601
+ const t2 = setTimeout(cb, ms);
602
+ return { clear: () => clearTimeout(t2) };
603
+ }
604
+ let inner;
605
+ const t = setTimeout(() => {
606
+ inner = safeSetTimeout(cb, ms - MAX_SAFE_TIMEOUT_MS);
607
+ }, MAX_SAFE_TIMEOUT_MS);
608
+ return {
609
+ clear: () => {
610
+ clearTimeout(t);
611
+ inner?.clear();
612
+ }
613
+ };
614
+ }
615
+ function cronNextN(pattern, tz, n) {
616
+ try {
617
+ const options = { paused: true };
618
+ if (tz) options.timezone = tz;
619
+ const cron = new import_croner.Cron(pattern, options);
620
+ const results = [];
621
+ let ref;
622
+ for (let i = 0; i < n; i++) {
623
+ const next = cron.nextRun(ref);
624
+ if (!next) break;
625
+ results.push(next.toISOString());
626
+ ref = new Date(next.getTime() + 1);
627
+ }
628
+ cron.stop();
629
+ return results;
630
+ } catch {
631
+ return [];
632
+ }
633
+ }
634
+ function missedCronFires(pattern, tz, from, to) {
635
+ try {
636
+ const options = { paused: true };
637
+ if (tz) options.timezone = tz;
638
+ const cron = new import_croner.Cron(pattern, options);
639
+ const missed = [];
640
+ let ref = from;
641
+ for (; ; ) {
642
+ const next = cron.nextRun(ref);
643
+ if (!next || next > to) break;
644
+ missed.push(next);
645
+ ref = new Date(next.getTime() + 1);
646
+ if (missed.length > 1e3) break;
647
+ }
648
+ cron.stop();
649
+ return missed;
650
+ } catch {
651
+ return [];
652
+ }
653
+ }
654
+
655
+ // src/daemon/runner.ts
656
+ var import_node_child_process = require("child_process");
657
+ var import_node_fs3 = require("fs");
658
+ var import_node_os = require("os");
659
+ var import_node_path3 = require("path");
660
+ var import_node_crypto2 = require("crypto");
661
+ var SECRET_PATTERNS = [
662
+ /(?:AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN)=[^\s]*/gi,
663
+ /(?:GITHUB_TOKEN|GH_TOKEN)=[^\s]*/gi,
664
+ /(?:GCLOUD_SERVICE_KEY|GOOGLE_CREDENTIALS)=[^\s]*/gi,
665
+ /Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi,
666
+ /ghp_[A-Za-z0-9]{36}/g,
667
+ /AKIA[0-9A-Z]{16}/g
668
+ ];
669
+ function redact(text) {
670
+ let out = text;
671
+ for (const pat of SECRET_PATTERNS) {
672
+ out = out.replace(pat, "[REDACTED]");
673
+ }
674
+ return out;
675
+ }
676
+ function safeRedact(chunk) {
677
+ if (chunk.includes(0)) return chunk;
678
+ const str = chunk.toString("utf8");
679
+ if (!Buffer.from(str, "utf8").equals(chunk)) return chunk;
680
+ const cleaned = redact(str);
681
+ return Buffer.from(cleaned, "utf8");
682
+ }
683
+ function parseEnvFile(contents) {
684
+ const result = {};
685
+ for (const raw of contents.split(/\r?\n/)) {
686
+ const line = raw.trim();
687
+ if (!line || line.startsWith("#")) continue;
688
+ const eq = line.indexOf("=");
689
+ if (eq === -1) continue;
690
+ const key = line.slice(0, eq).trim();
691
+ let value = line.slice(eq + 1).trim();
692
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
693
+ value = value.slice(1, -1);
694
+ }
695
+ if (key) result[key] = value;
696
+ }
697
+ return result;
698
+ }
699
+ var Runner = class {
700
+ /** Per-job queues for overlap=queue policy */
701
+ queues = /* @__PURE__ */ new Map();
702
+ /** Abort controllers for currently active runs per job */
703
+ activeAborts = /* @__PURE__ */ new Map();
704
+ /** Active run IDs per job */
705
+ activeRunIds = /* @__PURE__ */ new Map();
706
+ /** Whether a job's queue is currently being drained */
707
+ draining = /* @__PURE__ */ new Set();
708
+ /**
709
+ * Execute a job run, honouring overlap + retry + budget policies.
710
+ * The run record must already exist in the store (status=queued).
711
+ */
712
+ async run(job, runId, store) {
713
+ const overlap = job.overlap ?? "skip";
714
+ if (job.budgets?.maxRunsPerDay != null) {
715
+ const todayStart = /* @__PURE__ */ new Date();
716
+ todayStart.setUTCHours(0, 0, 0, 0);
717
+ const count = store.countRunsSince(job.id, todayStart.getTime(), runId);
718
+ if (count >= job.budgets.maxRunsPerDay) {
719
+ await this.finalizeRun(store, runId, {
720
+ status: "canceled",
721
+ error: `budget: maxRunsPerDay (${job.budgets.maxRunsPerDay}) exceeded`
722
+ });
723
+ return;
724
+ }
725
+ }
726
+ const isActive = this.activeRunIds.has(job.id);
727
+ if (overlap === "skip" && isActive) {
728
+ await this.finalizeRun(store, runId, {
729
+ status: "canceled",
730
+ error: "overlap=skip: another run is already active"
731
+ });
732
+ return;
733
+ }
734
+ if (overlap === "cancel-previous" && isActive) {
735
+ const ctrl = this.activeAborts.get(job.id);
736
+ if (ctrl) ctrl.abort();
737
+ }
738
+ if (overlap === "queue") {
739
+ await this.enqueue(job, runId, store);
740
+ } else {
741
+ await this.execute(job, runId, store);
742
+ }
743
+ }
744
+ enqueue(job, runId, store) {
745
+ return new Promise((resolve) => {
746
+ const queue = this.queues.get(job.id) ?? [];
747
+ queue.push(async () => {
748
+ await this.execute(job, runId, store);
749
+ resolve();
750
+ });
751
+ this.queues.set(job.id, queue);
752
+ if (!this.draining.has(job.id)) {
753
+ this.drainQueue(job.id);
754
+ }
755
+ });
756
+ }
757
+ async drainQueue(jobId) {
758
+ this.draining.add(jobId);
759
+ const queue = this.queues.get(jobId);
760
+ if (!queue || queue.length === 0) {
761
+ this.draining.delete(jobId);
762
+ return;
763
+ }
764
+ const next = queue.shift();
765
+ try {
766
+ await next();
767
+ } catch {
768
+ }
769
+ await this.drainQueue(jobId);
770
+ }
771
+ async execute(job, runId, store) {
772
+ const maxRetries = job.retry?.max ?? 0;
773
+ const backoffSec = job.retry?.backoffSec ?? 30;
774
+ let lastResult = { status: "failed", error: "not started" };
775
+ store.updateRun(runId, { status: "running" });
776
+ const ctrl = new AbortController();
777
+ this.activeAborts.set(job.id, ctrl);
778
+ this.activeRunIds.set(job.id, runId);
779
+ try {
780
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
781
+ if (attempt > 0) {
782
+ await sleep(backoffSec * 1e3);
783
+ }
784
+ if (ctrl.signal.aborted) {
785
+ lastResult = { status: "canceled", error: "canceled before retry" };
786
+ break;
787
+ }
788
+ lastResult = await this.spawn(job, runId, store, ctrl.signal);
789
+ if (lastResult.status === "success") break;
790
+ if (lastResult.status === "canceled" || lastResult.status === "timeout") break;
791
+ }
792
+ } finally {
793
+ if (this.activeAborts.get(job.id) === ctrl) this.activeAborts.delete(job.id);
794
+ if (this.activeRunIds.get(job.id) === runId) this.activeRunIds.delete(job.id);
795
+ }
796
+ await this.finalizeRun(store, runId, lastResult);
797
+ }
798
+ async spawn(job, runId, store, signal) {
799
+ const { action } = job;
800
+ let tmpFile;
801
+ try {
802
+ let cmd;
803
+ let args;
804
+ if (action.kind === "script") {
805
+ const ext = resolveShellExt(action.shell ?? "auto");
806
+ const tmpDir = (0, import_node_path3.join)((0, import_node_os.tmpdir)(), "crontick");
807
+ (0, import_node_fs3.mkdirSync)(tmpDir, { recursive: true });
808
+ tmpFile = (0, import_node_path3.join)(tmpDir, `${(0, import_node_crypto2.randomUUID)()}${ext}`);
809
+ (0, import_node_fs3.writeFileSync)(tmpFile, action.script, { encoding: "utf-8", mode: 448 });
810
+ const resolved = resolveShell(action.shell ?? "auto");
811
+ if (resolved === "pwsh") {
812
+ cmd = "pwsh";
813
+ args = ["-NoProfile", "-NonInteractive", "-File", tmpFile];
814
+ } else if (resolved === "cmd") {
815
+ cmd = "cmd";
816
+ args = ["/c", tmpFile];
817
+ } else {
818
+ cmd = "bash";
819
+ args = [tmpFile];
820
+ }
821
+ } else {
822
+ cmd = action.command;
823
+ args = action.args ?? [];
824
+ }
825
+ const spawnOpts = {
826
+ cwd: action.cwd ?? process.cwd(),
827
+ env: { ...process.env, ...action.env ?? {} },
828
+ signal,
829
+ shell: false
830
+ };
831
+ if (action.envFile) {
832
+ const envFilePath = (0, import_node_path3.isAbsolute)(action.envFile) ? action.envFile : (0, import_node_path3.join)(action.cwd ?? process.cwd(), action.envFile);
833
+ try {
834
+ const fileContents = (0, import_node_fs3.readFileSync)(envFilePath, "utf-8");
835
+ const envFileVars = parseEnvFile(fileContents);
836
+ spawnOpts.env = {
837
+ ...process.env,
838
+ ...envFileVars,
839
+ ...action.env ?? {}
840
+ };
841
+ } catch (err) {
842
+ throw new CrontickError("ENV_FILE_ERROR", `Failed to load envFile: ${String(err)}`);
843
+ }
844
+ }
845
+ if (action.timeoutSec) {
846
+ spawnOpts.timeout = action.timeoutSec * 1e3;
847
+ }
848
+ const result = await new Promise((resolve) => {
849
+ const child = (0, import_node_child_process.spawn)(cmd, args, spawnOpts);
850
+ const startedAt = Date.now();
851
+ child.stdout?.on("data", (chunk) => {
852
+ store.appendLog(runId, "stdout", safeRedact(chunk));
853
+ });
854
+ child.stderr?.on("data", (chunk) => {
855
+ store.appendLog(runId, "stderr", safeRedact(chunk));
856
+ });
857
+ child.on("close", (code, sig) => {
858
+ const durationMs = Date.now() - startedAt;
859
+ if (sig === "SIGTERM" || sig === "SIGKILL") {
860
+ resolve({ status: "canceled", error: `killed by signal ${sig}` });
861
+ } else if (code === null) {
862
+ resolve({ status: "failed", error: "process exited without code" });
863
+ } else {
864
+ resolve({
865
+ status: code === 0 ? "success" : "failed",
866
+ exitCode: code
867
+ });
868
+ }
869
+ void durationMs;
870
+ });
871
+ child.on("error", (err) => {
872
+ if (err.code === "ABORT_ERR" || signal.aborted) {
873
+ resolve({ status: "canceled", error: "aborted" });
874
+ } else if (err.code === "ETIMEDOUT") {
875
+ resolve({ status: "timeout", error: "timed out" });
876
+ } else {
877
+ resolve({ status: "failed", error: err.message });
878
+ }
879
+ });
880
+ });
881
+ return result;
882
+ } finally {
883
+ if (tmpFile && (0, import_node_fs3.existsSync)(tmpFile)) {
884
+ try {
885
+ (0, import_node_fs3.unlinkSync)(tmpFile);
886
+ } catch {
887
+ }
888
+ }
889
+ }
890
+ }
891
+ async finalizeRun(store, runId, result) {
892
+ const run = store.getRun(runId);
893
+ const now = Date.now();
894
+ store.updateRun(runId, {
895
+ status: result.status,
896
+ exitCode: result.exitCode,
897
+ error: result.error,
898
+ endedAt: now,
899
+ durationMs: run ? now - run.startedAt : void 0
900
+ });
901
+ }
902
+ /** Cancel any active run for a job. */
903
+ cancelJob(jobId) {
904
+ const ctrl = this.activeAborts.get(jobId);
905
+ if (ctrl) {
906
+ ctrl.abort();
907
+ return true;
908
+ }
909
+ return false;
910
+ }
911
+ /** Cancel an active run by run ID. */
912
+ cancelRun(runId) {
913
+ for (const [jobId, rId] of this.activeRunIds.entries()) {
914
+ if (rId === runId) {
915
+ return this.cancelJob(jobId);
916
+ }
917
+ }
918
+ return false;
919
+ }
920
+ };
921
+ function resolveShell(shell) {
922
+ if (shell === "auto") {
923
+ return (0, import_node_os.platform)() === "win32" ? "pwsh" : "bash";
924
+ }
925
+ if (shell === "pwsh") return "pwsh";
926
+ if (shell === "cmd") return "cmd";
927
+ return "bash";
928
+ }
929
+ function resolveShellExt(shell) {
930
+ const resolved = resolveShell(shell);
931
+ if (resolved === "pwsh") return ".ps1";
932
+ if (resolved === "cmd") return ".bat";
933
+ return ".sh";
934
+ }
935
+ function sleep(ms) {
936
+ return new Promise((resolve) => setTimeout(resolve, ms));
937
+ }
938
+
939
+ // src/daemon/api.ts
940
+ var import_node_http = __toESM(require("http"), 1);
941
+ var import_node_fs5 = require("fs");
942
+ var import_node_path5 = require("path");
943
+ var import_node_url = require("url");
944
+ var import_node_url2 = require("url");
945
+ init_job();
946
+
947
+ // src/version.ts
948
+ var VERSION = "0.1.0";
949
+
950
+ // src/autostart/win32.ts
951
+ var import_node_child_process2 = require("child_process");
952
+ var import_node_fs4 = require("fs");
953
+ var import_node_path4 = require("path");
954
+ var RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
955
+ function getValueName() {
956
+ return process.env["CRONTICK_AUTOSTART_TEST_VALUE"] ?? "crontick-daemon";
957
+ }
958
+ function getVbsPath() {
959
+ return (0, import_node_path4.join)(autostartDir(), "crontick-daemon.vbs");
960
+ }
961
+ function findDaemonBinary() {
962
+ const override = process.env["CRONTICK_DAEMON_BINARY"];
963
+ if (override) return override;
964
+ const whereResult = (0, import_node_child_process2.spawnSync)("where.exe", ["crontick-daemon"], {
965
+ encoding: "utf-8",
966
+ timeout: 5e3,
967
+ windowsHide: true
968
+ });
969
+ if (whereResult.status === 0 && whereResult.stdout.trim()) {
970
+ const first = whereResult.stdout.trim().split(/[\r\n]+/)[0]?.trim();
971
+ if (first) return first;
972
+ }
973
+ const nodeDir = (0, import_node_path4.dirname)(process.execPath);
974
+ for (const candidate of ["crontick-daemon.cmd", "crontick-daemon"]) {
975
+ const p = (0, import_node_path4.join)(nodeDir, candidate);
976
+ if ((0, import_node_fs4.existsSync)(p)) return p;
977
+ }
978
+ return null;
979
+ }
980
+ function buildVbsContent(daemonPath) {
981
+ const safePath = daemonPath.replace(/"/g, "");
982
+ return [
983
+ "' crontick-daemon autostart shim \u2014 generated by crontick autostart install",
984
+ "' Do not edit manually. Run: crontick autostart install to regenerate.",
985
+ 'Set WshShell = CreateObject("WScript.Shell")',
986
+ `WshShell.Run "cmd /c ""${safePath}""", 0, False`,
987
+ ""
988
+ ].join("\r\n");
989
+ }
990
+ function regQuery(valueName) {
991
+ const result = (0, import_node_child_process2.spawnSync)(
992
+ "reg",
993
+ ["query", RUN_KEY, "/v", valueName],
994
+ { encoding: "utf-8", timeout: 5e3, windowsHide: true }
995
+ );
996
+ if (result.status !== 0 || !result.stdout) return null;
997
+ const match = result.stdout.match(/REG_SZ\s+(.+)/i);
998
+ return match?.[1]?.trim() ?? null;
999
+ }
1000
+ function regWrite(valueName, data) {
1001
+ const result = (0, import_node_child_process2.spawnSync)(
1002
+ "reg",
1003
+ ["add", RUN_KEY, "/v", valueName, "/t", "REG_SZ", "/d", data, "/f"],
1004
+ { encoding: "utf-8", timeout: 5e3, windowsHide: true }
1005
+ );
1006
+ if (result.status !== 0) {
1007
+ throw new Error(
1008
+ `reg.exe add failed (exit ${result.status ?? "?"}): ${result.stderr?.trim() ?? "unknown error"}`
1009
+ );
1010
+ }
1011
+ }
1012
+ function regDelete(valueName) {
1013
+ (0, import_node_child_process2.spawnSync)(
1014
+ "reg",
1015
+ ["delete", RUN_KEY, "/v", valueName, "/f"],
1016
+ { encoding: "utf-8", timeout: 5e3, windowsHide: true }
1017
+ );
1018
+ }
1019
+ var Win32Autostart = class {
1020
+ async install() {
1021
+ const daemonPath = findDaemonBinary();
1022
+ if (!daemonPath) {
1023
+ throw new Error(
1024
+ "crontick-daemon binary not found. Ensure crontick is installed globally: npm i -g crontick"
1025
+ );
1026
+ }
1027
+ const vbsPath = getVbsPath();
1028
+ const valueName = getValueName();
1029
+ (0, import_node_fs4.mkdirSync)(autostartDir(), { recursive: true });
1030
+ (0, import_node_fs4.writeFileSync)(vbsPath, buildVbsContent(daemonPath), "utf-8");
1031
+ regDelete(valueName);
1032
+ regWrite(valueName, `wscript.exe "${vbsPath}"`);
1033
+ return { ok: true };
1034
+ }
1035
+ async remove() {
1036
+ const valueName = getValueName();
1037
+ regDelete(valueName);
1038
+ const vbsPath = getVbsPath();
1039
+ if ((0, import_node_fs4.existsSync)(vbsPath)) {
1040
+ try {
1041
+ (0, import_node_fs4.unlinkSync)(vbsPath);
1042
+ } catch {
1043
+ }
1044
+ }
1045
+ return { ok: true };
1046
+ }
1047
+ async status() {
1048
+ const valueName = getValueName();
1049
+ const data = regQuery(valueName);
1050
+ const installed = data !== null;
1051
+ return {
1052
+ installed,
1053
+ backend: "win32",
1054
+ details: installed ? { registryValue: valueName, registryData: data, vbsPath: getVbsPath() } : { registryValue: valueName }
1055
+ };
1056
+ }
1057
+ };
1058
+
1059
+ // src/autostart/manual.ts
1060
+ function instructions() {
1061
+ switch (process.platform) {
1062
+ case "win32":
1063
+ return "Windows: add the following command to your startup folder or HKCU Run key:\n crontick-daemon\nOr run: crontick autostart install (uses HKCU Run + hidden VBS shim)";
1064
+ case "darwin":
1065
+ return "macOS (post-v1): create a launchd plist at\n ~/Library/LaunchAgents/com.crontick.daemon.plist\nwith ProgramArguments pointing to the crontick-daemon binary.\nThen run: launchctl load ~/Library/LaunchAgents/com.crontick.daemon.plist\n\nFor now, add the following to your shell profile (~/.zprofile or ~/.bash_profile):\n crontick-daemon &";
1066
+ case "linux":
1067
+ return "Linux (post-v1): create a systemd user unit at\n ~/.config/systemd/user/crontick.service\nThen run: systemctl --user enable --now crontick.service\n\nFor now, add the following to your shell profile (~/.profile or ~/.bashrc):\n crontick-daemon &\nOr add a @reboot crontab entry: crontab -e\n @reboot crontick-daemon";
1068
+ default:
1069
+ return "Add `crontick-daemon` to your system startup mechanism.\nThe daemon listens on a random localhost port; the port is written to the data directory.";
1070
+ }
1071
+ }
1072
+ var ManualAutostart = class {
1073
+ async install() {
1074
+ return { ok: true };
1075
+ }
1076
+ async remove() {
1077
+ return { ok: true };
1078
+ }
1079
+ async status() {
1080
+ return {
1081
+ installed: false,
1082
+ backend: "manual",
1083
+ details: { instructions: instructions() }
1084
+ };
1085
+ }
1086
+ };
1087
+
1088
+ // src/autostart/darwin.ts
1089
+ var DarwinAutostart = class {
1090
+ async install() {
1091
+ throw new NotImplementedInV1Error(
1092
+ "darwin autostart is planned for post-v1; use manual for now. See https://github.com/crontick/crontick for updates."
1093
+ );
1094
+ }
1095
+ async remove() {
1096
+ throw new NotImplementedInV1Error(
1097
+ "darwin autostart is planned for post-v1; use manual for now."
1098
+ );
1099
+ }
1100
+ async status() {
1101
+ throw new NotImplementedInV1Error(
1102
+ "darwin autostart is planned for post-v1; use manual for now."
1103
+ );
1104
+ }
1105
+ };
1106
+
1107
+ // src/autostart/linux.ts
1108
+ var LinuxAutostart = class {
1109
+ async install() {
1110
+ throw new NotImplementedInV1Error(
1111
+ "linux autostart is planned for post-v1; use manual for now. See https://github.com/crontick/crontick for updates."
1112
+ );
1113
+ }
1114
+ async remove() {
1115
+ throw new NotImplementedInV1Error(
1116
+ "linux autostart is planned for post-v1; use manual for now."
1117
+ );
1118
+ }
1119
+ async status() {
1120
+ throw new NotImplementedInV1Error(
1121
+ "linux autostart is planned for post-v1; use manual for now."
1122
+ );
1123
+ }
1124
+ };
1125
+
1126
+ // src/autostart/index.ts
1127
+ var NotImplementedInV1Error = class _NotImplementedInV1Error extends Error {
1128
+ constructor(message) {
1129
+ super(message);
1130
+ this.name = "NotImplementedInV1Error";
1131
+ Object.setPrototypeOf(this, _NotImplementedInV1Error.prototype);
1132
+ }
1133
+ };
1134
+ function createAutostart(opts) {
1135
+ const backend = opts?.backend ?? (process.platform === "win32" ? "win32" : "manual");
1136
+ switch (backend) {
1137
+ case "win32":
1138
+ return new Win32Autostart();
1139
+ case "darwin":
1140
+ return new DarwinAutostart();
1141
+ case "linux":
1142
+ return new LinuxAutostart();
1143
+ case "manual":
1144
+ default:
1145
+ return new ManualAutostart();
1146
+ }
1147
+ }
1148
+
1149
+ // src/daemon/api.ts
1150
+ var import_meta = {};
1151
+ var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
1152
+ var SSE_POLL_MS = 200;
1153
+ var MIME_TYPES = {
1154
+ ".html": "text/html; charset=utf-8",
1155
+ ".js": "application/javascript; charset=utf-8",
1156
+ ".css": "text/css; charset=utf-8",
1157
+ ".json": "application/json; charset=utf-8",
1158
+ ".png": "image/png",
1159
+ ".svg": "image/svg+xml",
1160
+ ".ico": "image/x-icon"
1161
+ };
1162
+ function createApiServer(ctx) {
1163
+ const server = import_node_http.default.createServer((req, res) => {
1164
+ const remote = req.socket.remoteAddress ?? "";
1165
+ if (!LOOPBACK.has(remote)) {
1166
+ return sendError(res, 403, "FORBIDDEN", "Only localhost connections are allowed");
1167
+ }
1168
+ void handleRequest(req, res, ctx);
1169
+ });
1170
+ return server;
1171
+ }
1172
+ async function handleRequest(req, res, ctx) {
1173
+ const method = req.method ?? "GET";
1174
+ const rawUrl = req.url ?? "/";
1175
+ const baseUrl = `http://127.0.0.1`;
1176
+ const url = new import_node_url.URL(rawUrl, baseUrl);
1177
+ const path = url.pathname;
1178
+ try {
1179
+ if (method === "GET" && path === "/health") {
1180
+ const jobs = ctx.store.listJobs();
1181
+ const since24h = Date.now() - 24 * 60 * 60 * 1e3;
1182
+ const runs24h = ctx.store.listRuns({ since: since24h });
1183
+ return sendJson(res, 200, {
1184
+ ok: true,
1185
+ version: VERSION,
1186
+ uptimeSec: Math.floor((Date.now() - ctx.startedAt.getTime()) / 1e3),
1187
+ pid: process.pid,
1188
+ port: ctx.port,
1189
+ jobs: {
1190
+ total: jobs.length,
1191
+ enabled: jobs.filter((j) => j.enabled).length
1192
+ },
1193
+ runs: {
1194
+ last24h: runs24h.length,
1195
+ failures24h: runs24h.filter((r) => r.status === "failed").length
1196
+ },
1197
+ node: process.versions.node,
1198
+ platform: process.platform
1199
+ });
1200
+ }
1201
+ if (method === "GET" && path === "/api/jobs") {
1202
+ return sendJson(res, 200, ctx.store.listJobs());
1203
+ }
1204
+ if (method === "POST" && path === "/api/jobs") {
1205
+ const body = await readBody(req);
1206
+ const parsed = JobSchema.safeParse(body);
1207
+ if (!parsed.success) {
1208
+ return sendError(res, 400, "VALIDATION_ERROR", "Invalid job", parsed.error.format());
1209
+ }
1210
+ ctx.store.upsertJob(parsed.data);
1211
+ ctx.scheduler.schedule(parsed.data, ctx.store);
1212
+ return sendJson(res, 201, parsed.data);
1213
+ }
1214
+ const jobMatch = path.match(/^\/api\/jobs\/([^/]+)(\/.*)?$/);
1215
+ if (jobMatch) {
1216
+ const id = decodeURIComponent(jobMatch[1]);
1217
+ const sub = jobMatch[2] ?? "";
1218
+ if (method === "GET" && sub === "") {
1219
+ const job = ctx.store.getJob(id);
1220
+ if (!job) return sendError(res, 404, "NOT_FOUND", `Job ${id} not found`);
1221
+ return sendJson(res, 200, job);
1222
+ }
1223
+ if (method === "PUT" && sub === "") {
1224
+ const existing = ctx.store.getJob(id);
1225
+ if (!existing) return sendError(res, 404, "NOT_FOUND", `Job ${id} not found`);
1226
+ const body = await readBody(req);
1227
+ const parsed = JobSchema.safeParse({ ...existing, ...body, id });
1228
+ if (!parsed.success) {
1229
+ return sendError(res, 400, "VALIDATION_ERROR", "Invalid job", parsed.error.format());
1230
+ }
1231
+ ctx.store.upsertJob(parsed.data);
1232
+ ctx.scheduler.schedule(parsed.data, ctx.store);
1233
+ return sendJson(res, 200, parsed.data);
1234
+ }
1235
+ if (method === "DELETE" && sub === "") {
1236
+ const deleted = ctx.store.deleteJob(id);
1237
+ if (!deleted) return sendError(res, 404, "NOT_FOUND", `Job ${id} not found`);
1238
+ ctx.scheduler.unschedule(id);
1239
+ return sendJson(res, 200, { ok: true });
1240
+ }
1241
+ if (method === "POST" && sub === "/enable") {
1242
+ const job = ctx.store.getJob(id);
1243
+ if (!job) return sendError(res, 404, "NOT_FOUND", `Job ${id} not found`);
1244
+ const updated = { ...job, enabled: true };
1245
+ ctx.store.upsertJob(updated);
1246
+ ctx.scheduler.schedule(updated, ctx.store);
1247
+ return sendJson(res, 200, updated);
1248
+ }
1249
+ if (method === "POST" && sub === "/disable") {
1250
+ const job = ctx.store.getJob(id);
1251
+ if (!job) return sendError(res, 404, "NOT_FOUND", `Job ${id} not found`);
1252
+ const updated = { ...job, enabled: false };
1253
+ ctx.store.upsertJob(updated);
1254
+ ctx.scheduler.unschedule(id);
1255
+ return sendJson(res, 200, updated);
1256
+ }
1257
+ if (method === "POST" && sub === "/run") {
1258
+ const job = ctx.store.getJob(id);
1259
+ if (!job) return sendError(res, 404, "NOT_FOUND", `Job ${id} not found`);
1260
+ const run = ctx.store.insertRun(id);
1261
+ ctx.runner.run(job, run.id, ctx.store).catch(() => {
1262
+ });
1263
+ return sendJson(res, 202, { runId: run.id });
1264
+ }
1265
+ }
1266
+ if (method === "GET" && path === "/api/runs") {
1267
+ const jobId = url.searchParams.get("jobId") ?? void 0;
1268
+ const limit = url.searchParams.has("limit") ? parseInt(url.searchParams.get("limit"), 10) : void 0;
1269
+ const since = url.searchParams.has("since") ? parseInt(url.searchParams.get("since"), 10) : void 0;
1270
+ return sendJson(res, 200, ctx.store.listRuns({ jobId, limit, since }));
1271
+ }
1272
+ const runMatch = path.match(/^\/api\/runs\/([^/]+)(\/.*)?$/);
1273
+ if (runMatch) {
1274
+ const id = decodeURIComponent(runMatch[1]);
1275
+ const sub = runMatch[2] ?? "";
1276
+ if (method === "GET" && sub === "") {
1277
+ const run = ctx.store.getRun(id);
1278
+ if (!run) return sendError(res, 404, "NOT_FOUND", `Run ${id} not found`);
1279
+ return sendJson(res, 200, run);
1280
+ }
1281
+ if (method === "POST" && sub === "/cancel") {
1282
+ const run = ctx.store.getRun(id);
1283
+ if (!run) return sendError(res, 404, "NOT_FOUND", `Run ${id} not found`);
1284
+ const canceled = ctx.runner.cancelRun(id);
1285
+ return sendJson(res, 200, { ok: true, canceled });
1286
+ }
1287
+ if (method === "GET" && sub === "/logs") {
1288
+ const run = ctx.store.getRun(id);
1289
+ if (!run) return sendError(res, 404, "NOT_FOUND", `Run ${id} not found`);
1290
+ const logs = ctx.store.getLogs(id);
1291
+ return sendJson(res, 200, logs.map((l) => ({
1292
+ runId: l.runId,
1293
+ stream: l.stream,
1294
+ ts: l.ts,
1295
+ data: l.chunk.toString("utf-8")
1296
+ })));
1297
+ }
1298
+ if (method === "GET" && sub === "/logs/stream") {
1299
+ const run = ctx.store.getRun(id);
1300
+ if (!run) return sendError(res, 404, "NOT_FOUND", `Run ${id} not found`);
1301
+ return streamLogs(req, res, id, ctx);
1302
+ }
1303
+ }
1304
+ if (method === "POST" && path === "/api/schedules/validate") {
1305
+ const body = await readBody(req);
1306
+ const { ScheduleSchema: ScheduleSchema2 } = await Promise.resolve().then(() => (init_job(), job_exports));
1307
+ const parsed = ScheduleSchema2.safeParse(body);
1308
+ if (!parsed.success) {
1309
+ return sendJson(res, 200, { ok: false, error: JSON.stringify(parsed.error.format()) });
1310
+ }
1311
+ const result = ctx.scheduler.validateSchedule(parsed.data);
1312
+ return sendJson(res, 200, result);
1313
+ }
1314
+ if (method === "POST" && path === "/api/schedules/preview") {
1315
+ const body = await readBody(req);
1316
+ const { ScheduleSchema: ScheduleSchema2 } = await Promise.resolve().then(() => (init_job(), job_exports));
1317
+ const scheduleResult = ScheduleSchema2.safeParse(body?.schedule ?? body);
1318
+ if (!scheduleResult.success) {
1319
+ return sendError(res, 400, "VALIDATION_ERROR", "Invalid schedule");
1320
+ }
1321
+ const n = typeof body?.n === "number" ? body.n : 5;
1322
+ const tz = body?.tz;
1323
+ const next = ctx.scheduler.previewNext(scheduleResult.data, { n, tz });
1324
+ return sendJson(res, 200, { next });
1325
+ }
1326
+ if (method === "GET" && path === "/api/stats/summary") {
1327
+ const jobs = ctx.store.listJobs();
1328
+ const runs = ctx.store.listRuns({ limit: 1e3 });
1329
+ const failed = runs.filter((r) => r.status === "failed").length;
1330
+ const succeeded = runs.filter((r) => r.status === "success").length;
1331
+ return sendJson(res, 200, {
1332
+ totalJobs: jobs.length,
1333
+ enabledJobs: jobs.filter((j) => j.enabled).length,
1334
+ totalRuns: runs.length,
1335
+ succeeded,
1336
+ failed,
1337
+ avgDurationMs: runs.length > 0 ? Math.round(runs.reduce((s, r) => s + (r.durationMs ?? 0), 0) / runs.length) : null
1338
+ });
1339
+ }
1340
+ const statsJobMatch = path.match(/^\/api\/stats\/jobs\/([^/]+)$/);
1341
+ if (method === "GET" && statsJobMatch) {
1342
+ const id = decodeURIComponent(statsJobMatch[1]);
1343
+ const job = ctx.store.getJob(id);
1344
+ if (!job) return sendError(res, 404, "NOT_FOUND", `Job ${id} not found`);
1345
+ const runs = ctx.store.listRuns({ jobId: id, limit: 100 });
1346
+ return sendJson(res, 200, {
1347
+ jobId: id,
1348
+ totalRuns: runs.length,
1349
+ succeeded: runs.filter((r) => r.status === "success").length,
1350
+ failed: runs.filter((r) => r.status === "failed").length,
1351
+ lastStatus: runs[0]?.status ?? null,
1352
+ lastRunAt: runs[0]?.startedAt ?? null
1353
+ });
1354
+ }
1355
+ if (method === "GET" && path === "/api/daemon/status") {
1356
+ return sendJson(res, 200, {
1357
+ pid: process.pid,
1358
+ version: VERSION,
1359
+ uptimeSec: Math.floor((Date.now() - ctx.startedAt.getTime()) / 1e3),
1360
+ jobs: ctx.store.listJobs().length
1361
+ });
1362
+ }
1363
+ if (method === "POST" && path === "/api/daemon/reload") {
1364
+ await ctx.reload();
1365
+ return sendJson(res, 200, { ok: true });
1366
+ }
1367
+ if (method === "GET" && path === "/api/export") {
1368
+ return sendJson(res, 200, { jobs: ctx.store.listJobs() });
1369
+ }
1370
+ if (method === "POST" && path === "/api/import") {
1371
+ const body = await readBody(req);
1372
+ const jobs = Array.isArray(body?.jobs) ? body.jobs : [];
1373
+ const results = [];
1374
+ for (const raw of jobs) {
1375
+ const parsed = JobSchema.safeParse(raw);
1376
+ if (parsed.success) {
1377
+ ctx.store.upsertJob(parsed.data);
1378
+ ctx.scheduler.schedule(parsed.data, ctx.store);
1379
+ results.push({ id: parsed.data.id, ok: true });
1380
+ } else {
1381
+ results.push({ id: String(raw?.id ?? "?"), ok: false, error: "validation failed" });
1382
+ }
1383
+ }
1384
+ return sendJson(res, 200, { imported: results.filter((r) => r.ok).length, results });
1385
+ }
1386
+ if (method === "GET" && path === "/api/autostart/status") {
1387
+ const backend = url.searchParams.get("backend") ?? void 0;
1388
+ const autostart = createAutostart({ backend });
1389
+ const result = await autostart.status();
1390
+ return sendJson(res, 200, result);
1391
+ }
1392
+ if (method === "POST" && path === "/api/autostart/install") {
1393
+ const body = await readBody(req);
1394
+ const backend = body?.["backend"];
1395
+ const autostart = createAutostart({ backend });
1396
+ const result = await autostart.install();
1397
+ return sendJson(res, 200, result);
1398
+ }
1399
+ if (method === "POST" && path === "/api/autostart/remove") {
1400
+ const body = await readBody(req);
1401
+ const backend = body?.["backend"];
1402
+ const autostart = createAutostart({ backend });
1403
+ const result = await autostart.remove();
1404
+ return sendJson(res, 200, result);
1405
+ }
1406
+ if (method === "GET" && (path === "/" || path === "/dashboard" || path.startsWith("/dashboard/"))) {
1407
+ return serveDashboard(res, path);
1408
+ }
1409
+ return sendError(res, 404, "NOT_FOUND", `${method} ${path} not found`);
1410
+ } catch (err) {
1411
+ if (err instanceof NotImplementedInV1Error) {
1412
+ return sendError(res, 501, "NOT_IMPLEMENTED_V1", err.message);
1413
+ }
1414
+ if (err instanceof CrontickError) {
1415
+ return sendError(res, 400, err.code, err.message, err.details);
1416
+ }
1417
+ const msg = err instanceof Error ? err.message : String(err);
1418
+ return sendError(res, 500, "INTERNAL_ERROR", msg);
1419
+ }
1420
+ }
1421
+ function streamLogs(req, res, runId, ctx) {
1422
+ res.writeHead(200, {
1423
+ "Content-Type": "text/event-stream",
1424
+ "Cache-Control": "no-cache",
1425
+ Connection: "keep-alive"
1426
+ });
1427
+ let lastTs = 0;
1428
+ const existing = ctx.store.getLogs(runId);
1429
+ for (const log of existing) {
1430
+ sseEvent(res, { stream: log.stream, ts: log.ts, data: log.chunk.toString("utf-8") });
1431
+ if (log.ts > lastTs) lastTs = log.ts;
1432
+ }
1433
+ const poll = setInterval(() => {
1434
+ const run = ctx.store.getRun(runId);
1435
+ const newLogs = ctx.store.tailLogs(runId, lastTs);
1436
+ for (const log of newLogs) {
1437
+ sseEvent(res, { stream: log.stream, ts: log.ts, data: log.chunk.toString("utf-8") });
1438
+ if (log.ts > lastTs) lastTs = log.ts;
1439
+ }
1440
+ const terminal = /* @__PURE__ */ new Set(["success", "failed", "canceled", "timeout"]);
1441
+ if (!run || terminal.has(run.status)) {
1442
+ sseEvent(res, { done: true, status: run?.status });
1443
+ clearInterval(poll);
1444
+ res.end();
1445
+ }
1446
+ }, SSE_POLL_MS);
1447
+ req.on("close", () => {
1448
+ clearInterval(poll);
1449
+ });
1450
+ }
1451
+ function dashboardDir() {
1452
+ const moduleDir = (0, import_node_path5.resolve)((0, import_node_url2.fileURLToPath)(import_meta.url), "..");
1453
+ return (0, import_node_path5.resolve)(moduleDir, "../dashboard");
1454
+ }
1455
+ function serveDashboard(res, reqPath) {
1456
+ const dashDir = dashboardDir();
1457
+ const indexFile = (0, import_node_path5.join)(dashDir, "index.html");
1458
+ let filePath;
1459
+ if (reqPath === "/" || reqPath === "/dashboard" || reqPath === "/dashboard/") {
1460
+ filePath = indexFile;
1461
+ } else {
1462
+ const sub = reqPath.startsWith("/dashboard/") ? reqPath.slice("/dashboard".length) : reqPath;
1463
+ const normalizedSub = (0, import_node_path5.normalize)(sub).replace(/^[/\\]+/, "");
1464
+ filePath = (0, import_node_path5.resolve)(dashDir, normalizedSub);
1465
+ }
1466
+ if (filePath !== indexFile && !filePath.startsWith(`${dashDir}${import_node_path5.sep}`)) {
1467
+ res.writeHead(400, { "Content-Type": "text/plain" });
1468
+ res.end("Bad Request");
1469
+ return;
1470
+ }
1471
+ if (!(0, import_node_fs5.existsSync)(filePath)) {
1472
+ filePath = indexFile;
1473
+ }
1474
+ if (!(0, import_node_fs5.existsSync)(filePath)) {
1475
+ res.writeHead(404, { "Content-Type": "text/plain" });
1476
+ res.end("Not Found");
1477
+ return;
1478
+ }
1479
+ const stat = (0, import_node_fs5.statSync)(filePath);
1480
+ if (!stat.isFile()) {
1481
+ res.writeHead(403, { "Content-Type": "text/plain" });
1482
+ res.end("Forbidden");
1483
+ return;
1484
+ }
1485
+ const ext = (0, import_node_path5.extname)(filePath).toLowerCase();
1486
+ const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
1487
+ res.writeHead(200, {
1488
+ "Content-Type": contentType,
1489
+ "Content-Length": stat.size,
1490
+ "Cache-Control": "no-cache"
1491
+ });
1492
+ (0, import_node_fs5.createReadStream)(filePath).pipe(res);
1493
+ }
1494
+ function sseEvent(res, data) {
1495
+ res.write(`data: ${JSON.stringify(data)}
1496
+
1497
+ `);
1498
+ }
1499
+ async function readBody(req) {
1500
+ return new Promise((resolve, reject) => {
1501
+ const chunks = [];
1502
+ req.on("data", (chunk) => chunks.push(chunk));
1503
+ req.on("end", () => {
1504
+ const raw = Buffer.concat(chunks).toString("utf-8");
1505
+ if (!raw) {
1506
+ resolve({});
1507
+ return;
1508
+ }
1509
+ try {
1510
+ resolve(JSON.parse(raw));
1511
+ } catch {
1512
+ resolve({});
1513
+ }
1514
+ });
1515
+ req.on("error", reject);
1516
+ });
1517
+ }
1518
+ function sendJson(res, status, body) {
1519
+ const json = JSON.stringify(body);
1520
+ res.writeHead(status, { "Content-Type": "application/json" });
1521
+ res.end(json);
1522
+ }
1523
+ function sendError(res, status, code, message, details) {
1524
+ sendJson(res, status, { error: { code, message, details } });
1525
+ }
1526
+
1527
+ // src/daemon/index.ts
1528
+ var nodeMajor = parseInt(process.versions.node.split(".")[0], 10);
1529
+ var needsSqliteShim = nodeMajor < 24 && !process.execArgv.includes("--experimental-sqlite");
1530
+ if (needsSqliteShim) {
1531
+ const child = (0, import_node_child_process3.spawn)(process.execPath, ["--experimental-sqlite", ...process.argv.slice(1)], {
1532
+ stdio: "inherit",
1533
+ env: process.env,
1534
+ detached: false
1535
+ });
1536
+ child.on("exit", (code) => {
1537
+ process.exit(code ?? 0);
1538
+ });
1539
+ } else {
1540
+ let log = function(level, msg, data) {
1541
+ const line = JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), level, msg, ...data ? { data } : {} });
1542
+ process.stderr.write(line + "\n");
1543
+ if (logFile) {
1544
+ try {
1545
+ (0, import_node_fs6.appendFileSync)(logFile, line + "\n");
1546
+ } catch {
1547
+ }
1548
+ }
1549
+ }, checkSingleInstance = function() {
1550
+ const pidPath = pidFilePath();
1551
+ if (!(0, import_node_fs6.existsSync)(pidPath)) return;
1552
+ try {
1553
+ const existingPid = parseInt((0, import_node_fs6.readFileSync)(pidPath, "utf-8").trim(), 10);
1554
+ if (!isNaN(existingPid)) {
1555
+ try {
1556
+ process.kill(existingPid, 0);
1557
+ log("error", "Daemon already running", { pid: existingPid });
1558
+ process.exit(1);
1559
+ } catch {
1560
+ log("warn", "Removing stale PID file", { pid: existingPid });
1561
+ }
1562
+ }
1563
+ } catch {
1564
+ }
1565
+ }, cleanup = function() {
1566
+ for (const p of [pidFilePath(), portFilePath()]) {
1567
+ try {
1568
+ if ((0, import_node_fs6.existsSync)(p)) (0, import_node_fs6.unlinkSync)(p);
1569
+ } catch {
1570
+ }
1571
+ }
1572
+ };
1573
+ log2 = log, checkSingleInstance2 = checkSingleInstance, cleanup2 = cleanup;
1574
+ let logFile = null;
1575
+ async function main() {
1576
+ ensureDirs();
1577
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1578
+ logFile = (0, import_node_path6.join)(logsDir(), `daemon-${today}.log`);
1579
+ log("info", "Starting crontick daemon", { pid: process.pid, node: process.version });
1580
+ checkSingleInstance();
1581
+ (0, import_node_fs6.writeFileSync)(pidFilePath(), String(process.pid), "utf-8");
1582
+ const store = new Store(runsDbPath(), jobsDir());
1583
+ store.open();
1584
+ const reconciled = store.reconcileOrphanRuns();
1585
+ if (reconciled > 0) {
1586
+ log("warn", `Reconciled ${reconciled} orphaned run(s) from previous daemon session`);
1587
+ }
1588
+ store.loadJobsFromDisk();
1589
+ const jobs = store.listJobs();
1590
+ log("info", `Loaded ${jobs.length} job(s) from disk`);
1591
+ const scheduler = new Scheduler();
1592
+ const runner = new Runner();
1593
+ for (const job of jobs) {
1594
+ if (job.enabled) scheduler.schedule(job, store);
1595
+ }
1596
+ scheduler.on("tick", ({ jobId, plannedAt }) => {
1597
+ const job = store.getJob(jobId);
1598
+ if (!job || !job.enabled) return;
1599
+ const run = store.insertRun(jobId, plannedAt.getTime());
1600
+ runner.run(job, run.id, store).catch((err) => {
1601
+ log("error", "Runner error", { jobId, error: String(err) });
1602
+ });
1603
+ });
1604
+ async function reload() {
1605
+ log("info", "Reloading jobs from disk");
1606
+ scheduler.unscheduleAll();
1607
+ store.loadJobsFromDisk();
1608
+ const reloaded = store.listJobs();
1609
+ for (const job of reloaded) {
1610
+ if (job.enabled) scheduler.schedule(job, store);
1611
+ }
1612
+ log("info", `Reloaded ${reloaded.length} job(s)`);
1613
+ }
1614
+ const startedAt = /* @__PURE__ */ new Date();
1615
+ const ctx = { store, scheduler, runner, startedAt, port: 0, reload };
1616
+ const server = createApiServer(ctx);
1617
+ await new Promise((resolve, reject) => {
1618
+ server.listen(0, "127.0.0.1", () => {
1619
+ const addr = server.address();
1620
+ const port = typeof addr === "object" && addr ? addr.port : 0;
1621
+ ctx.port = port;
1622
+ (0, import_node_fs6.writeFileSync)(portFilePath(), String(port), "utf-8");
1623
+ log("info", `API listening on 127.0.0.1:${port}`);
1624
+ resolve();
1625
+ });
1626
+ server.on("error", reject);
1627
+ });
1628
+ let shuttingDown = false;
1629
+ async function shutdown(signal) {
1630
+ if (shuttingDown) return;
1631
+ shuttingDown = true;
1632
+ log("info", `Received ${signal}, shutting down`);
1633
+ server.close();
1634
+ scheduler.unscheduleAll();
1635
+ await new Promise((r) => setTimeout(r, 100));
1636
+ store.close();
1637
+ cleanup();
1638
+ log("info", "Daemon stopped");
1639
+ process.exit(0);
1640
+ }
1641
+ process.on("SIGINT", () => void shutdown("SIGINT"));
1642
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
1643
+ log("info", "Daemon ready");
1644
+ }
1645
+ main().catch((err) => {
1646
+ process.stderr.write(`Fatal daemon error: ${String(err)}
1647
+ `);
1648
+ cleanup();
1649
+ process.exit(1);
1650
+ });
1651
+ }
1652
+ var log2;
1653
+ var checkSingleInstance2;
1654
+ var cleanup2;
1655
+ //# sourceMappingURL=index.cjs.map