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 +134 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +72 -0
- package/dist/crontab.d.ts +11 -0
- package/dist/crontab.js +38 -0
- package/dist/daemon.d.ts +15 -0
- package/dist/daemon.js +163 -0
- package/dist/logger.d.ts +4 -0
- package/dist/logger.js +27 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +129 -0
- package/dist/pid.d.ts +18 -0
- package/dist/pid.js +61 -0
- package/package.json +41 -0
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
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[]>;
|
package/dist/crontab.js
ADDED
|
@@ -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
|
+
}
|
package/dist/daemon.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/logger.d.ts
ADDED
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
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
|
+
}
|