@arkkwang/pi-cron 0.1.0 → 0.1.1
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 +2 -0
- package/README.zh-CN.md +101 -99
- package/cron.mjs +271 -269
- package/package.json +45 -45
package/README.md
CHANGED
|
@@ -16,6 +16,8 @@ pi-cron ls -f /path/to/tasks.json
|
|
|
16
16
|
pi-cron start -f /path/to/tasks.json
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
On minimal Linux images, install `procps` (for example, `apt-get install procps` on Debian/Ubuntu): process-tree termination depends on `ps`. A container with only Node and Pi is not sufficient for timeout handling.
|
|
20
|
+
|
|
19
21
|
Installation does not install Pi, start a service, or create tasks. To run from source:
|
|
20
22
|
|
|
21
23
|
```bash
|
package/README.zh-CN.md
CHANGED
|
@@ -1,99 +1,101 @@
|
|
|
1
|
-
[English](README.md) | 简体中文
|
|
2
|
-
|
|
3
|
-
# pi-cron
|
|
4
|
-
|
|
5
|
-
面向官方 [Pi](https://github.com/earendil-works/pi) CLI 的本机轻量调度器:配置热更新、任务去重、不重叠执行与超时控制。
|
|
6
|
-
|
|
7
|
-
独立 Node 轮询进程,不依赖 Pi 的 scheduler 插件;源码附带 Bash 入口;每次到期调用 PATH 中的 `pi`,参数原样传递,不再定义模型、provider 等包装字段。
|
|
8
|
-
|
|
9
|
-
## 启动
|
|
10
|
-
|
|
11
|
-
Windows 使用 Git Bash;macOS 使用 Bash。两端均需 Node.js 22.19+、npm,以及 PATH 中可用的 `pi`。
|
|
12
|
-
|
|
13
|
-
推荐通过 npm 安装(不会安装 Pi、启动服务或创建任务):
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
npm install -g @arkkwang/pi-cron
|
|
17
|
-
pi-cron ls -f /path/to/tasks.json
|
|
18
|
-
pi-cron start -f /path/to/tasks.json
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
]
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
- `
|
|
55
|
-
- `
|
|
56
|
-
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
-
|
|
65
|
-
-
|
|
66
|
-
-
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
70
|
-
-
|
|
71
|
-
-
|
|
72
|
-
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
1
|
+
[English](README.md) | 简体中文
|
|
2
|
+
|
|
3
|
+
# pi-cron
|
|
4
|
+
|
|
5
|
+
面向官方 [Pi](https://github.com/earendil-works/pi) CLI 的本机轻量调度器:配置热更新、任务去重、不重叠执行与超时控制。
|
|
6
|
+
|
|
7
|
+
独立 Node 轮询进程,不依赖 Pi 的 scheduler 插件;源码附带 Bash 入口;每次到期调用 PATH 中的 `pi`,参数原样传递,不再定义模型、provider 等包装字段。
|
|
8
|
+
|
|
9
|
+
## 启动
|
|
10
|
+
|
|
11
|
+
Windows 使用 Git Bash;macOS 使用 Bash。两端均需 Node.js 22.19+、npm,以及 PATH 中可用的 `pi`。
|
|
12
|
+
|
|
13
|
+
推荐通过 npm 安装(不会安装 Pi、启动服务或创建任务):
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g @arkkwang/pi-cron
|
|
17
|
+
pi-cron ls -f /path/to/tasks.json
|
|
18
|
+
pi-cron start -f /path/to/tasks.json
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
精简 Linux 镜像还需安装 `procps`(例如 Debian/Ubuntu 的 `apt-get install procps`),为进程树终止提供 `ps`。只有 Node 和 Pi 的容器不足以支持超时处理。
|
|
22
|
+
|
|
23
|
+
主要用于自动执行 Agent 任务,任务参数仍是原生 Pi CLI 参数。源码运行:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
cd pi-cron
|
|
27
|
+
npm ci
|
|
28
|
+
bash pi-cron ls -f /path/to/tasks.json # 只读列出任务和最近记录
|
|
29
|
+
bash pi-cron start -f /path/to/tasks.json # 前台常驻
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
必须用 `-f` 显式指定配置,没有默认路径;相对路径以调用者当前工作目录为基准。包不附带私人任务或 runtime 文件。可用 `bash /absolute/path/to/pi-cron/pi-cron ...` 从任意目录调用,保留当前工作目录。
|
|
33
|
+
|
|
34
|
+
只在前台运行,不自动设置开机启动或后台服务。切换前请停用旧调度器,避免重复执行。依赖 PATH 中的官方 Pi CLI(`@earendil-works/pi-coding-agent`),无需修改 Pi 本身。
|
|
35
|
+
|
|
36
|
+
只有 `ls` 和 `start` 两个命令。子命令与 `-f` 顺序不限,例如 `./pi-cron -f ../../tasks.json ls` 与 `./pi-cron ls -f ../../tasks.json` 等价。`ls` 按每个任务一段纯文本展示,不输出表格;不获取单例锁,不创建或修改 runtime;调度器运行时也可查询。列表的 `lastStatus` 是磁盘中保存的最近状态,不代表进程仍然存活;`nextRun` 是按当前配置算出的下一次计划时间,`nextRun`/`lastRun` 均按本机时区展示,不保证一定执行。`start` 启动前也会校验配置。
|
|
37
|
+
|
|
38
|
+
## 两份数据文件
|
|
39
|
+
|
|
40
|
+
`-f` 指定的 `tasks.json` 是任务数组,唯一的配置来源。示例(自行保存到包目录之外):
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
[
|
|
44
|
+
{
|
|
45
|
+
"id": "daily-check",
|
|
46
|
+
"cron": "0 9 * * *",
|
|
47
|
+
"enabled": true,
|
|
48
|
+
"timeoutSeconds": 420,
|
|
49
|
+
"args": ["--model", "provider/model", "--thinking", "medium", "--print", "--", "执行每日检查"]
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- `id`、五段 `cron`、`args` 必填;`id` 会出现在日志与 runtime 记录里,用人类可读的短标识(如 `daily-check`),不要用 UUID;cron 时间按本机时区解释。
|
|
55
|
+
- `enabled` 默认 `true`;`timeoutSeconds` 默认 **1800 秒**。所有对外超时配置均以秒为单位。
|
|
56
|
+
- `cwd` 可选,相对路径以 `tasks.json` 所在目录为基准;省略则用该目录。支持绝对路径,但共享配置应避免机器专属盘符路径。不展开 `~` 或环境变量。
|
|
57
|
+
- `args` 就是 Pi CLI 参数数组。非交互任务请包含 `--print`;prompt 前可用 `--` 结束选项解析。无需配置 `command` 或 Node/Pi 安装路径。
|
|
58
|
+
- 配置在启动时读取并校验;之后每次轮询比对文件的修改时间与大小,变了就重新读取解析:保存即生效,增删任务、启用停用、改时间都不需要重启。重载成功日志只在解析后的配置指纹变化时输出;原样重存、touch 或仅修改缩进不记录,字段顺序变化仍视为变化。运行中读到非法或读不到的文件时保留上一份可用配置,在 stderr 报一次错(同一份坏文件不会每秒刷屏),不退出;修好保存后自动生效。`ls` 每次读取磁盘上的配置。
|
|
59
|
+
|
|
60
|
+
`tasks.runtime.json` 在配置文件旁自动创建、仅由调度器写入,不随包分发:按任务 ID 保存最后触发时间、状态、起止时间、退出码及最多 4000 字符的输出尾部。时间戳为本机时区的 ISO 8601,带偏移(如 `2026-09-30T11:30:00.489+08:00`);读取兼容 `Z` 结尾的历史 UTC 记录。不保存完整历史,也不是通知渠道。写入使用临时文件加 rename,避免半份 JSON;损坏的 runtime 会阻止启动,不会偷偷清空去重记录。不要手动修改运行中的 runtime。
|
|
61
|
+
|
|
62
|
+
## 固定规则
|
|
63
|
+
|
|
64
|
+
- 每秒轮询,分钟精度;同一任务同一分钟最多触发一次,包括同分钟重启。
|
|
65
|
+
- 热更新只替换任务定义:已在运行的任务不打断,跑到结束;删掉或停用的任务在本次运行结束后不再触发;改 `args`/`timeoutSeconds` 下次运行生效。重载不重置去重记录,所以本分钟已触发过的任务不会因重载再触发一次;新增任务若 cron 命中当前分钟且本分钟未跑过,会立即执行。
|
|
66
|
+
- 停机、休眠错过的分钟跳过,不补跑。若在计划分钟内启动,仍会执行该分钟的任务。
|
|
67
|
+
- 同任务不重叠,重叠的触发直接跳过;不同任务可并行。
|
|
68
|
+
- 先持久化触发记录,再启动 Pi;启动失败或进程中断不自动重试。不承诺 exactly-once。
|
|
69
|
+
- 到期执行前立即输出 `starting` 日志,包含实际时间、任务 ID 和计划触发时间;结束日志包含实际时间、结果和退出码。日志与 runtime 中的时间戳均为本机时区 ISO 8601(带偏移)。`starting` 表示开始尝试启动 Pi,不代表模型已开始处理。
|
|
70
|
+
- 超时尝试终止任务进程树并记录 `timeout`。终止失败会报错并停止新调度,等待任务结束或人工处理,不假报成功。
|
|
71
|
+
- SIGINT/SIGTERM 停止新调度,等待当前任务结束(其超时仍有效)。终端关闭/强制结束不保证走完优雅退出。
|
|
72
|
+
- 重启时遗留的 `running` 标为 `unknown`:无法证明旧进程已退出。异常终止后,人工确认旧任务已结束再启动,避免遗留进程重复执行。
|
|
73
|
+
- 用本机 `127.0.0.1:17391` 的监听占位保证单实例,不提供网络 API,也无需额外锁文件;端口被占用会明确失败。
|
|
74
|
+
- 暂不支持一次性任务、重试队列和原会话投递。
|
|
75
|
+
|
|
76
|
+
## 依赖与验证
|
|
77
|
+
|
|
78
|
+
只复用 `croner` 的时间计算,不使用其调度引擎;`cross-spawn` 处理跨平台 PATH/命令启动;`tree-kill` 处理 Windows/macOS/Linux 的进程树终止差异。本项目不维护 Windows 专用分支。进程库内部会使用各系统的原生命令,不代表依赖一个仅支持 Windows 的方案。
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npm test
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
测试将调度时钟固定在同一分钟,避免跨分钟误报;通过测试 preload 将单例监听映射到 OS 分配的临时端口,同一用例的后续进程复用该端口验证互斥与重启,不占用生产端口,也不要求停止常驻调度器。等待执行结果前先确认启动成功,提前退出时直接报告进程输出。日志专用用例禁用任务,覆盖原样重存、格式变化、实际字段变化、损坏配置及恢复,并观察多个轮询周期确认没有重复日志;真实子进程和定时器在综合用例中照常运行。测试仅运行 `pi --version` / `pi --help`,不调用模型或发送通知;覆盖到期计算、去重、非法配置、超时单位、实际启动与失败、配置热更新(非法配置保留旧值且只报一次)、单例、输出截断、重启去重及损坏状态文件。
|
|
85
|
+
|
|
86
|
+
已在 Windows Git Bash + Node 22 实测;macOS/Linux 尚未实测。每秒轮询先比对配置文件的时间戳与大小,只有变化时才重新读取解析;其余时间只检查内存中的任务是否到期。
|
|
87
|
+
|
|
88
|
+
## 边界与安全
|
|
89
|
+
|
|
90
|
+
每台机器只允许一个实例(固定本地端口 17391),同目录配置共用 `tasks.runtime.json`。不同配置不是独立实例隔离单元。
|
|
91
|
+
|
|
92
|
+
任务以当前用户权限运行 Pi,可能修改文件、调用工具并产生模型费用。仅运行可信任务配置;runtime 保存的输出尾部可能包含敏感内容,不应提交或公开。不提供沙箱、自动重试、补跑、完整历史或 exactly-once 保证。
|
|
93
|
+
|
|
94
|
+
## 相关项目
|
|
95
|
+
|
|
96
|
+
- [pi-snap](https://github.com/arkkwang/pi-snap):帮助人和 Agent 快速定位会话问题。
|
|
97
|
+
- [pi-subagent](https://github.com/arkkwang/pi-subagent):派发、观察和继续指挥子 Agent。
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
[MIT](LICENSE)
|
package/cron.mjs
CHANGED
|
@@ -1,269 +1,271 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import net from 'node:net';
|
|
5
|
-
import spawn from 'cross-spawn';
|
|
6
|
-
import killTree from 'tree-kill';
|
|
7
|
-
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import { Cron } from 'croner';
|
|
9
|
-
|
|
10
|
-
const POLL_MS = 1000;
|
|
11
|
-
const LOCK_PORT = 17391;
|
|
12
|
-
const OUTPUT_LIMIT = 4000;
|
|
13
|
-
const DEFAULT_TIMEOUT_SECONDS = 1800;
|
|
14
|
-
const RUNTIME_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}(Z|[+-]\d{2}:\d{2})$/;
|
|
15
|
-
let configPath, runtimePath;
|
|
16
|
-
const runningTaskIds = new Set();
|
|
17
|
-
let runtime = {}, config, configStamp, configFingerprint, stopping = false;
|
|
18
|
-
|
|
19
|
-
export function parseArgs(args) {
|
|
20
|
-
const usage = 'Usage: pi-cron <ls|start> -f <tasks.json> (either order)';
|
|
21
|
-
let command, file;
|
|
22
|
-
for (let i = 0; i < args.length; i++) {
|
|
23
|
-
if ((args[i] === 'ls' || args[i] === 'start') && !command) {
|
|
24
|
-
command = args[i];
|
|
25
|
-
continue;
|
|
26
|
-
}
|
|
27
|
-
if (args[i] === '-f' && !file && args[i + 1] && !args[i + 1].startsWith('-')) {
|
|
28
|
-
file = args[++i];
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
throw new Error(usage);
|
|
32
|
-
}
|
|
33
|
-
if (!command) throw new Error(usage);
|
|
34
|
-
if (!file) throw new Error('Required: -f <tasks.json>');
|
|
35
|
-
return { command, file };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function parseConfig(text, baseDir) {
|
|
39
|
-
const value = JSON.parse(text);
|
|
40
|
-
const strings = a => Array.isArray(a) && a.length > 0 && a.every(s => typeof s === 'string');
|
|
41
|
-
if (!Array.isArray(value)) throw new Error('tasks.json must be an array of tasks');
|
|
42
|
-
const ids = new Set();
|
|
43
|
-
return value.map(task => {
|
|
44
|
-
if (!task.id || typeof task.id !== 'string' || ids.has(task.id)) throw new Error('Task IDs must be unique nonempty strings');
|
|
45
|
-
ids.add(task.id);
|
|
46
|
-
if (!strings(task.args)) throw new Error(`${task.id}: args must be a nonempty string array`);
|
|
47
|
-
if (typeof task.cron !== 'string' || task.cron.trim().split(/\s+/).length !== 5) throw new Error(`${task.id}: use a five-field cron`);
|
|
48
|
-
if (task.enabled !== undefined && typeof task.enabled !== 'boolean') throw new Error(`${task.id}: enabled must be boolean`);
|
|
49
|
-
if (task.cwd !== undefined && typeof task.cwd !== 'string') throw new Error(`${task.id}: cwd must be a string`);
|
|
50
|
-
const timeoutSeconds = task.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
|
|
51
|
-
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0 || timeoutSeconds > 2147483) {
|
|
52
|
-
throw new Error(`${task.id}: timeoutSeconds must be positive and <= 2147483`);
|
|
53
|
-
}
|
|
54
|
-
return { ...task, timeoutSeconds, timer: new Cron(task.cron, { paused: true }),
|
|
55
|
-
cwd: path.resolve(baseDir, task.cwd ?? '.') };
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function toLocalISO(date) {
|
|
60
|
-
const pad = (n, width = 2) => String(n).padStart(width, '0');
|
|
61
|
-
const offset = -date.getTimezoneOffset();
|
|
62
|
-
const minutes = Math.abs(offset);
|
|
63
|
-
return `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
64
|
-
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`
|
|
65
|
-
+ `${offset < 0 ? '-' : '+'}${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function formatLocal(value) {
|
|
69
|
-
if (value == null) return '-';
|
|
70
|
-
const date = value instanceof Date ? value : new Date(value);
|
|
71
|
-
if (Number.isNaN(+date)) return String(value);
|
|
72
|
-
const pad = n => String(n).padStart(2, '0');
|
|
73
|
-
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function isDue(task, lastTriggerAt, minute) {
|
|
77
|
-
if (task.enabled === false || +new Date(lastTriggerAt) >= minute) return false;
|
|
78
|
-
return task.timer.nextRun(new Date(minute - 1))?.getTime() === minute;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function loadConfig() {
|
|
82
|
-
return parseConfig(fs.readFileSync(configPath, 'utf8'), path.dirname(configPath));
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function stamp() {
|
|
86
|
-
try {
|
|
87
|
-
const stats = fs.statSync(configPath);
|
|
88
|
-
return `${stats.mtimeMs}:${stats.size}`;
|
|
89
|
-
} catch (error) {
|
|
90
|
-
return `error:${error.code}`;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Config identity: the user-authored fields, in file order. Only the Cron instance is
|
|
95
|
-
// dropped; the resolved cwd and timeoutSeconds are deterministic, so a real edit changes
|
|
96
|
-
// this but a re-save or touch of identical content does not.
|
|
97
|
-
function fingerprint(tasks) {
|
|
98
|
-
return JSON.stringify(tasks.map(({ timer, ...task }) => task));
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Reload only when the file's stamp changes, and log only when the parsed config actually
|
|
102
|
-
// differs from the last good one: a re-save/touch with identical content stays silent.
|
|
103
|
-
// An unreadable or invalid file keeps the last good config and reports once; the stamp is
|
|
104
|
-
// consumed either way, so a broken file does not log every second. Already-triggered tasks
|
|
105
|
-
// stay deduplicated through runtime.lastTriggerAt.
|
|
106
|
-
function reload() {
|
|
107
|
-
const current = stamp();
|
|
108
|
-
if (current === configStamp) return;
|
|
109
|
-
configStamp = current;
|
|
110
|
-
try {
|
|
111
|
-
const next = loadConfig();
|
|
112
|
-
const nextFingerprint = fingerprint(next);
|
|
113
|
-
if (nextFingerprint === configFingerprint) return;
|
|
114
|
-
config = next;
|
|
115
|
-
configFingerprint = nextFingerprint;
|
|
116
|
-
console.log(JSON.stringify({ time: toLocalISO(new Date()), action: 'config reloaded', tasks: config.length }));
|
|
117
|
-
} catch (error) {
|
|
118
|
-
console.error(JSON.stringify({ time: toLocalISO(new Date()), action: 'config reload failed', configPath, error: error.message }));
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function save() {
|
|
123
|
-
const temp = `${runtimePath}.${process.pid}.tmp`;
|
|
124
|
-
fs.writeFileSync(temp, JSON.stringify(runtime, null, 2) + '\n');
|
|
125
|
-
fs.renameSync(temp, runtimePath);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function launch(task, slot) {
|
|
129
|
-
const state = runtime[task.id] = {
|
|
130
|
-
lastTriggerAt: slot, status: 'running', startedAt: toLocalISO(new Date())
|
|
131
|
-
};
|
|
132
|
-
save(); // Persist the claim BEFORE spawning; failed/interrupted claims are not retried.
|
|
133
|
-
console.log(JSON.stringify({ time: state.startedAt, task: task.id, status: 'starting', scheduledAt: slot }));
|
|
134
|
-
const child = spawn('pi', task.args, {
|
|
135
|
-
cwd: task.cwd,
|
|
136
|
-
env: { ...process.env, PI_SCHEDULED_RUN: '1' },
|
|
137
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
138
|
-
});
|
|
139
|
-
runningTaskIds.add(task.id);
|
|
140
|
-
let output = '', errorMessage, timedOut = false, finished = false;
|
|
141
|
-
const append = chunk => { output = (output + chunk.toString()).slice(-OUTPUT_LIMIT); };
|
|
142
|
-
child.stdout.setEncoding('utf8').on('data', append);
|
|
143
|
-
child.stderr.setEncoding('utf8').on('data', append);
|
|
144
|
-
child.on('error', error => { errorMessage = error.message; });
|
|
145
|
-
const timeout = setTimeout(() => {
|
|
146
|
-
if (!child.pid) return;
|
|
147
|
-
timedOut = true;
|
|
148
|
-
killTree(child.pid, 'SIGKILL', error => {
|
|
149
|
-
if (!error || finished) return;
|
|
150
|
-
state.error = `Termination failed: ${error.message}`;
|
|
151
|
-
try { save(); } catch (saveError) { console.error(saveError); }
|
|
152
|
-
fatal(new Error(JSON.stringify({ task: task.id, pid: child.pid, error: state.error })));
|
|
153
|
-
});
|
|
154
|
-
}, task.timeoutSeconds * 1000);
|
|
155
|
-
child.on('close', (code, signal) => {
|
|
156
|
-
finished = true;
|
|
157
|
-
clearTimeout(timeout);
|
|
158
|
-
runningTaskIds.delete(task.id);
|
|
159
|
-
let status = 'success';
|
|
160
|
-
if (code !== 0 || errorMessage) status = 'error';
|
|
161
|
-
if (timedOut) status = 'timeout';
|
|
162
|
-
Object.assign(state, {
|
|
163
|
-
status,
|
|
164
|
-
finishedAt: toLocalISO(new Date()), exitCode: code, signal,
|
|
165
|
-
output: errorMessage ? `${output}\n${errorMessage}`.slice(-OUTPUT_LIMIT) : output
|
|
166
|
-
});
|
|
167
|
-
try { save(); } catch (error) { fatal(error); }
|
|
168
|
-
console.log(JSON.stringify({ time: state.finishedAt, task: task.id, status, exitCode: code, signal, error: errorMessage }));
|
|
169
|
-
if (stopping && runningTaskIds.size === 0) lock?.close();
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function tick() {
|
|
174
|
-
reload();
|
|
175
|
-
const minute = Math.floor(Date.now() / 60000) * 60000;
|
|
176
|
-
const slot = toLocalISO(new Date(minute));
|
|
177
|
-
for (const task of config) {
|
|
178
|
-
if (!isDue(task, runtime[task.id]?.lastTriggerAt, minute)) continue;
|
|
179
|
-
if (runningTaskIds.has(task.id)) {
|
|
180
|
-
runtime[task.id].lastTriggerAt = slot; // Skip, don't queue an overlapping run.
|
|
181
|
-
save();
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
|
-
launch(task, slot);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function fatal(error) {
|
|
189
|
-
console.error(error);
|
|
190
|
-
process.exitCode = 1;
|
|
191
|
-
stop();
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
let interval, lock;
|
|
195
|
-
function stop() {
|
|
196
|
-
if (stopping) return;
|
|
197
|
-
stopping = true;
|
|
198
|
-
clearInterval(interval);
|
|
199
|
-
console.log(JSON.stringify({ action: 'stop scheduling; wait for active tasks', tasks: [...runningTaskIds] }));
|
|
200
|
-
if (runningTaskIds.size === 0) lock?.close();
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function readRuntime() {
|
|
204
|
-
let states;
|
|
205
|
-
try { states = JSON.parse(fs.readFileSync(runtimePath, 'utf8')); } catch (error) {
|
|
206
|
-
if (error.code === 'ENOENT') return Object.create(null);
|
|
207
|
-
throw error;
|
|
208
|
-
}
|
|
209
|
-
if (!states || Array.isArray(states) || typeof states !== 'object') throw new Error('Invalid runtime file');
|
|
210
|
-
for (const [id, state] of Object.entries(states)) {
|
|
211
|
-
const timestamp = state?.lastTriggerAt;
|
|
212
|
-
if (typeof timestamp !== 'string' || !RUNTIME_TIMESTAMP.test(timestamp) || !Number.isFinite(+new Date(timestamp))) {
|
|
213
|
-
throw new Error(`${id}: invalid runtime lastTriggerAt`);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
return Object.assign(Object.create(null), states);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function listTasks() {
|
|
220
|
-
const states = readRuntime();
|
|
221
|
-
console.log(`Timezone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}; times below are local; lastStatus is saved state, not a liveness check`);
|
|
222
|
-
for (const task of config) {
|
|
223
|
-
const state = states[task.id];
|
|
224
|
-
const next = task.enabled === false ? null : task.timer.nextRun(new Date());
|
|
225
|
-
console.log([
|
|
226
|
-
'',
|
|
227
|
-
task.name ?? task.id,
|
|
228
|
-
` id: ${task.id}`,
|
|
229
|
-
` enabled: ${task.enabled !== false} cron: ${task.cron}`,
|
|
230
|
-
` nextRun: ${next ? formatLocal(next) : '-'}`,
|
|
231
|
-
` lastStatus: ${state?.status ?? 'never'} lastRun: ${formatLocal(state?.startedAt)}`
|
|
232
|
-
].join('\n'));
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
async function main() {
|
|
237
|
-
const options = parseArgs(process.argv.slice(2));
|
|
238
|
-
configPath = path.resolve(options.file);
|
|
239
|
-
runtimePath = path.join(path.dirname(configPath), 'tasks.runtime.json');
|
|
240
|
-
config = loadConfig();
|
|
241
|
-
configStamp = stamp();
|
|
242
|
-
configFingerprint = fingerprint(config);
|
|
243
|
-
if (options.command === 'ls') { listTasks(); return; }
|
|
244
|
-
// OS-managed singleton: no lock file or stale PID recovery.
|
|
245
|
-
lock = net.createServer(socket => socket.end());
|
|
246
|
-
await new Promise((resolve, reject) => {
|
|
247
|
-
lock.once('error', reject);
|
|
248
|
-
lock.listen(LOCK_PORT, '127.0.0.1', resolve);
|
|
249
|
-
});
|
|
250
|
-
runtime = readRuntime();
|
|
251
|
-
for (const state of Object.values(runtime)) {
|
|
252
|
-
if (state.status === 'running') state.status = 'unknown';
|
|
253
|
-
}
|
|
254
|
-
save();
|
|
255
|
-
process.on('SIGINT', stop);
|
|
256
|
-
process.on('SIGTERM', stop);
|
|
257
|
-
const safeTick = () => { try { tick(); } catch (error) { fatal(error); } };
|
|
258
|
-
console.log(`Polling ${configPath} every second (reloads when the file changes; local timezone; missed minutes skipped)`);
|
|
259
|
-
interval = setInterval(safeTick, POLL_MS);
|
|
260
|
-
safeTick();
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import spawn from 'cross-spawn';
|
|
6
|
+
import killTree from 'tree-kill';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { Cron } from 'croner';
|
|
9
|
+
|
|
10
|
+
const POLL_MS = 1000;
|
|
11
|
+
const LOCK_PORT = 17391;
|
|
12
|
+
const OUTPUT_LIMIT = 4000;
|
|
13
|
+
const DEFAULT_TIMEOUT_SECONDS = 1800;
|
|
14
|
+
const RUNTIME_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}(Z|[+-]\d{2}:\d{2})$/;
|
|
15
|
+
let configPath, runtimePath;
|
|
16
|
+
const runningTaskIds = new Set();
|
|
17
|
+
let runtime = {}, config, configStamp, configFingerprint, stopping = false;
|
|
18
|
+
|
|
19
|
+
export function parseArgs(args) {
|
|
20
|
+
const usage = 'Usage: pi-cron <ls|start> -f <tasks.json> (either order)';
|
|
21
|
+
let command, file;
|
|
22
|
+
for (let i = 0; i < args.length; i++) {
|
|
23
|
+
if ((args[i] === 'ls' || args[i] === 'start') && !command) {
|
|
24
|
+
command = args[i];
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (args[i] === '-f' && !file && args[i + 1] && !args[i + 1].startsWith('-')) {
|
|
28
|
+
file = args[++i];
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
throw new Error(usage);
|
|
32
|
+
}
|
|
33
|
+
if (!command) throw new Error(usage);
|
|
34
|
+
if (!file) throw new Error('Required: -f <tasks.json>');
|
|
35
|
+
return { command, file };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parseConfig(text, baseDir) {
|
|
39
|
+
const value = JSON.parse(text);
|
|
40
|
+
const strings = a => Array.isArray(a) && a.length > 0 && a.every(s => typeof s === 'string');
|
|
41
|
+
if (!Array.isArray(value)) throw new Error('tasks.json must be an array of tasks');
|
|
42
|
+
const ids = new Set();
|
|
43
|
+
return value.map(task => {
|
|
44
|
+
if (!task.id || typeof task.id !== 'string' || ids.has(task.id)) throw new Error('Task IDs must be unique nonempty strings');
|
|
45
|
+
ids.add(task.id);
|
|
46
|
+
if (!strings(task.args)) throw new Error(`${task.id}: args must be a nonempty string array`);
|
|
47
|
+
if (typeof task.cron !== 'string' || task.cron.trim().split(/\s+/).length !== 5) throw new Error(`${task.id}: use a five-field cron`);
|
|
48
|
+
if (task.enabled !== undefined && typeof task.enabled !== 'boolean') throw new Error(`${task.id}: enabled must be boolean`);
|
|
49
|
+
if (task.cwd !== undefined && typeof task.cwd !== 'string') throw new Error(`${task.id}: cwd must be a string`);
|
|
50
|
+
const timeoutSeconds = task.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
|
|
51
|
+
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0 || timeoutSeconds > 2147483) {
|
|
52
|
+
throw new Error(`${task.id}: timeoutSeconds must be positive and <= 2147483`);
|
|
53
|
+
}
|
|
54
|
+
return { ...task, timeoutSeconds, timer: new Cron(task.cron, { paused: true }),
|
|
55
|
+
cwd: path.resolve(baseDir, task.cwd ?? '.') };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function toLocalISO(date) {
|
|
60
|
+
const pad = (n, width = 2) => String(n).padStart(width, '0');
|
|
61
|
+
const offset = -date.getTimezoneOffset();
|
|
62
|
+
const minutes = Math.abs(offset);
|
|
63
|
+
return `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
64
|
+
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`
|
|
65
|
+
+ `${offset < 0 ? '-' : '+'}${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function formatLocal(value) {
|
|
69
|
+
if (value == null) return '-';
|
|
70
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
71
|
+
if (Number.isNaN(+date)) return String(value);
|
|
72
|
+
const pad = n => String(n).padStart(2, '0');
|
|
73
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function isDue(task, lastTriggerAt, minute) {
|
|
77
|
+
if (task.enabled === false || +new Date(lastTriggerAt) >= minute) return false;
|
|
78
|
+
return task.timer.nextRun(new Date(minute - 1))?.getTime() === minute;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function loadConfig() {
|
|
82
|
+
return parseConfig(fs.readFileSync(configPath, 'utf8'), path.dirname(configPath));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function stamp() {
|
|
86
|
+
try {
|
|
87
|
+
const stats = fs.statSync(configPath);
|
|
88
|
+
return `${stats.mtimeMs}:${stats.size}`;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return `error:${error.code}`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Config identity: the user-authored fields, in file order. Only the Cron instance is
|
|
95
|
+
// dropped; the resolved cwd and timeoutSeconds are deterministic, so a real edit changes
|
|
96
|
+
// this but a re-save or touch of identical content does not.
|
|
97
|
+
function fingerprint(tasks) {
|
|
98
|
+
return JSON.stringify(tasks.map(({ timer, ...task }) => task));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Reload only when the file's stamp changes, and log only when the parsed config actually
|
|
102
|
+
// differs from the last good one: a re-save/touch with identical content stays silent.
|
|
103
|
+
// An unreadable or invalid file keeps the last good config and reports once; the stamp is
|
|
104
|
+
// consumed either way, so a broken file does not log every second. Already-triggered tasks
|
|
105
|
+
// stay deduplicated through runtime.lastTriggerAt.
|
|
106
|
+
function reload() {
|
|
107
|
+
const current = stamp();
|
|
108
|
+
if (current === configStamp) return;
|
|
109
|
+
configStamp = current;
|
|
110
|
+
try {
|
|
111
|
+
const next = loadConfig();
|
|
112
|
+
const nextFingerprint = fingerprint(next);
|
|
113
|
+
if (nextFingerprint === configFingerprint) return;
|
|
114
|
+
config = next;
|
|
115
|
+
configFingerprint = nextFingerprint;
|
|
116
|
+
console.log(JSON.stringify({ time: toLocalISO(new Date()), action: 'config reloaded', tasks: config.length }));
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error(JSON.stringify({ time: toLocalISO(new Date()), action: 'config reload failed', configPath, error: error.message }));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function save() {
|
|
123
|
+
const temp = `${runtimePath}.${process.pid}.tmp`;
|
|
124
|
+
fs.writeFileSync(temp, JSON.stringify(runtime, null, 2) + '\n');
|
|
125
|
+
fs.renameSync(temp, runtimePath);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function launch(task, slot) {
|
|
129
|
+
const state = runtime[task.id] = {
|
|
130
|
+
lastTriggerAt: slot, status: 'running', startedAt: toLocalISO(new Date())
|
|
131
|
+
};
|
|
132
|
+
save(); // Persist the claim BEFORE spawning; failed/interrupted claims are not retried.
|
|
133
|
+
console.log(JSON.stringify({ time: state.startedAt, task: task.id, status: 'starting', scheduledAt: slot }));
|
|
134
|
+
const child = spawn('pi', task.args, {
|
|
135
|
+
cwd: task.cwd,
|
|
136
|
+
env: { ...process.env, PI_SCHEDULED_RUN: '1' },
|
|
137
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
138
|
+
});
|
|
139
|
+
runningTaskIds.add(task.id);
|
|
140
|
+
let output = '', errorMessage, timedOut = false, finished = false;
|
|
141
|
+
const append = chunk => { output = (output + chunk.toString()).slice(-OUTPUT_LIMIT); };
|
|
142
|
+
child.stdout.setEncoding('utf8').on('data', append);
|
|
143
|
+
child.stderr.setEncoding('utf8').on('data', append);
|
|
144
|
+
child.on('error', error => { errorMessage = error.message; });
|
|
145
|
+
const timeout = setTimeout(() => {
|
|
146
|
+
if (!child.pid) return;
|
|
147
|
+
timedOut = true;
|
|
148
|
+
killTree(child.pid, 'SIGKILL', error => {
|
|
149
|
+
if (!error || finished) return;
|
|
150
|
+
state.error = `Termination failed: ${error.message}`;
|
|
151
|
+
try { save(); } catch (saveError) { console.error(saveError); }
|
|
152
|
+
fatal(new Error(JSON.stringify({ task: task.id, pid: child.pid, error: state.error })));
|
|
153
|
+
});
|
|
154
|
+
}, task.timeoutSeconds * 1000);
|
|
155
|
+
child.on('close', (code, signal) => {
|
|
156
|
+
finished = true;
|
|
157
|
+
clearTimeout(timeout);
|
|
158
|
+
runningTaskIds.delete(task.id);
|
|
159
|
+
let status = 'success';
|
|
160
|
+
if (code !== 0 || errorMessage) status = 'error';
|
|
161
|
+
if (timedOut) status = 'timeout';
|
|
162
|
+
Object.assign(state, {
|
|
163
|
+
status,
|
|
164
|
+
finishedAt: toLocalISO(new Date()), exitCode: code, signal,
|
|
165
|
+
output: errorMessage ? `${output}\n${errorMessage}`.slice(-OUTPUT_LIMIT) : output
|
|
166
|
+
});
|
|
167
|
+
try { save(); } catch (error) { fatal(error); }
|
|
168
|
+
console.log(JSON.stringify({ time: state.finishedAt, task: task.id, status, exitCode: code, signal, error: errorMessage }));
|
|
169
|
+
if (stopping && runningTaskIds.size === 0) lock?.close();
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function tick() {
|
|
174
|
+
reload();
|
|
175
|
+
const minute = Math.floor(Date.now() / 60000) * 60000;
|
|
176
|
+
const slot = toLocalISO(new Date(minute));
|
|
177
|
+
for (const task of config) {
|
|
178
|
+
if (!isDue(task, runtime[task.id]?.lastTriggerAt, minute)) continue;
|
|
179
|
+
if (runningTaskIds.has(task.id)) {
|
|
180
|
+
runtime[task.id].lastTriggerAt = slot; // Skip, don't queue an overlapping run.
|
|
181
|
+
save();
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
launch(task, slot);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function fatal(error) {
|
|
189
|
+
console.error(error);
|
|
190
|
+
process.exitCode = 1;
|
|
191
|
+
stop();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let interval, lock;
|
|
195
|
+
function stop() {
|
|
196
|
+
if (stopping) return;
|
|
197
|
+
stopping = true;
|
|
198
|
+
clearInterval(interval);
|
|
199
|
+
console.log(JSON.stringify({ action: 'stop scheduling; wait for active tasks', tasks: [...runningTaskIds] }));
|
|
200
|
+
if (runningTaskIds.size === 0) lock?.close();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function readRuntime() {
|
|
204
|
+
let states;
|
|
205
|
+
try { states = JSON.parse(fs.readFileSync(runtimePath, 'utf8')); } catch (error) {
|
|
206
|
+
if (error.code === 'ENOENT') return Object.create(null);
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
if (!states || Array.isArray(states) || typeof states !== 'object') throw new Error('Invalid runtime file');
|
|
210
|
+
for (const [id, state] of Object.entries(states)) {
|
|
211
|
+
const timestamp = state?.lastTriggerAt;
|
|
212
|
+
if (typeof timestamp !== 'string' || !RUNTIME_TIMESTAMP.test(timestamp) || !Number.isFinite(+new Date(timestamp))) {
|
|
213
|
+
throw new Error(`${id}: invalid runtime lastTriggerAt`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return Object.assign(Object.create(null), states);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function listTasks() {
|
|
220
|
+
const states = readRuntime();
|
|
221
|
+
console.log(`Timezone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}; times below are local; lastStatus is saved state, not a liveness check`);
|
|
222
|
+
for (const task of config) {
|
|
223
|
+
const state = states[task.id];
|
|
224
|
+
const next = task.enabled === false ? null : task.timer.nextRun(new Date());
|
|
225
|
+
console.log([
|
|
226
|
+
'',
|
|
227
|
+
task.name ?? task.id,
|
|
228
|
+
` id: ${task.id}`,
|
|
229
|
+
` enabled: ${task.enabled !== false} cron: ${task.cron}`,
|
|
230
|
+
` nextRun: ${next ? formatLocal(next) : '-'}`,
|
|
231
|
+
` lastStatus: ${state?.status ?? 'never'} lastRun: ${formatLocal(state?.startedAt)}`
|
|
232
|
+
].join('\n'));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function main() {
|
|
237
|
+
const options = parseArgs(process.argv.slice(2));
|
|
238
|
+
configPath = path.resolve(options.file);
|
|
239
|
+
runtimePath = path.join(path.dirname(configPath), 'tasks.runtime.json');
|
|
240
|
+
config = loadConfig();
|
|
241
|
+
configStamp = stamp();
|
|
242
|
+
configFingerprint = fingerprint(config);
|
|
243
|
+
if (options.command === 'ls') { listTasks(); return; }
|
|
244
|
+
// OS-managed singleton: no lock file or stale PID recovery.
|
|
245
|
+
lock = net.createServer(socket => socket.end());
|
|
246
|
+
await new Promise((resolve, reject) => {
|
|
247
|
+
lock.once('error', reject);
|
|
248
|
+
lock.listen(LOCK_PORT, '127.0.0.1', resolve);
|
|
249
|
+
});
|
|
250
|
+
runtime = readRuntime();
|
|
251
|
+
for (const state of Object.values(runtime)) {
|
|
252
|
+
if (state.status === 'running') state.status = 'unknown';
|
|
253
|
+
}
|
|
254
|
+
save();
|
|
255
|
+
process.on('SIGINT', stop);
|
|
256
|
+
process.on('SIGTERM', stop);
|
|
257
|
+
const safeTick = () => { try { tick(); } catch (error) { fatal(error); } };
|
|
258
|
+
console.log(`Polling ${configPath} every second (reloads when the file changes; local timezone; missed minutes skipped)`);
|
|
259
|
+
interval = setInterval(safeTick, POLL_MS);
|
|
260
|
+
safeTick();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// npm bins are symlinks on Unix; compare their resolved target, not the link path.
|
|
264
|
+
const entryPath = process.argv[1] && fs.existsSync(process.argv[1]) ? fs.realpathSync(process.argv[1]) : undefined;
|
|
265
|
+
if (entryPath === fileURLToPath(import.meta.url)) {
|
|
266
|
+
main().catch(error => {
|
|
267
|
+
console.error(JSON.stringify({ configPath, runtimePath, error: error.message }));
|
|
268
|
+
process.exitCode = 1;
|
|
269
|
+
lock?.close();
|
|
270
|
+
});
|
|
271
|
+
}
|
package/package.json
CHANGED
|
@@ -1,45 +1,45 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@arkkwang/pi-cron",
|
|
3
|
-
"type": "module",
|
|
4
|
-
"scripts": {
|
|
5
|
-
"start": "node cron.mjs start",
|
|
6
|
-
"ls": "node cron.mjs ls",
|
|
7
|
-
"test": "node --test cron.test.mjs"
|
|
8
|
-
},
|
|
9
|
-
"dependencies": {
|
|
10
|
-
"croner": "9.1.0",
|
|
11
|
-
"cross-spawn": "7.0.6",
|
|
12
|
-
"tree-kill": "1.2.2"
|
|
13
|
-
},
|
|
14
|
-
"version": "0.1.
|
|
15
|
-
"description": "Lightweight local scheduler for the Pi CLI",
|
|
16
|
-
"repository": {
|
|
17
|
-
"type": "git",
|
|
18
|
-
"url": "git+https://github.com/arkkwang/pi-cron.git"
|
|
19
|
-
},
|
|
20
|
-
"engines": {
|
|
21
|
-
"node": ">=22.19.0"
|
|
22
|
-
},
|
|
23
|
-
"license": "MIT",
|
|
24
|
-
"author": "arkkwang",
|
|
25
|
-
"files": [
|
|
26
|
-
"cron.mjs",
|
|
27
|
-
"pi-cron",
|
|
28
|
-
"README.zh-CN.md"
|
|
29
|
-
],
|
|
30
|
-
"publishConfig": {
|
|
31
|
-
"access": "public"
|
|
32
|
-
},
|
|
33
|
-
"homepage": "https://github.com/arkkwang/pi-cron#readme",
|
|
34
|
-
"bugs": {
|
|
35
|
-
"url": "https://github.com/arkkwang/pi-cron/issues"
|
|
36
|
-
},
|
|
37
|
-
"keywords": [
|
|
38
|
-
"pi-agent",
|
|
39
|
-
"ai",
|
|
40
|
-
"cli"
|
|
41
|
-
],
|
|
42
|
-
"bin": {
|
|
43
|
-
"pi-cron": "./cron.mjs"
|
|
44
|
-
}
|
|
45
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@arkkwang/pi-cron",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"start": "node cron.mjs start",
|
|
6
|
+
"ls": "node cron.mjs ls",
|
|
7
|
+
"test": "node --test cron.test.mjs npm-entry.test.mjs"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"croner": "9.1.0",
|
|
11
|
+
"cross-spawn": "7.0.6",
|
|
12
|
+
"tree-kill": "1.2.2"
|
|
13
|
+
},
|
|
14
|
+
"version": "0.1.1",
|
|
15
|
+
"description": "Lightweight local scheduler for the Pi CLI",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/arkkwang/pi-cron.git"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=22.19.0"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "arkkwang",
|
|
25
|
+
"files": [
|
|
26
|
+
"cron.mjs",
|
|
27
|
+
"pi-cron",
|
|
28
|
+
"README.zh-CN.md"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/arkkwang/pi-cron#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/arkkwang/pi-cron/issues"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"pi-agent",
|
|
39
|
+
"ai",
|
|
40
|
+
"cli"
|
|
41
|
+
],
|
|
42
|
+
"bin": {
|
|
43
|
+
"pi-cron": "./cron.mjs"
|
|
44
|
+
}
|
|
45
|
+
}
|