crond-js 1.0.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/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # crond-js
2
+
3
+ Project-local cron daemon that reads a standard crontab file. Works on macOS and Linux, ships as an npm package with optional MCP integration for AI agent lifecycle management.
4
+
5
+ ## Why
6
+
7
+ - **System cron** is global — one daemon per machine, can't scope to a project
8
+ - **npm cron packages** (`cron`, `node-cron`, `croner`) are libraries, not daemons
9
+ - **supercronic** does exactly the right thing but only ships Linux binaries
10
+
11
+ crond-js fills the gap: point it at a crontab file in your project directory and it runs.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ npm install crond-js
17
+ ```
18
+
19
+ Or run directly:
20
+
21
+ ```sh
22
+ npx crond-js .cron/crontab
23
+ ```
24
+
25
+ ## Crontab format
26
+
27
+ Standard 5-field cron syntax. Put it in `.cron/crontab` (or wherever you like):
28
+
29
+ ```crontab
30
+ # Check API health every 5 minutes
31
+ */5 * * * * ./scripts/check-health.sh
32
+
33
+ # Daily report at 9am weekdays
34
+ 0 9 * * 1-5 ./scripts/daily-report.sh
35
+
36
+ # Clean tmp directory every hour
37
+ 0 * * * * rm -rf tmp/*
38
+ ```
39
+
40
+ ## CLI usage
41
+
42
+ ```sh
43
+ # Foreground (logs to stdout + file)
44
+ crond-js .cron/crontab
45
+
46
+ # Background daemon
47
+ crond-js .cron/crontab -d
48
+ crond-js .cron/crontab --daemon
49
+
50
+ # Check status
51
+ crond-js .cron/crontab -s
52
+ crond-js .cron/crontab --status
53
+
54
+ # Stop daemon
55
+ crond-js .cron/crontab -k
56
+ crond-js .cron/crontab --stop
57
+
58
+ # Custom PID file location
59
+ crond-js .cron/crontab -d -p /tmp/my.pid
60
+ ```
61
+
62
+ | Flag | Description |
63
+ |------|-------------|
64
+ | `-d`, `--daemon` | Run as background daemon |
65
+ | `-s`, `--status` | Check if daemon is running |
66
+ | `-k`, `--stop` | Stop the daemon |
67
+ | `-p`, `--pidfile` | Override PID file path (default: same dir as crontab) |
68
+
69
+ ## MCP integration
70
+
71
+ Add to your `.mcp.json`:
72
+
73
+ ```json
74
+ {
75
+ "mcpServers": {
76
+ "cron": {
77
+ "command": "npx",
78
+ "args": ["-y", "crond-mcp", ".cron/crontab"]
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ The MCP server auto-starts the daemon if it's not running, and exposes a single `cron_status` tool:
85
+
86
+ ```json
87
+ {
88
+ "daemon_pid": 12345,
89
+ "daemon_running": true,
90
+ "jobs": [
91
+ {
92
+ "schedule": "*/5 * * * *",
93
+ "command": "./scripts/check-health.sh",
94
+ "last_run": "2026-03-15 14:05:01",
95
+ "last_exit_code": "0",
96
+ "next_run": "2026-03-15T18:10:00.000Z"
97
+ }
98
+ ]
99
+ }
100
+ ```
101
+
102
+ Agents manage jobs by editing the crontab file directly — no custom MCP tools needed.
103
+
104
+ ## How it works
105
+
106
+ - Re-reads the crontab file every 60 seconds (same as traditional crond)
107
+ - Commands run with `sh -c` in the crontab's directory
108
+ - Jobs still running when the next tick fires are skipped (no overlapping)
109
+ - Logs written to `.cron/log/YYYY-MM-DD.log` in crond format
110
+ - PID stored in `.cron/cron.pid`
111
+ - The daemon survives MCP session restarts — second sessions detect the existing daemon
112
+
113
+ ### Log format
114
+
115
+ ```
116
+ 2026-03-15 01:05:01 crond-js[12345]: STARTUP (crond-js 1.0.0)
117
+ 2026-03-15 01:05:01 crond-js[12345]: CMD (./scripts/check-health.sh)
118
+ 2026-03-15 01:05:03 crond-js[12345]: CMDOUT (OK: 200)
119
+ 2026-03-15 01:05:03 crond-js[12345]: CMDEND (./scripts/check-health.sh exit=0)
120
+ 2026-03-15 01:10:01 crond-js[12345]: RELOAD (.cron/crontab)
121
+ ```
122
+
123
+ ### Project file layout
124
+
125
+ ```
126
+ .cron/
127
+ crontab # the schedule (check into git)
128
+ cron.pid # daemon PID (gitignore)
129
+ log/ # execution logs (gitignore)
130
+ ```
131
+
132
+ ## License
133
+
134
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { checkDaemon, readPid, isProcessAlive, stopDaemon } from './pid.js';
6
+ import { startDaemon } from './daemon.js';
7
+ import { dirname, join } from 'node:path';
8
+ const args = process.argv.slice(2);
9
+ const flags = new Set();
10
+ let crontabPath = null;
11
+ let pidFile = null;
12
+ for (let i = 0; i < args.length; i++) {
13
+ const arg = args[i];
14
+ if (arg === '-p' || arg === '--pidfile') {
15
+ pidFile = args[++i];
16
+ }
17
+ else if (arg.startsWith('-')) {
18
+ flags.add(arg);
19
+ }
20
+ else {
21
+ crontabPath = arg;
22
+ }
23
+ }
24
+ if (!crontabPath) {
25
+ console.error('Usage: crond-js <crontab-path> [-d|--daemon] [-s|--status] [-k|--stop] [-p|--pidfile <path>]');
26
+ process.exit(1);
27
+ }
28
+ const resolvedCrontab = resolve(crontabPath);
29
+ const cronDir = dirname(resolvedCrontab);
30
+ const resolvedPidFile = pidFile ? resolve(pidFile) : join(cronDir, 'cron.pid');
31
+ // --status
32
+ if (flags.has('-s') || flags.has('--status')) {
33
+ const { running, pid } = checkDaemon(resolvedPidFile);
34
+ if (running) {
35
+ console.log(`crond-js is running (PID ${pid})`);
36
+ }
37
+ else {
38
+ console.log('crond-js is not running');
39
+ }
40
+ process.exit(0);
41
+ }
42
+ // --stop
43
+ if (flags.has('-k') || flags.has('--stop')) {
44
+ const pid = readPid(resolvedPidFile);
45
+ if (pid === null || !isProcessAlive(pid)) {
46
+ console.log('crond-js is not running');
47
+ process.exit(0);
48
+ }
49
+ try {
50
+ await stopDaemon(pid, resolvedPidFile);
51
+ }
52
+ catch (err) {
53
+ console.error(`Failed to stop crond-js: ${err.message}`);
54
+ process.exit(1);
55
+ }
56
+ console.log(`crond-js stopped (PID ${pid})`);
57
+ process.exit(0);
58
+ }
59
+ // --daemon (fork and exit)
60
+ if (flags.has('-d') || flags.has('--daemon')) {
61
+ // Re-invoke with the same argv[0..1] so tsx/node/bun all work
62
+ const execArgs = [...process.execArgv, fileURLToPath(import.meta.url), resolvedCrontab, '-p', resolvedPidFile];
63
+ const child = spawn(process.execPath, execArgs, {
64
+ detached: true,
65
+ stdio: 'ignore',
66
+ });
67
+ child.unref();
68
+ console.log(`crond-js started in background (PID ${child.pid})`);
69
+ process.exit(0);
70
+ }
71
+ // Foreground mode (default)
72
+ startDaemon(resolvedCrontab, { foreground: true, pidFile: resolvedPidFile });
@@ -0,0 +1,11 @@
1
+ import { Cron } from 'croner';
2
+ export interface CronJob {
3
+ id: number;
4
+ schedule: string;
5
+ command: string;
6
+ cron: Cron;
7
+ }
8
+ /** Check if a date (floored to the minute) matches a cron schedule. */
9
+ export declare function cronMatchDate(cron: Cron, date: Date): boolean;
10
+ export declare function parseCrontab(content: string): CronJob[];
11
+ export declare function readCrontab(path: string): Promise<CronJob[]>;
@@ -0,0 +1,38 @@
1
+ import { Cron } from 'croner';
2
+ import { readFile } from 'node:fs/promises';
3
+ /** Check if a date (floored to the minute) matches a cron schedule. */
4
+ export function cronMatchDate(cron, date) {
5
+ const prev = new Date(date.getTime() - 60_000);
6
+ const next = cron.nextRun(prev);
7
+ if (!next)
8
+ return false;
9
+ return Math.floor(next.getTime() / 60_000) === Math.floor(date.getTime() / 60_000);
10
+ }
11
+ export function parseCrontab(content) {
12
+ const jobs = [];
13
+ for (const raw of content.split('\n')) {
14
+ const line = raw.trim();
15
+ if (!line || line.startsWith('#'))
16
+ continue;
17
+ // Split into 5 cron fields + command (everything after field 5)
18
+ const parts = line.split(/\s+/);
19
+ if (parts.length < 6) {
20
+ console.warn(`crond-js: skipping malformed line: ${line}`);
21
+ continue;
22
+ }
23
+ const schedule = parts.slice(0, 5).join(' ');
24
+ const command = parts.slice(5).join(' ');
25
+ try {
26
+ const cron = new Cron(schedule, { paused: true });
27
+ jobs.push({ id: jobs.length, schedule, command, cron });
28
+ }
29
+ catch {
30
+ console.warn(`crond-js: skipping invalid schedule "${schedule}": ${line}`);
31
+ }
32
+ }
33
+ return jobs;
34
+ }
35
+ export async function readCrontab(path) {
36
+ const content = await readFile(path, 'utf-8');
37
+ return parseCrontab(content);
38
+ }
@@ -0,0 +1,15 @@
1
+ import { type Logger } from './logger.js';
2
+ import { type CronJob } from './crontab.js';
3
+ export interface DaemonOptions {
4
+ foreground?: boolean;
5
+ pidFile?: string;
6
+ }
7
+ export declare function msToNextMinute(now?: Date): number;
8
+ export declare function matchJobs(jobs: CronJob[], now: Date): CronJob[];
9
+ export declare function executeJob(job: CronJob): void;
10
+ export declare function getRunningJobCount(): number;
11
+ /** @internal — test-only: inject a logger without full startDaemon side-effects */
12
+ export declare function _setLogger(l: Logger): void;
13
+ /** Stop the daemon and reset module state. Useful for tests. */
14
+ export declare function stopDaemonProcess(): void;
15
+ export declare function startDaemon(crontabFile: string, options?: DaemonOptions): void;
package/dist/daemon.js ADDED
@@ -0,0 +1,163 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { createLogger } from './logger.js';
5
+ import { writePid, checkDaemon, removePid } from './pid.js';
6
+ import { parseCrontab, cronMatchDate } from './crontab.js';
7
+ const runningJobs = new Map();
8
+ let lastCrontabContent = '';
9
+ let currentJobs = [];
10
+ let logger;
11
+ let pidPath;
12
+ let crontabPath;
13
+ let cwd;
14
+ let timer = null;
15
+ let isRunning = false;
16
+ function floorToMinute(date) {
17
+ const d = new Date(date);
18
+ d.setSeconds(0, 0);
19
+ return d;
20
+ }
21
+ export function msToNextMinute(now) {
22
+ const date = now ?? new Date();
23
+ return 60_000 - (date.getTime() % 60_000);
24
+ }
25
+ export function matchJobs(jobs, now) {
26
+ const floored = floorToMinute(now);
27
+ return jobs.filter(job => cronMatchDate(job.cron, floored));
28
+ }
29
+ function loadCrontab() {
30
+ let content;
31
+ try {
32
+ content = readFileSync(crontabPath, 'utf-8');
33
+ }
34
+ catch {
35
+ return; // File missing or unreadable — keep current jobs
36
+ }
37
+ if (content === lastCrontabContent)
38
+ return;
39
+ if (lastCrontabContent !== '') {
40
+ logger.log('RELOAD', crontabPath);
41
+ }
42
+ lastCrontabContent = content;
43
+ currentJobs = parseCrontab(content);
44
+ }
45
+ export function executeJob(job) {
46
+ if (runningJobs.has(job.id))
47
+ return; // Skip overlapping
48
+ logger.log('CMD', job.command);
49
+ const child = spawn('sh', ['-c', job.command], {
50
+ cwd,
51
+ stdio: ['ignore', 'pipe', 'pipe'],
52
+ });
53
+ runningJobs.set(job.id, child);
54
+ let stdoutBuf = '';
55
+ child.stdout?.on('data', (data) => {
56
+ stdoutBuf += data.toString();
57
+ const lines = stdoutBuf.split('\n');
58
+ stdoutBuf = lines.pop(); // keep incomplete fragment
59
+ for (const line of lines) {
60
+ if (line)
61
+ logger.log('CMDOUT', line);
62
+ }
63
+ });
64
+ let stderrBuf = '';
65
+ child.stderr?.on('data', (data) => {
66
+ stderrBuf += data.toString();
67
+ const lines = stderrBuf.split('\n');
68
+ stderrBuf = lines.pop(); // keep incomplete fragment
69
+ for (const line of lines) {
70
+ if (line)
71
+ logger.log('CMDOUT', line);
72
+ }
73
+ });
74
+ child.on('error', (err) => {
75
+ logger.log('CMDERR', `${job.command} error=${err.message}`);
76
+ runningJobs.delete(job.id);
77
+ });
78
+ child.on('close', (code) => {
79
+ if (stdoutBuf)
80
+ logger.log('CMDOUT', stdoutBuf);
81
+ if (stderrBuf)
82
+ logger.log('CMDOUT', stderrBuf);
83
+ logger.log('CMDEND', `${job.command} exit=${code ?? 'unknown'}`);
84
+ runningJobs.delete(job.id);
85
+ });
86
+ }
87
+ export function getRunningJobCount() {
88
+ return runningJobs.size;
89
+ }
90
+ /** @internal — test-only: inject a logger without full startDaemon side-effects */
91
+ export function _setLogger(l) {
92
+ logger = l;
93
+ }
94
+ function tick() {
95
+ loadCrontab();
96
+ const now = new Date();
97
+ const matched = matchJobs(currentJobs, now);
98
+ for (const job of matched) {
99
+ executeJob(job);
100
+ }
101
+ }
102
+ function scheduleTick() {
103
+ timer = setTimeout(() => { tick(); scheduleTick(); }, msToNextMinute());
104
+ }
105
+ function cleanup() {
106
+ if (timer) {
107
+ clearTimeout(timer);
108
+ timer = null;
109
+ }
110
+ // Kill all running child processes before removing PID file
111
+ for (const [id, child] of runningJobs) {
112
+ try {
113
+ child.kill('SIGTERM');
114
+ }
115
+ catch {
116
+ // Process may have already exited
117
+ }
118
+ }
119
+ runningJobs.clear();
120
+ isRunning = false;
121
+ removePid(pidPath);
122
+ logger.log('SHUTDOWN', 'crond-js');
123
+ }
124
+ /** Stop the daemon and reset module state. Useful for tests. */
125
+ export function stopDaemonProcess() {
126
+ cleanup();
127
+ }
128
+ export function startDaemon(crontabFile, options = {}) {
129
+ if (isRunning) {
130
+ throw new Error('crond-js: daemon already running in this process');
131
+ }
132
+ crontabPath = resolve(crontabFile);
133
+ cwd = process.cwd();
134
+ const cronDir = dirname(crontabPath);
135
+ const logDir = join(cronDir, 'log');
136
+ pidPath = options.pidFile ?? join(cronDir, 'cron.pid');
137
+ const foreground = options.foreground ?? true;
138
+ // Check for existing daemon
139
+ const { running, pid } = checkDaemon(pidPath);
140
+ if (running) {
141
+ console.error(`crond-js: daemon already running (PID ${pid})`);
142
+ process.exit(1);
143
+ }
144
+ logger = createLogger(logDir, foreground);
145
+ writePid(pidPath);
146
+ isRunning = true;
147
+ logger.log('STARTUP', 'crond-js 1.0.0');
148
+ // Clean shutdown on signals — re-entrancy guard prevents double cleanup
149
+ let cleaningUp = false;
150
+ const onSignal = () => {
151
+ if (cleaningUp)
152
+ return;
153
+ cleaningUp = true;
154
+ cleanup();
155
+ setTimeout(() => process.exit(0), 3000);
156
+ };
157
+ process.on('SIGINT', onSignal);
158
+ process.on('SIGTERM', onSignal);
159
+ // First tick immediately
160
+ tick();
161
+ // Schedule next tick aligned to wall clock
162
+ scheduleTick();
163
+ }
@@ -0,0 +1,4 @@
1
+ export interface Logger {
2
+ log(tag: string, detail: string): void;
3
+ }
4
+ export declare function createLogger(logDir: string, foreground: boolean): Logger;
package/dist/logger.js ADDED
@@ -0,0 +1,27 @@
1
+ import { appendFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ function pad(n) {
4
+ return String(n).padStart(2, '0');
5
+ }
6
+ function timestamp() {
7
+ const d = new Date();
8
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
9
+ }
10
+ function dateSlug() {
11
+ const d = new Date();
12
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
13
+ }
14
+ export function createLogger(logDir, foreground) {
15
+ const pid = process.pid;
16
+ return {
17
+ log(tag, detail) {
18
+ const line = `${timestamp()} crond-js[${pid}]: ${tag} (${detail})\n`;
19
+ if (foreground) {
20
+ process.stdout.write(line);
21
+ }
22
+ mkdirSync(logDir, { recursive: true });
23
+ const logFile = join(logDir, `${dateSlug()}.log`);
24
+ appendFileSync(logFile, line);
25
+ },
26
+ };
27
+ }
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/mcp.js ADDED
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { readFileSync } from 'node:fs';
4
+ import { resolve, dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
+ import { checkDaemon, readPid, isProcessAlive } from './pid.js';
9
+ import { parseCrontab } from './crontab.js';
10
+ // Parse args
11
+ const args = process.argv.slice(2);
12
+ let crontabPath = null;
13
+ let pidFile = null;
14
+ for (let i = 0; i < args.length; i++) {
15
+ const arg = args[i];
16
+ if (arg === '-p' || arg === '--pidfile') {
17
+ pidFile = args[++i];
18
+ }
19
+ else if (!arg.startsWith('-')) {
20
+ crontabPath = arg;
21
+ }
22
+ }
23
+ if (!crontabPath) {
24
+ console.error('Usage: crond-mcp <crontab-path> [-p|--pidfile <path>]');
25
+ process.exit(1);
26
+ }
27
+ const resolvedCrontab = resolve(crontabPath);
28
+ const cronDir = dirname(resolvedCrontab);
29
+ const resolvedPidFile = pidFile ? resolve(pidFile) : join(cronDir, 'cron.pid');
30
+ const logDir = join(cronDir, 'log');
31
+ // Ensure daemon is running
32
+ const { running } = checkDaemon(resolvedPidFile);
33
+ if (!running) {
34
+ // Fork daemon using CLI entry point — preserve tsx/loader args
35
+ // Use the same extension as the current file (.ts in dev, .js when compiled)
36
+ const thisFile = fileURLToPath(import.meta.url);
37
+ const ext = thisFile.endsWith('.ts') ? '.ts' : '.js';
38
+ const cliPath = fileURLToPath(new URL(`./cli${ext}`, import.meta.url));
39
+ const child = spawn(process.execPath, [...process.execArgv, cliPath, resolvedCrontab, '-p', resolvedPidFile], {
40
+ detached: true,
41
+ stdio: 'ignore',
42
+ });
43
+ child.on('error', (err) => console.error('crond-js: failed to start daemon:', err.message));
44
+ child.unref();
45
+ // Poll for daemon to be ready (PID file written + process alive), up to 5s
46
+ const POLL_INTERVAL = 200;
47
+ const POLL_TIMEOUT = 5000;
48
+ let waited = 0;
49
+ while (waited < POLL_TIMEOUT) {
50
+ await new Promise(r => setTimeout(r, POLL_INTERVAL));
51
+ waited += POLL_INTERVAL;
52
+ const status = checkDaemon(resolvedPidFile);
53
+ if (status.running)
54
+ break;
55
+ }
56
+ if (waited >= POLL_TIMEOUT) {
57
+ console.warn('crond-js: daemon did not start within 5s — continuing anyway');
58
+ }
59
+ }
60
+ // Parse today's log for last run info
61
+ function parseLogForJobs() {
62
+ const results = new Map();
63
+ const now = new Date();
64
+ const dateSlug = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
65
+ const logFile = join(logDir, `${dateSlug}.log`);
66
+ let content;
67
+ try {
68
+ content = readFileSync(logFile, 'utf-8');
69
+ }
70
+ catch {
71
+ return results;
72
+ }
73
+ for (const line of content.split('\n')) {
74
+ const cmdMatch = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) crond-js\[\d+\]: CMD \((.+)\)$/);
75
+ if (cmdMatch) {
76
+ results.set(cmdMatch[2], { lastRun: cmdMatch[1], lastExitCode: 'running' });
77
+ continue;
78
+ }
79
+ const endMatch = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) crond-js\[\d+\]: CMDEND \((.+) exit=(.+)\)$/);
80
+ if (endMatch) {
81
+ const cmd = endMatch[2];
82
+ const existing = results.get(cmd);
83
+ if (existing) {
84
+ existing.lastExitCode = endMatch[3];
85
+ }
86
+ }
87
+ }
88
+ return results;
89
+ }
90
+ // MCP Server
91
+ const server = new McpServer({
92
+ name: 'crond-mcp',
93
+ version: '1.0.0',
94
+ });
95
+ server.tool('cron_status', 'Check daemon status, scheduled jobs, last/next run times', {}, async () => {
96
+ const pid = readPid(resolvedPidFile);
97
+ const alive = pid !== null && isProcessAlive(pid);
98
+ const logInfo = parseLogForJobs();
99
+ let jobs = [];
100
+ try {
101
+ const parsed = parseCrontab(readFileSync(resolvedCrontab, 'utf-8'));
102
+ jobs = parsed.map(job => {
103
+ const info = logInfo.get(job.command);
104
+ const nextRun = job.cron.nextRun();
105
+ return {
106
+ schedule: job.schedule,
107
+ command: job.command,
108
+ last_run: info?.lastRun ?? null,
109
+ last_exit_code: info?.lastExitCode ?? null,
110
+ next_run: nextRun ? nextRun.toISOString() : null,
111
+ };
112
+ });
113
+ }
114
+ catch {
115
+ // Crontab unreadable
116
+ }
117
+ return {
118
+ content: [{
119
+ type: 'text',
120
+ text: JSON.stringify({
121
+ daemon_pid: alive ? pid : null,
122
+ daemon_running: alive,
123
+ jobs,
124
+ }, null, 2),
125
+ }],
126
+ };
127
+ });
128
+ const transport = new StdioServerTransport();
129
+ await server.connect(transport);
package/dist/pid.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ export declare function writePid(pidPath: string): void;
2
+ export declare function readPid(pidPath: string): number | null;
3
+ export declare function isProcessAlive(pid: number): boolean;
4
+ export declare function checkDaemon(pidPath: string): {
5
+ running: boolean;
6
+ pid: number | null;
7
+ };
8
+ export declare function removePid(pidPath: string): void;
9
+ /**
10
+ * Send SIGTERM to a daemon and wait for it to exit.
11
+ * Polls isProcessAlive every `intervalMs` up to `timeoutMs`.
12
+ * Only removes the PID file if the daemon's own cleanup didn't.
13
+ * Throws if the process doesn't exit within the timeout.
14
+ */
15
+ export declare function stopDaemon(pid: number, pidPath: string, { timeoutMs, intervalMs }?: {
16
+ timeoutMs?: number | undefined;
17
+ intervalMs?: number | undefined;
18
+ }): Promise<void>;
package/dist/pid.js ADDED
@@ -0,0 +1,61 @@
1
+ import { readFileSync, writeFileSync, unlinkSync } from 'node:fs';
2
+ export function writePid(pidPath) {
3
+ writeFileSync(pidPath, String(process.pid), 'utf-8');
4
+ }
5
+ export function readPid(pidPath) {
6
+ try {
7
+ const content = readFileSync(pidPath, 'utf-8').trim();
8
+ const pid = parseInt(content, 10);
9
+ return isNaN(pid) ? null : pid;
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ export function isProcessAlive(pid) {
16
+ try {
17
+ process.kill(pid, 0);
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ export function checkDaemon(pidPath) {
25
+ const pid = readPid(pidPath);
26
+ if (pid === null)
27
+ return { running: false, pid: null };
28
+ if (isProcessAlive(pid))
29
+ return { running: true, pid };
30
+ // Stale PID file — clean it up
31
+ removePid(pidPath);
32
+ return { running: false, pid: null };
33
+ }
34
+ export function removePid(pidPath) {
35
+ try {
36
+ unlinkSync(pidPath);
37
+ }
38
+ catch {
39
+ // Already gone
40
+ }
41
+ }
42
+ /**
43
+ * Send SIGTERM to a daemon and wait for it to exit.
44
+ * Polls isProcessAlive every `intervalMs` up to `timeoutMs`.
45
+ * Only removes the PID file if the daemon's own cleanup didn't.
46
+ * Throws if the process doesn't exit within the timeout.
47
+ */
48
+ export async function stopDaemon(pid, pidPath, { timeoutMs = 5000, intervalMs = 100 } = {}) {
49
+ process.kill(pid, 'SIGTERM');
50
+ const deadline = Date.now() + timeoutMs;
51
+ while (isProcessAlive(pid)) {
52
+ if (Date.now() >= deadline) {
53
+ throw new Error(`Process ${pid} did not exit within ${timeoutMs}ms after SIGTERM`);
54
+ }
55
+ await new Promise((r) => setTimeout(r, intervalMs));
56
+ }
57
+ // Clean up PID file only if the daemon didn't remove it itself
58
+ if (readPid(pidPath) !== null) {
59
+ removePid(pidPath);
60
+ }
61
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "crond-js",
3
+ "version": "1.0.0",
4
+ "description": "Project-local cron daemon that reads a standard crontab file",
5
+ "type": "module",
6
+ "bin": {
7
+ "crond-js": "dist/cli.js",
8
+ "crond-mcp": "dist/mcp.js"
9
+ },
10
+ "files": [
11
+ "dist/"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "test:unit": "vitest run test/unit/",
16
+ "test:e2e": "vitest run test/e2e/ --testTimeout=120000",
17
+ "test": "vitest run",
18
+ "test:integration": "vitest run test/integration/ --testTimeout=180000",
19
+ "prepublishOnly": "tsc"
20
+ },
21
+ "keywords": [
22
+ "cron",
23
+ "daemon",
24
+ "mcp",
25
+ "crontab"
26
+ ],
27
+ "license": "MIT",
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "dependencies": {
32
+ "@modelcontextprotocol/sdk": "^1.27.1",
33
+ "croner": "^9.0.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^22.0.0",
37
+ "tsx": "^4.21.0",
38
+ "typescript": "^5.7.0",
39
+ "vitest": "^3.0.0"
40
+ }
41
+ }