cronpipe 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.
- package/README.md +220 -0
- package/bin/cronpipe-ctl.js +48 -0
- package/bin/cronpipe.js +50 -0
- package/package.json +23 -0
- package/src/config-parser.js +69 -0
- package/src/runner.js +52 -0
- package/src/scheduler.js +64 -0
- package/src/script-runner.js +86 -0
package/README.md
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# cronpipe
|
|
2
|
+
|
|
3
|
+
Run scripts on a schedule with optional gate and finalise pipeline control.
|
|
4
|
+
|
|
5
|
+
- **Simple intervals** — `10m`, `2h`, `1d` or full cron syntax
|
|
6
|
+
- **Gate scripts** — conditionally skip a run based on exit code
|
|
7
|
+
- **Finalise scripts** — run cleanup or reporting after the main script
|
|
8
|
+
- **Overlap-safe** — if a run is still in progress at the next tick, the tick is skipped
|
|
9
|
+
- **Logging** — timestamped, labelled output to console and/or log file
|
|
10
|
+
- **Cross-platform** — Windows and Linux/Mac
|
|
11
|
+
- **Multi-script support** — `.js`, `.ps1`, and `.sh` scripts
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Single job — `cronpipe`
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx cronpipe --run <script> [options]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Option | Description |
|
|
22
|
+
|--------|-------------|
|
|
23
|
+
| `--run <script>` | **(required)** Script to run |
|
|
24
|
+
| `--gate <script>` | Run only if this exits `0` |
|
|
25
|
+
| `--post-gate <script>` | Run finalise only if this exits `0` |
|
|
26
|
+
| `--finalise <script>` | Script to run after the agent |
|
|
27
|
+
| `--schedule <expr>` | Interval or cron expression (omit to run once) |
|
|
28
|
+
| `--name <label>` | Label shown in log output |
|
|
29
|
+
| `--log <file>` | Append all output to a log file |
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Run once
|
|
33
|
+
npx cronpipe --run ./script.js
|
|
34
|
+
|
|
35
|
+
# Run every 10 minutes
|
|
36
|
+
npx cronpipe --run ./script.js --schedule 10m
|
|
37
|
+
|
|
38
|
+
# Full pipeline, hourly, with logging
|
|
39
|
+
npx cronpipe \
|
|
40
|
+
--name my-job \
|
|
41
|
+
--gate ./gate.sh \
|
|
42
|
+
--run ./script.js \
|
|
43
|
+
--post-gate ./post-gate.js \
|
|
44
|
+
--finalise ./finalise.ps1 \
|
|
45
|
+
--schedule 1h \
|
|
46
|
+
--log ./my-job.log
|
|
47
|
+
|
|
48
|
+
# Cron syntax — every day at midnight
|
|
49
|
+
npx cronpipe --run ./script.js --schedule "0 0 * * *"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Multiple jobs — `cronpipe-ctl`
|
|
53
|
+
|
|
54
|
+
Define all jobs in a config file and run them together in a single process:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npx cronpipe-ctl <config-file>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Schedule expressions
|
|
61
|
+
|
|
62
|
+
| Format | Example | Description |
|
|
63
|
+
|--------|---------|-------------|
|
|
64
|
+
| Milliseconds | `500ms` | Every 500 milliseconds |
|
|
65
|
+
| Seconds | `30s` | Every 30 seconds |
|
|
66
|
+
| Minutes | `10m` | Every 10 minutes |
|
|
67
|
+
| Hours | `2h` | Every 2 hours |
|
|
68
|
+
| Days | `1d` | Every day |
|
|
69
|
+
| Cron | `0 9 * * 1-5` | 9am on weekdays |
|
|
70
|
+
|
|
71
|
+
Simple intervals run immediately on startup, then repeat. Cron expressions follow standard 5-field syntax.
|
|
72
|
+
|
|
73
|
+
## Pipeline
|
|
74
|
+
|
|
75
|
+
Each job runs four steps in order. Optional steps are skipped when not provided.
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
gate? ──► run ──► post-gate? ──► finalise?
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- **gate** — if provided and exits non-zero, the entire run is skipped
|
|
82
|
+
- **run** — always runs if gate passes (or no gate is provided)
|
|
83
|
+
- **post-gate** — if provided and exits non-zero, finalise is skipped
|
|
84
|
+
- **finalise** — runs after agent if post-gate passes (or no post-gate is provided)
|
|
85
|
+
|
|
86
|
+
### Overlap protection
|
|
87
|
+
|
|
88
|
+
If a run is still in progress when the next tick fires, the tick is silently skipped. The running job is never interrupted and no backlog builds up — execution simply resumes at the following tick.
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
[cronpipe] previous run still in progress — skipping tick
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Logging
|
|
95
|
+
|
|
96
|
+
All script output is timestamped and prefixed with `[name:step]` so output from different jobs and pipeline steps stays identifiable.
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
2026-04-08 09:00:01 [my-job:gate] checking conditions...
|
|
100
|
+
2026-04-08 09:00:01 [my-job:run] starting task
|
|
101
|
+
2026-04-08 09:00:03 [my-job:run] task complete
|
|
102
|
+
2026-04-08 09:00:03 [my-job:finalise] sending report
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Use `--log <file>` to append the same output to a file alongside the console. The file is appended to, not overwritten, so logs accumulate across restarts.
|
|
106
|
+
|
|
107
|
+
## Script types
|
|
108
|
+
|
|
109
|
+
The runner detects the script type from the file extension:
|
|
110
|
+
|
|
111
|
+
| Extension | Runner |
|
|
112
|
+
|-----------|--------|
|
|
113
|
+
| `.js` `.mjs` `.cjs` | `node` |
|
|
114
|
+
| `.ps1` | `powershell.exe` (Windows) / `pwsh` (Linux/Mac) |
|
|
115
|
+
| `.sh` | `bash` |
|
|
116
|
+
| *(none)* | `powershell.exe` (Windows) / `bash` (Linux/Mac) |
|
|
117
|
+
|
|
118
|
+
Scripts communicate pass/fail via exit code — `0` means pass, anything else means fail.
|
|
119
|
+
|
|
120
|
+
**Node.js example (gate):**
|
|
121
|
+
```js
|
|
122
|
+
// gate.js — allow run only between 9am–5pm
|
|
123
|
+
const hour = new Date().getHours();
|
|
124
|
+
process.exit(hour >= 9 && hour < 17 ? 0 : 1);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Bash example (gate):**
|
|
128
|
+
```bash
|
|
129
|
+
#!/bin/bash
|
|
130
|
+
# gate.sh — skip if a lock file exists
|
|
131
|
+
[ -f /tmp/task.lock ] && exit 1
|
|
132
|
+
exit 0
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**PowerShell example (gate):**
|
|
136
|
+
```powershell
|
|
137
|
+
# gate.ps1 — skip on weekends
|
|
138
|
+
$day = (Get-Date).DayOfWeek
|
|
139
|
+
if ($day -eq 'Saturday' -or $day -eq 'Sunday') { exit 1 }
|
|
140
|
+
exit 0
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Config file format (`cronpipe-ctl`)
|
|
144
|
+
|
|
145
|
+
Pipe-delimited, one job per line. Use `-` for optional fields you need to skip over.
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
# schedule | gate | run | post-gate | finalise | log-file
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
# Just schedule and run script
|
|
153
|
+
10m | ./script.js
|
|
154
|
+
|
|
155
|
+
# Gate + run (no finalise)
|
|
156
|
+
1h | ./gate.sh | ./script.js
|
|
157
|
+
|
|
158
|
+
# Skip gate, run finalise (- holds the gate position)
|
|
159
|
+
1h | - | ./script.js | - | ./finalise.js
|
|
160
|
+
|
|
161
|
+
# Full pipeline with log file
|
|
162
|
+
0 9 * * 1-5 | ./gate.ps1 | ./script.ps1 | ./post-gate.ps1 | ./finalise.ps1 | ./logs/job.log
|
|
163
|
+
|
|
164
|
+
# Mix of script types
|
|
165
|
+
30m | ./gate.js | ./script.sh | - | ./finalise.ps1
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
- Trailing optional fields can be omitted entirely
|
|
169
|
+
- `-` is only needed to skip an earlier field when a later one is specified
|
|
170
|
+
- Lines starting with `#` are comments
|
|
171
|
+
|
|
172
|
+
See [`example.agentctl`](example.agentctl) for a complete annotated example.
|
|
173
|
+
|
|
174
|
+
## Long-running processes
|
|
175
|
+
|
|
176
|
+
`npx` is best suited for one-off runs. For persistent scheduled jobs, install globally and manage with a process manager:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
npm install -g cronpipe
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**Linux (pm2):**
|
|
183
|
+
```bash
|
|
184
|
+
pm2 start "cronpipe-ctl jobs.agentctl" --name cronpipe
|
|
185
|
+
pm2 save
|
|
186
|
+
pm2 startup
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**Windows (pm2):**
|
|
190
|
+
```powershell
|
|
191
|
+
pm2 start "cronpipe-ctl jobs.agentctl" --name cronpipe
|
|
192
|
+
pm2 save
|
|
193
|
+
pm2 startup
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Linux (systemd):** create `/etc/systemd/system/cronpipe.service`:
|
|
197
|
+
```ini
|
|
198
|
+
[Unit]
|
|
199
|
+
Description=cronpipe
|
|
200
|
+
|
|
201
|
+
[Service]
|
|
202
|
+
ExecStart=/usr/bin/cronpipe-ctl /path/to/jobs.agentctl
|
|
203
|
+
Restart=always
|
|
204
|
+
|
|
205
|
+
[Install]
|
|
206
|
+
WantedBy=multi-user.target
|
|
207
|
+
```
|
|
208
|
+
```bash
|
|
209
|
+
systemctl enable --now cronpipe
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Requirements
|
|
213
|
+
|
|
214
|
+
- Node.js 18 or later
|
|
215
|
+
- `bash` for `.sh` scripts (pre-installed on Linux/Mac; use Git Bash or WSL on Windows)
|
|
216
|
+
- `pwsh` for `.ps1` scripts on Linux/Mac ([PowerShell Core](https://github.com/PowerShell/PowerShell))
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
MIT
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { program } = require('commander');
|
|
6
|
+
const { parseConfig } = require('../src/config-parser');
|
|
7
|
+
const { runPipeline } = require('../src/runner');
|
|
8
|
+
const { schedule } = require('../src/scheduler');
|
|
9
|
+
const { version } = require('../package.json');
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.name('cronpipe-ctl')
|
|
13
|
+
.description('Run multiple cronpipe jobs defined in a config file')
|
|
14
|
+
.version(version)
|
|
15
|
+
.argument('<config>', 'Path to cronpipe-ctl config file')
|
|
16
|
+
.parse();
|
|
17
|
+
|
|
18
|
+
const [configFile] = program.args;
|
|
19
|
+
|
|
20
|
+
let jobs;
|
|
21
|
+
try {
|
|
22
|
+
jobs = parseConfig(configFile);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
console.error(`[cronpipe-ctl] Failed to read config: ${err.message}`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (jobs.length === 0) {
|
|
29
|
+
console.error('[cronpipe-ctl] No valid jobs found in config file');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
console.log(`[cronpipe-ctl] Loaded ${jobs.length} job(s) from ${configFile}`);
|
|
34
|
+
|
|
35
|
+
for (const job of jobs) {
|
|
36
|
+
const label = job.name ?? job.run;
|
|
37
|
+
const logStream = job.logFile
|
|
38
|
+
? fs.createWriteStream(job.logFile, { flags: 'a' })
|
|
39
|
+
: null;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
schedule(job.schedule, () => runPipeline({ ...job, logStream }));
|
|
43
|
+
const logNote = job.logFile ? ` → ${job.logFile}` : '';
|
|
44
|
+
console.log(`[cronpipe-ctl] Scheduled "${label}" — ${job.schedule}${logNote}`);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error(`[cronpipe-ctl] Failed to schedule "${label}": ${err.message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
package/bin/cronpipe.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { program } = require('commander');
|
|
6
|
+
const { runPipeline } = require('../src/runner');
|
|
7
|
+
const { schedule } = require('../src/scheduler');
|
|
8
|
+
const { version } = require('../package.json');
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.name('cronpipe')
|
|
12
|
+
.description('Run a script with optional gate and finalise pipeline control')
|
|
13
|
+
.version(version)
|
|
14
|
+
.requiredOption('--run <script>', 'Script to run (.js, .sh, .ps1)')
|
|
15
|
+
.option('--gate <script>', 'Gate script — script runs only if this exits 0')
|
|
16
|
+
.option('--post-gate <script>', 'Post-gate script — finalise runs only if this exits 0')
|
|
17
|
+
.option('--finalise <script>', 'Finalise script to run after the main script')
|
|
18
|
+
.option('--schedule <expr>', 'Run on a schedule: simple interval (10m, 1h, 1d) or cron syntax')
|
|
19
|
+
.option('--name <label>', 'Label shown in log output')
|
|
20
|
+
.option('--log <file>', 'Append all script output to a log file')
|
|
21
|
+
.parse();
|
|
22
|
+
|
|
23
|
+
const opts = program.opts();
|
|
24
|
+
|
|
25
|
+
const logStream = opts.log
|
|
26
|
+
? fs.createWriteStream(opts.log, { flags: 'a' })
|
|
27
|
+
: null;
|
|
28
|
+
|
|
29
|
+
const config = {
|
|
30
|
+
gate: opts.gate ?? null,
|
|
31
|
+
run: opts.run,
|
|
32
|
+
postGate: opts.postGate ?? null,
|
|
33
|
+
finalise: opts.finalise ?? null,
|
|
34
|
+
name: opts.name ?? '',
|
|
35
|
+
logStream,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
if (opts.schedule) {
|
|
39
|
+
try {
|
|
40
|
+
schedule(opts.schedule, () => runPipeline(config));
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error(err.message);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
runPipeline(config).catch((err) => {
|
|
47
|
+
console.error('[cronpipe] fatal:', err.message);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
});
|
|
50
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cronpipe",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Run agent scripts on a schedule with gate/finalise pipeline control",
|
|
5
|
+
"files": ["bin", "src"],
|
|
6
|
+
"bin": {
|
|
7
|
+
"cronpipe": "./bin/cronpipe.js",
|
|
8
|
+
"cronpipe-ctl": "./bin/cronpipe-ctl.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test test/**/*.test.js",
|
|
12
|
+
"lint": "eslint src bin"
|
|
13
|
+
},
|
|
14
|
+
"keywords": ["agent", "cron", "scheduler", "automation"],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"commander": "^12.0.0",
|
|
21
|
+
"node-cron": "^3.0.3"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parses an cronpipe-ctl config file.
|
|
7
|
+
*
|
|
8
|
+
* Format (pipe-delimited, one job per line):
|
|
9
|
+
*
|
|
10
|
+
* # comment
|
|
11
|
+
* schedule | run
|
|
12
|
+
* schedule | gate | run | post-gate | finalise | log-file
|
|
13
|
+
*
|
|
14
|
+
* All fields except schedule and run are optional — use "-" or leave empty.
|
|
15
|
+
*
|
|
16
|
+
* Examples:
|
|
17
|
+
* 10m | ./run.js (agent only)
|
|
18
|
+
* 1h | ./gate.sh | ./run.js (gate + agent)
|
|
19
|
+
* 1h | - | ./run.js | - | ./finalise.ps1 (- holds position)
|
|
20
|
+
* 0 * * * * | ./gate.sh | ./run.js | ./post.js | ./done.js (full pipeline)
|
|
21
|
+
* 10m | - | ./run.js | - | - | ./logs/agent.log (with log file)
|
|
22
|
+
*
|
|
23
|
+
* @param {string} filePath - Path to the config file
|
|
24
|
+
* @returns {Array<{schedule, gate, run, postGate, finalise}>}
|
|
25
|
+
*/
|
|
26
|
+
function parseConfig(filePath) {
|
|
27
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
28
|
+
const jobs = [];
|
|
29
|
+
|
|
30
|
+
const lines = content.split(/\r?\n/);
|
|
31
|
+
for (let i = 0; i < lines.length; i++) {
|
|
32
|
+
const line = lines[i].trim();
|
|
33
|
+
if (!line || line.startsWith('#')) continue;
|
|
34
|
+
|
|
35
|
+
const parts = line.split('|').map((p) => p.trim());
|
|
36
|
+
const norm = (v) => (!v || v === '-' ? null : v);
|
|
37
|
+
|
|
38
|
+
let schedule, gate, run, postGate, finalise, logFile;
|
|
39
|
+
|
|
40
|
+
if (parts.length === 2) {
|
|
41
|
+
// schedule | run (no gate)
|
|
42
|
+
[schedule, run] = parts;
|
|
43
|
+
} else if (parts.length >= 3) {
|
|
44
|
+
// schedule | gate | run | [post-gate] | [finalise] | [log-file]
|
|
45
|
+
[schedule, gate, run, postGate, finalise, logFile] = parts;
|
|
46
|
+
} else {
|
|
47
|
+
console.warn(`[cronpipe-ctl] line ${i + 1}: skipping — needs at least "schedule | run"`);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!norm(run)) {
|
|
52
|
+
console.warn(`[cronpipe-ctl] line ${i + 1}: skipping — run script is required`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
jobs.push({
|
|
57
|
+
schedule: schedule.trim(),
|
|
58
|
+
gate: norm(gate),
|
|
59
|
+
run: norm(run),
|
|
60
|
+
postGate: norm(postGate),
|
|
61
|
+
finalise: norm(finalise),
|
|
62
|
+
logFile: norm(logFile),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return jobs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { parseConfig };
|
package/src/runner.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { runScript } = require('./script-runner');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Executes the agent pipeline:
|
|
7
|
+
*
|
|
8
|
+
* gate? → run → post-gate? → finalise?
|
|
9
|
+
*
|
|
10
|
+
* @param {object} opts
|
|
11
|
+
* @param {string|null} opts.gate - Path to gate script (optional)
|
|
12
|
+
* @param {string} opts.run - Path to agent script (required)
|
|
13
|
+
* @param {string|null} opts.postGate - Path to post-gate script (optional)
|
|
14
|
+
* @param {string|null} opts.finalise - Path to finalise script (optional)
|
|
15
|
+
* @param {string} [opts.name] - Label prefix used in log output
|
|
16
|
+
* @param {import('fs').WriteStream} [opts.logStream] - Optional file log stream
|
|
17
|
+
*/
|
|
18
|
+
async function runPipeline({ gate, run, postGate, finalise, name = '', logStream = null }) {
|
|
19
|
+
const label = (step) => name ? `${name}:${step}` : step;
|
|
20
|
+
const log = (msg) => {
|
|
21
|
+
console.log(msg);
|
|
22
|
+
logStream?.write(msg + '\n');
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
if (gate) {
|
|
26
|
+
log(`[cronpipe] gate: ${gate}`);
|
|
27
|
+
const code = await runScript(gate, { label: label('gate'), logStream });
|
|
28
|
+
if (code !== 0) {
|
|
29
|
+
log(`[cronpipe] gate exited ${code} — skipping run`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
log(`[cronpipe] run: ${run}`);
|
|
35
|
+
await runScript(run, { label: label('run'), logStream });
|
|
36
|
+
|
|
37
|
+
if (postGate) {
|
|
38
|
+
log(`[cronpipe] post-gate: ${postGate}`);
|
|
39
|
+
const code = await runScript(postGate, { label: label('post-gate'), logStream });
|
|
40
|
+
if (code !== 0) {
|
|
41
|
+
log(`[cronpipe] post-gate exited ${code} — skipping finalise`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (finalise) {
|
|
47
|
+
log(`[cronpipe] finalise: ${finalise}`);
|
|
48
|
+
await runScript(finalise, { label: label('finalise'), logStream });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { runPipeline };
|
package/src/scheduler.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const cron = require('node-cron');
|
|
4
|
+
|
|
5
|
+
// Matches simple interval expressions: 500ms, 30s, 10m, 2h, 1d
|
|
6
|
+
const INTERVAL_RE = /^(\d+)(ms|s|m|h|d)$/;
|
|
7
|
+
const MULTIPLIERS = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parses a simple interval string (e.g. "10m", "1h") into milliseconds.
|
|
11
|
+
* Returns null if the string is not a simple interval.
|
|
12
|
+
*/
|
|
13
|
+
function parseInterval(expr) {
|
|
14
|
+
const match = expr.match(INTERVAL_RE);
|
|
15
|
+
if (!match) return null;
|
|
16
|
+
return parseInt(match[1], 10) * MULTIPLIERS[match[2]];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Schedules a function to run on a given expression.
|
|
21
|
+
*
|
|
22
|
+
* Expression can be:
|
|
23
|
+
* - Simple interval: "30s", "10m", "2h", "1d" (runs immediately, then repeats)
|
|
24
|
+
* - Cron syntax: "0 * * * *" (standard 5-field cron)
|
|
25
|
+
*
|
|
26
|
+
* Returns an object with a `stop()` method to cancel the schedule.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} expr - Schedule expression
|
|
29
|
+
* @param {function} fn - Async-safe callback (errors are caught and logged)
|
|
30
|
+
* @returns {{ stop: function }}
|
|
31
|
+
*/
|
|
32
|
+
function schedule(expr, fn) {
|
|
33
|
+
let running = false;
|
|
34
|
+
|
|
35
|
+
const safeRun = () => {
|
|
36
|
+
if (running) {
|
|
37
|
+
console.log('[cronpipe] previous run still in progress — skipping tick');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
running = true;
|
|
41
|
+
Promise.resolve(fn())
|
|
42
|
+
.catch((err) => console.error('[cronpipe] pipeline error:', err.message))
|
|
43
|
+
.finally(() => { running = false; });
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const ms = parseInterval(expr);
|
|
47
|
+
if (ms !== null) {
|
|
48
|
+
safeRun(); // run immediately on first tick
|
|
49
|
+
const timer = setInterval(safeRun, ms);
|
|
50
|
+
return { stop: () => clearInterval(timer) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (cron.validate(expr)) {
|
|
54
|
+
const task = cron.schedule(expr, safeRun);
|
|
55
|
+
return { stop: () => task.stop() };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Invalid schedule expression: "${expr}".\n` +
|
|
60
|
+
'Use a simple interval (30s, 10m, 2h, 1d) or standard cron syntax (e.g. "0 * * * *").'
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { schedule, parseInterval };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const isWindows = os.platform() === 'win32';
|
|
8
|
+
|
|
9
|
+
function resolveScriptCommand(scriptPath) {
|
|
10
|
+
const ext = path.extname(scriptPath).toLowerCase();
|
|
11
|
+
|
|
12
|
+
switch (ext) {
|
|
13
|
+
case '.js':
|
|
14
|
+
case '.mjs':
|
|
15
|
+
case '.cjs':
|
|
16
|
+
return { cmd: process.execPath, args: [scriptPath] };
|
|
17
|
+
|
|
18
|
+
case '.ps1':
|
|
19
|
+
return isWindows
|
|
20
|
+
? { cmd: 'powershell.exe', args: ['-NonInteractive', '-File', scriptPath] }
|
|
21
|
+
: { cmd: 'pwsh', args: ['-NonInteractive', '-File', scriptPath] };
|
|
22
|
+
|
|
23
|
+
case '.sh':
|
|
24
|
+
return { cmd: 'bash', args: [scriptPath] };
|
|
25
|
+
|
|
26
|
+
default:
|
|
27
|
+
return isWindows
|
|
28
|
+
? { cmd: 'powershell.exe', args: ['-NonInteractive', '-File', scriptPath] }
|
|
29
|
+
: { cmd: 'bash', args: [scriptPath] };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function timestamp() {
|
|
34
|
+
return new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pipes a readable stream line-by-line, prefixing each line and optionally
|
|
39
|
+
* writing to a log file stream alongside the given output stream.
|
|
40
|
+
*/
|
|
41
|
+
function forwardLines(source, output, prefix, logStream) {
|
|
42
|
+
let buf = '';
|
|
43
|
+
source.on('data', (chunk) => {
|
|
44
|
+
buf += chunk.toString();
|
|
45
|
+
const lines = buf.split('\n');
|
|
46
|
+
buf = lines.pop(); // hold incomplete last line
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
const formatted = `${timestamp()} ${prefix} ${line}\n`;
|
|
49
|
+
output.write(formatted);
|
|
50
|
+
logStream?.write(formatted);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
source.on('end', () => {
|
|
54
|
+
if (buf) {
|
|
55
|
+
const formatted = `${timestamp()} ${prefix} ${buf}\n`;
|
|
56
|
+
output.write(formatted);
|
|
57
|
+
logStream?.write(formatted);
|
|
58
|
+
buf = '';
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Runs a script and resolves with its exit code.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} scriptPath
|
|
67
|
+
* @param {object} [opts]
|
|
68
|
+
* @param {string} [opts.label] - Prefix label for all output lines
|
|
69
|
+
* @param {import('fs').WriteStream} [opts.logStream] - Optional file log stream
|
|
70
|
+
*/
|
|
71
|
+
function runScript(scriptPath, { label = '', logStream = null } = {}) {
|
|
72
|
+
const { cmd, args } = resolveScriptCommand(scriptPath);
|
|
73
|
+
const prefix = label ? `[${label}]` : '';
|
|
74
|
+
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const proc = spawn(cmd, args, { stdio: ['inherit', 'pipe', 'pipe'] });
|
|
77
|
+
|
|
78
|
+
forwardLines(proc.stdout, process.stdout, prefix, logStream);
|
|
79
|
+
forwardLines(proc.stderr, process.stderr, prefix, logStream);
|
|
80
|
+
|
|
81
|
+
proc.on('close', (code) => resolve(code ?? 0));
|
|
82
|
+
proc.on('error', reject);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { runScript, resolveScriptCommand };
|