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 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
+ }
@@ -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 };
@@ -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 };