cron-up 26.9.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/LICENSE +21 -0
- package/README.md +196 -0
- package/bin/cron-up.mjs +16 -0
- package/package.json +32 -0
- package/src/cli.mjs +196 -0
- package/src/config.mjs +164 -0
- package/src/constants.mjs +57 -0
- package/src/cron.mjs +186 -0
- package/src/display.mjs +103 -0
- package/src/east-asian-width.mjs +40 -0
- package/src/index.mjs +13 -0
- package/src/internals.mjs +64 -0
- package/src/list.mjs +100 -0
- package/src/logs.mjs +75 -0
- package/src/overview.mjs +260 -0
- package/src/paths.mjs +45 -0
- package/src/patrol.mjs +396 -0
- package/src/registry.mjs +155 -0
- package/src/service.mjs +420 -0
- package/src/sessions.mjs +187 -0
- package/src/tasks.mjs +220 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// 全部调参常量集中一处,改动时便于通盘核对。
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_CONFIG = {
|
|
4
|
+
version: 1,
|
|
5
|
+
// 默认扫家目录(两层深覆盖 ~/<root>/<workspace>);安装时可用 roots 收窄。
|
|
6
|
+
roots: ['~'],
|
|
7
|
+
maxDepth: 2,
|
|
8
|
+
intervalSeconds: 300,
|
|
9
|
+
leadSeconds: 600,
|
|
10
|
+
// 每轮巡检给周期任务补 permanent 标记,绕开 Claude Code 的 7 天周期任务
|
|
11
|
+
// 过期。loadConfig 会用默认值补缺键,所以安装或升级后的首轮会把旧任务
|
|
12
|
+
// 全部补标。配置里 autoRenew:false 可关(下轮生效),或安装时
|
|
13
|
+
// --no-auto-renew。
|
|
14
|
+
autoRenew: true,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// 预热会话连续三次启动后死亡,大概率是环境坏了(缺信任、env 不对),退避
|
|
18
|
+
// 而不是每 5 分钟无限重拉。
|
|
19
|
+
export const FAIL_LIMIT = 3;
|
|
20
|
+
export const COOLDOWN_SECONDS = 1800;
|
|
21
|
+
// pid 合法值域。state.json 与会话登记两个外部来源都在入口处按它校验:
|
|
22
|
+
// 超大整数能穿过类型转换,直到 process.kill 才抛,所以必须入口卡范围。
|
|
23
|
+
export const PID_T_MAX = 2 ** 31 - 1;
|
|
24
|
+
// 健康会话约 10 秒内完成登记。超时仍存活却没登记,是卡在无人应答的提问
|
|
25
|
+
// 界面(目录信任、工具授权);只看存活会把它当健康,只有这个时限兜得住。
|
|
26
|
+
export const WARMUP_GRACE_SECONDS = 180;
|
|
27
|
+
// 预热会话是缓存不是宠物:「永久」任务让它无限常驻会把它钉死在启动当天的
|
|
28
|
+
// claude 版本和不断增长的 typescript 上。超龄会话仅在日志静默一段时间后
|
|
29
|
+
// 才退役——日志安静是没有任务在执行的证据,运行中的任务绝不会被切掉。
|
|
30
|
+
export const SESSION_MAX_AGE_SECONDS = 7 * 86400;
|
|
31
|
+
export const SESSION_IDLE_SECONDS = 3600;
|
|
32
|
+
// TUI typescript 是全屏渲染流,长驻时无限增长,拉起时轮转(script(1) 打开
|
|
33
|
+
// 即截断,所以轮转粒度是一个预热会话的生命周期)。
|
|
34
|
+
export const LOG_ROTATE_BYTES = 10 * 1024 * 1024;
|
|
35
|
+
// 巡检日志每轮几行,但跑几年的机器也该有上限。launchd 每次按路径重开,
|
|
36
|
+
// 轮初改名是安全的:本轮 fd 继续写改名后的 inode,下轮开新文件。
|
|
37
|
+
export const PATROL_LOG_ROTATE_BYTES = 1024 * 1024;
|
|
38
|
+
|
|
39
|
+
// 不进入这些目录:VCS/依赖内部与 macOS 家目录系统文件夹——agent 工作区不
|
|
40
|
+
// 会在里面,光一个 ~/Library 就足以让家目录扫描爆炸。
|
|
41
|
+
export const PRUNE_DIRS = new Set([
|
|
42
|
+
'.git', 'node_modules',
|
|
43
|
+
'Library', 'Applications', 'Desktop', 'Documents', 'Downloads',
|
|
44
|
+
'Movies', 'Music', 'Pictures', 'Public', '.Trash',
|
|
45
|
+
'.cache', '.npm', '.local', '.config',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// 定时任务 7 天过期(周期任务被调度器删除,一次性任务早就触发过),巡检
|
|
49
|
+
// 不需要看得更远。展示页另算。
|
|
50
|
+
export const SEARCH_DAYS = 7;
|
|
51
|
+
// list/总览回答「下次何时触发」,要看到一年后:年度任务必须显示真实日期,
|
|
52
|
+
// 不能误报成「无安排」。nextAfter 的日历快进让长窗口依然便宜。
|
|
53
|
+
export const DISPLAY_SEARCH_DAYS = 366;
|
|
54
|
+
// 补执行检测从任务创建时间向前搜,需要独立的上界。
|
|
55
|
+
export const MISSED_LOOKBACK_DAYS = 30;
|
|
56
|
+
// list 问「一次性任务是否被错过」,与巡检同一个补执行判定,只是不带前瞻。
|
|
57
|
+
export const ZERO_LEAD_MS = 0;
|
package/src/cron.mjs
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// 分钟级 cron 解析与下一次触发计算。字段全是别的程序写的,畸形表达式在
|
|
2
|
+
// 边界处返回 null/不可满足,绝不抛进巡检循环。
|
|
3
|
+
|
|
4
|
+
import { SEARCH_DAYS, MISSED_LOOKBACK_DAYS } from './constants.mjs';
|
|
5
|
+
|
|
6
|
+
const MS_DAY = 86400_000;
|
|
7
|
+
const MS_MINUTE = 60_000;
|
|
8
|
+
|
|
9
|
+
// 严格十进制整数:parseInt("5x") 会静默返回 5,必须自己卡。仅接受可选负号
|
|
10
|
+
// 加纯数字。
|
|
11
|
+
function strictInt(s) {
|
|
12
|
+
if (!/^-?\d+$/.test(s)) throw new Error(`不是整数:${s}`);
|
|
13
|
+
return parseInt(s, 10);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 解析一个 cron 字段为它命名的合法值域集合。
|
|
17
|
+
// 越界值丢弃而非报错:typo "99" 读作「该字段不匹配任何值」(由
|
|
18
|
+
// satisfiable 浮出),而不是炸掉解析或静默匹配到不该匹配的分钟。
|
|
19
|
+
export function parseCronField(expr, lo, hi) {
|
|
20
|
+
const values = new Set();
|
|
21
|
+
for (const token of expr.split(',')) {
|
|
22
|
+
let rng;
|
|
23
|
+
let step;
|
|
24
|
+
const slash = token.indexOf('/');
|
|
25
|
+
if (slash >= 0) {
|
|
26
|
+
rng = token.slice(0, slash);
|
|
27
|
+
step = strictInt(token.slice(slash + 1)); // "/0" 必须抛:步进 0 会死循环
|
|
28
|
+
if (step === 0) throw new Error('cron 步进不能为 0');
|
|
29
|
+
} else {
|
|
30
|
+
rng = token;
|
|
31
|
+
step = 1;
|
|
32
|
+
}
|
|
33
|
+
let start;
|
|
34
|
+
let end;
|
|
35
|
+
if (rng === '*') {
|
|
36
|
+
start = lo;
|
|
37
|
+
end = hi;
|
|
38
|
+
} else if (rng.includes('-')) {
|
|
39
|
+
const [a, b] = rng.split('-');
|
|
40
|
+
start = strictInt(a);
|
|
41
|
+
end = strictInt(b);
|
|
42
|
+
} else {
|
|
43
|
+
start = end = strictInt(rng);
|
|
44
|
+
if (slash >= 0) end = hi; // vixie:"5/10" 表示 5,15,25,… 直到 hi
|
|
45
|
+
}
|
|
46
|
+
for (let v = start; v <= end; v += step) {
|
|
47
|
+
if (v >= lo && v <= hi) values.add(v);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return values;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class Cron {
|
|
54
|
+
constructor(expr) {
|
|
55
|
+
if (typeof expr !== 'string') throw new Error('cron 表达式必须是字符串');
|
|
56
|
+
const parts = expr.trim().split(/\s+/); // 折叠任意空白
|
|
57
|
+
if (parts.length !== 5) throw new Error(`cron 字段数应为 5:${expr}`);
|
|
58
|
+
const [m, h, dom, mon, dow] = parts;
|
|
59
|
+
this.minutes = parseCronField(m, 0, 59);
|
|
60
|
+
this.hours = parseCronField(h, 0, 23);
|
|
61
|
+
this.doms = parseCronField(dom, 1, 31);
|
|
62
|
+
this.months = parseCronField(mon, 1, 12);
|
|
63
|
+
// 先在 0..7 范围解析(0 和 7 都表示周日)再折叠。折叠必须发生在集合
|
|
64
|
+
// 展开之后:文本上把 "7" 替成 "0" 会把正常区间 "1-7" 变成反向的
|
|
65
|
+
// "1-0",解析为空集后任务静默永不触发。
|
|
66
|
+
this.dows = new Set([...parseCronField(dow, 0, 7)].map((v) => v % 7));
|
|
67
|
+
this.domRestricted = dom !== '*';
|
|
68
|
+
this.dowRestricted = dow !== '*';
|
|
69
|
+
// 每字段非空只是必要条件(2 月 30 日依旧永不匹配);这个标记只识别
|
|
70
|
+
// 任何一分钟都不可能匹配的表达式,它们值得一句响亮的「cron 无效」,
|
|
71
|
+
// 而不是看起来像「没有安排」。
|
|
72
|
+
this.satisfiable = Boolean(
|
|
73
|
+
this.minutes.size && this.hours.size && this.doms.size
|
|
74
|
+
&& this.months.size && this.dows.size,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Date 的 getDay() 本身就是周日=0,与 cron 同构,无需额外换算。
|
|
79
|
+
matches(t) {
|
|
80
|
+
if (!this.minutes.has(t.getMinutes()) || !this.hours.has(t.getHours())) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
if (!this.months.has(t.getMonth() + 1)) return false;
|
|
84
|
+
if (this.domRestricted && this.dowRestricted) {
|
|
85
|
+
// DoM 与 DoW 同时受限时是 vixie 的 OR 语义:命中其一即可。
|
|
86
|
+
return this.doms.has(t.getDate()) || this.dows.has(t.getDay());
|
|
87
|
+
}
|
|
88
|
+
return this.doms.has(t.getDate()) && this.dows.has(t.getDay());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 严格晚于 after 的下一次触发;withinDays 内没有则 null。不可满足的表达
|
|
92
|
+
// 式也返回 null。
|
|
93
|
+
nextAfter(after, withinDays = SEARCH_DAYS) {
|
|
94
|
+
if (!this.satisfiable) return null;
|
|
95
|
+
const t = new Date(after.getTime());
|
|
96
|
+
t.setSeconds(0, 0);
|
|
97
|
+
t.setMinutes(t.getMinutes() + 1);
|
|
98
|
+
const deadline = after.getTime() + withinDays * MS_DAY;
|
|
99
|
+
while (t.getTime() <= deadline) {
|
|
100
|
+
// 日历快进:跳过不可能匹配的整月、整小时,再逐分钟扫描。跳过的都
|
|
101
|
+
// 是 matches 会拒绝的时刻,结果与朴素扫描完全一致;一年窗口从约
|
|
102
|
+
// 5·10⁵ 次迭代降到 ~10⁴,DISPLAY_SEARCH_DAYS 因此可行。
|
|
103
|
+
if (!this.months.has(t.getMonth() + 1)) {
|
|
104
|
+
t.setTime(new Date(t.getFullYear(), t.getMonth() + 1, 1).getTime());
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!this.hours.has(t.getHours())) {
|
|
108
|
+
t.setHours(t.getHours() + 1, 0, 0, 0);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (this.matches(t)) return t;
|
|
112
|
+
t.setMinutes(t.getMinutes() + 1);
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// CronCreate 产生的一次性任务形状像 'M H DoM Mon *',作为没有 recurring
|
|
119
|
+
// 标志时的兜底识别法。
|
|
120
|
+
export function cronLooksOneshot(expr) {
|
|
121
|
+
const parts = expr.split(/\s+/);
|
|
122
|
+
return parts.length === 5 && parts[4] === '*'
|
|
123
|
+
&& parts.slice(0, 4).every((p) => /^\d+$/.test(p));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function taskIsOneshot(task) {
|
|
127
|
+
// 调度器会写显式 recurring 标志,优先信它;手写任务文件才退回形状识别。
|
|
128
|
+
if ('recurring' in task) return !Boolean(task.recurring);
|
|
129
|
+
try {
|
|
130
|
+
return cronLooksOneshot(task.cron);
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 解析 cron;任何畸形都返回 null,不把异常抛给调用方。展示页与巡检共用
|
|
137
|
+
// 这一个入口,两边不必各自抄一遍异常元组。
|
|
138
|
+
export function parseCronOrNone(expr) {
|
|
139
|
+
try {
|
|
140
|
+
return new Cron(expr);
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function createdAtDate(task) {
|
|
147
|
+
const v = task.createdAt;
|
|
148
|
+
if (!v || typeof v !== 'number') return null;
|
|
149
|
+
const d = new Date(v); // 字段是毫秒时间戳
|
|
150
|
+
return Number.isFinite(d.getTime()) ? d : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 在已解析的 cron 上判定:这个任务现在是否需要一个已预热的会话。
|
|
154
|
+
function wanted(cron, task, now, leadMs) {
|
|
155
|
+
const nxt = cron.nextAfter(now);
|
|
156
|
+
if (nxt && nxt.getTime() <= now.getTime() + leadMs) return true;
|
|
157
|
+
// 首次触发已过却仍在文件里:一次性任务意味着没有 REPL 运行时被错过
|
|
158
|
+
// (重启/睡眠)。原生机制会在会话启动时补执行一次再删除它。
|
|
159
|
+
if (taskIsOneshot(task)) {
|
|
160
|
+
const created = createdAtDate(task);
|
|
161
|
+
if (!created) return false;
|
|
162
|
+
// 从创建时间向前搜,而不是从现在。跨度要封顶:表达式永不匹配的陈旧
|
|
163
|
+
// 条目否则会逐分钟扫到创建之初。一次性任务都在触发前不久创建,封顶
|
|
164
|
+
// 不会漏掉真实条目,超过上界的早就死透了。
|
|
165
|
+
const span = Math.min(
|
|
166
|
+
Math.floor((now.getTime() - created.getTime()) / MS_DAY) + 1,
|
|
167
|
+
MISSED_LOOKBACK_DAYS,
|
|
168
|
+
);
|
|
169
|
+
const first = cron.nextAfter(
|
|
170
|
+
new Date(created.getTime() - MS_MINUTE),
|
|
171
|
+
span,
|
|
172
|
+
);
|
|
173
|
+
if (first && first.getTime() <= now.getTime()) return true;
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 任务是否需要已预热的会话。任务字段全是别的程序写的,畸形条目读作「不
|
|
179
|
+
// 需要」,绝不能让一个坏条目中断整轮巡检。
|
|
180
|
+
export function taskWanted(task, now, leadMs) {
|
|
181
|
+
const cron = parseCronOrNone(task?.cron);
|
|
182
|
+
return cron !== null && wanted(cron, task, now, leadMs);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// wanted 供展示视图复用(同一个已解析 cron 顺带回答巡检问题)。
|
|
186
|
+
export { wanted };
|
package/src/display.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// 等宽终端展示原语与任务的展示视图。
|
|
2
|
+
|
|
3
|
+
import { isWide } from './east-asian-width.mjs';
|
|
4
|
+
import { parseCronOrNone, taskIsOneshot, wanted } from './cron.mjs';
|
|
5
|
+
import { DISPLAY_SEARCH_DAYS, ZERO_LEAD_MS } from './constants.mjs';
|
|
6
|
+
|
|
7
|
+
// 等宽终端里的显示宽度:东亚宽字符(中文、全角标点)占两列。
|
|
8
|
+
export function dispWidth(s) {
|
|
9
|
+
let n = 0;
|
|
10
|
+
for (const ch of String(s)) n += isWide(ch.codePointAt(0)) ? 2 : 1;
|
|
11
|
+
return n;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 按显示宽度截断,尾部用省略号 … 占位。
|
|
15
|
+
export function clip(s, width) {
|
|
16
|
+
width = Math.max(0, width);
|
|
17
|
+
s = String(s);
|
|
18
|
+
if (dispWidth(s) <= width) return s;
|
|
19
|
+
// 省略号 U+2026 在多数终端占 1 列(Ambiguous),按 2 列预留只是更保守,
|
|
20
|
+
// 结果总宽保证不超过 width。
|
|
21
|
+
const keep = width - 2;
|
|
22
|
+
let out = '';
|
|
23
|
+
let used = 0;
|
|
24
|
+
for (const ch of s) {
|
|
25
|
+
const w = isWide(ch.codePointAt(0)) ? 2 : 1;
|
|
26
|
+
if (used + w > keep) break;
|
|
27
|
+
out += ch;
|
|
28
|
+
used += w;
|
|
29
|
+
}
|
|
30
|
+
return `${out}…`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function pad(s, width) {
|
|
34
|
+
return s + ' '.repeat(Math.max(0, width - dispWidth(s)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 倒计时的粗略中文说法,只用于总览页。ms 为非负毫秒差。
|
|
38
|
+
export function humanDelta(ms) {
|
|
39
|
+
const secs = Math.max(0, Math.floor(ms / 1000));
|
|
40
|
+
if (secs < 60) return '不到 1 分钟';
|
|
41
|
+
if (secs < 3600) return `${Math.floor(secs / 60)} 分钟后`;
|
|
42
|
+
if (secs < 86400) return `${Math.floor(secs / 3600)} 小时后`;
|
|
43
|
+
return `${Math.floor(secs / 86400)} 天后`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// list/总览上代表任务身份的一句话:prompt 的首个非空行。真实任务第一行
|
|
47
|
+
// 通常自带标题;取不到时如实标注,不留空白格。
|
|
48
|
+
export function taskSummary(task) {
|
|
49
|
+
const prompt = task?.prompt;
|
|
50
|
+
if (typeof prompt !== 'string' || !prompt.trim()) return '(无任务描述)';
|
|
51
|
+
const first = prompt.split('\n').map((l) => l.trim()).find((l) => l) ?? '';
|
|
52
|
+
return first.replace(/\s+/g, ' ');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 把一个任务整理成展示数据。字段来自别的程序写的文件,畸形也不抛。
|
|
56
|
+
// 传 leadMs 时顺带在同一个已解析 cron 上回答「现在是否需要预热会话」
|
|
57
|
+
// (wanted),总览页就不必为同一任务再解析一遍 cron。
|
|
58
|
+
export function taskView(task, now, leadMs = undefined) {
|
|
59
|
+
const expr = task?.cron;
|
|
60
|
+
const cron = parseCronOrNone(expr);
|
|
61
|
+
if (cron === null || !cron.satisfiable) {
|
|
62
|
+
// 解析失败,或字段越界/反向区间导致集合为空:任何分钟都不可能匹配。
|
|
63
|
+
// 区别于「合法但展示窗口内没有触发点」(如 2 月 30 日,解析正常却
|
|
64
|
+
// 永不触发)。
|
|
65
|
+
return {
|
|
66
|
+
valid: false,
|
|
67
|
+
expr,
|
|
68
|
+
summary: taskSummary(task),
|
|
69
|
+
permanent: false,
|
|
70
|
+
wanted: null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const nxt = cron.nextAfter(now, DISPLAY_SEARCH_DAYS);
|
|
74
|
+
const oneshot = taskIsOneshot(task);
|
|
75
|
+
// nxt 按定义严格晚于 now,「已错过」完全由补执行判定回答。
|
|
76
|
+
const missed = oneshot && wanted(cron, task, now, ZERO_LEAD_MS);
|
|
77
|
+
return {
|
|
78
|
+
valid: true,
|
|
79
|
+
nxt,
|
|
80
|
+
missed,
|
|
81
|
+
kind: oneshot ? '一次性' : '周期',
|
|
82
|
+
permanent: Boolean(task.permanent) && !oneshot,
|
|
83
|
+
cadence: typeof expr === 'string' ? expr : String(expr),
|
|
84
|
+
summary: taskSummary(task),
|
|
85
|
+
// wanted 恒为 null 或布尔:没传 leadMs(不问)与 cron 无效(问不了)
|
|
86
|
+
// 都是 null,键的形状不随参数变化。
|
|
87
|
+
wanted: leadMs !== undefined ? wanted(cron, task, now, leadMs) : null,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// MM-DD HH:MM(任务触发点的统一展示格式)。
|
|
92
|
+
export function fmtMDHM(d) {
|
|
93
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
94
|
+
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:`
|
|
95
|
+
+ `${p(d.getMinutes())}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 会话年龄的粗粒度中文:长驻按天计,否则按分钟。
|
|
99
|
+
export function sessionAgeZh(startedAt, cur) {
|
|
100
|
+
const secs = Math.max(0, cur - startedAt);
|
|
101
|
+
if (secs >= 86400) return `${Math.floor(secs / 86400)} 天`;
|
|
102
|
+
return `${Math.floor(secs / 60)} 分钟`;
|
|
103
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// 本文件由 scripts/gen-east-asian-width.mjs 生成,请勿手改。
|
|
2
|
+
// 数据:Unicode East_Asian_Width 的 W/F 码点区间(Unicode 18.0.0,2026-06-29, 15:25:05 GMT)。
|
|
3
|
+
// Ambiguous 字符(如 U+2026 省略号)不在表中,按 1 列计。
|
|
4
|
+
// 共 126 个连续区间,二分查找。
|
|
5
|
+
|
|
6
|
+
const WIDE_RANGES = [
|
|
7
|
+
[4352,4447],[8986,8987],[9001,9002],[9193,9196],[9200],[9203],
|
|
8
|
+
[9725,9726],[9748,9749],[9776,9783],[9800,9811],[9855],[9866,9871],
|
|
9
|
+
[9875],[9889],[9898,9899],[9917,9918],[9924,9925],[9934],
|
|
10
|
+
[9940],[9962],[9970,9971],[9973],[9978],[9981],
|
|
11
|
+
[9989],[9994,9995],[10024],[10060],[10062],[10067,10069],
|
|
12
|
+
[10071],[10133,10135],[10160],[10175],[11035,11036],[11088],
|
|
13
|
+
[11093],[11904,11929],[11931,12019],[12032,12245],[12272,12350],[12353,12438],
|
|
14
|
+
[12441,12543],[12549,12591],[12593,12686],[12688,12773],[12783,12830],[12832,12871],
|
|
15
|
+
[12880,42124],[42128,42182],[43360,43388],[44032,55203],[63744,64255],[65040,65049],
|
|
16
|
+
[65072,65106],[65108,65126],[65128,65131],[65281,65376],[65504,65510],[94176,94180],
|
|
17
|
+
[94192,94198],[94208,101594],[101631,101664],[101760,101874],[101888,102801],[102816,102866],
|
|
18
|
+
[110576,110579],[110581,110587],[110589,110590],[110592,110888],[110898],[110928,110930],
|
|
19
|
+
[110933],[110948,110952],[110960,111355],[119552,119638],[119648,119670],[126980],
|
|
20
|
+
[127183],[127374],[127377,127386],[127406],[127488,127490],[127504,127547],
|
|
21
|
+
[127552,127560],[127568,127569],[127584,127589],[127744,127776],[127789,127797],[127799,127868],
|
|
22
|
+
[127870,127891],[127904,127946],[127951,127955],[127968,127984],[127988],[127992,128062],
|
|
23
|
+
[128064],[128066,128252],[128255,128317],[128331,128334],[128336,128359],[128378],
|
|
24
|
+
[128405,128406],[128420],[128507,128591],[128640,128709],[128716],[128720,128722],
|
|
25
|
+
[128725,128729],[128732,128735],[128747,128748],[128756,128764],[128986],[128992,129003],
|
|
26
|
+
[129008],[129292,129338],[129340,129349],[129351,129535],[129648,129660],[129664,129734],
|
|
27
|
+
[129736],[129740,129757],[129759,129771],[129775,129786],[131072,196605],[196608,262141]
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export function isWide(cp) {
|
|
31
|
+
let lo = 0, hi = WIDE_RANGES.length - 1;
|
|
32
|
+
while (lo <= hi) {
|
|
33
|
+
const mid = (lo + hi) >> 1;
|
|
34
|
+
const [a, b] = WIDE_RANGES[mid];
|
|
35
|
+
if (cp < a) hi = mid - 1;
|
|
36
|
+
else if (cp > (b ?? a)) lo = mid + 1;
|
|
37
|
+
else return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// 全量副作用入口:import 全部业务模块,触发它们向 deps 容器自注册。CLI 与
|
|
2
|
+
// 测试都先 import 这里,保证 deps 上的可替换函数一个不缺(internals 不反向
|
|
3
|
+
// 依赖业务模块,单独 import 某个模块时其同侪可能尚未注册)。
|
|
4
|
+
|
|
5
|
+
import './tasks.mjs';
|
|
6
|
+
import './config.mjs';
|
|
7
|
+
import './sessions.mjs';
|
|
8
|
+
import './registry.mjs';
|
|
9
|
+
import './patrol.mjs';
|
|
10
|
+
import './service.mjs';
|
|
11
|
+
import './logs.mjs';
|
|
12
|
+
import './overview.mjs';
|
|
13
|
+
import './list.mjs';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// 可替换依赖容器:ESM 命名导出不可改,测试无法事后打桩,因此把所有要替换
|
|
2
|
+
// 的外部依赖集中到 deps。业务模块在文件末尾 Object.assign(deps, {...})
|
|
3
|
+
// 自注册(internals 不反向 import 业务模块,避免循环依赖);cli 先 import
|
|
4
|
+
// 全量入口保证注册完成后再 dispatch。
|
|
5
|
+
//
|
|
6
|
+
// 纯系统叶子的默认实现放这里;业务函数(discover、spawnSession…)由各自
|
|
7
|
+
// 模块注册。
|
|
8
|
+
|
|
9
|
+
import { spawn, spawnSync, execFileSync } from 'node:child_process';
|
|
10
|
+
import { paths } from './paths.mjs';
|
|
11
|
+
|
|
12
|
+
export class ExitError extends Error {
|
|
13
|
+
// CLI 入口捕获后按 code 退出。
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'ExitError';
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 非 0 退出不抛:调用 ps/launchctl 的地方只关心 status 与输出,抛了反而
|
|
22
|
+
// 要在每个调用点 catch。返回 {status, stdout, stderr}。
|
|
23
|
+
function execFile(file, args, opts = {}) {
|
|
24
|
+
try {
|
|
25
|
+
const stdout = execFileSync(file, args, {
|
|
26
|
+
// execFileSync 的默认 stdio 会让非零退出子进程的 stderr 继承父进程
|
|
27
|
+
// (launchctl print 未加载服务时错误会直接糊到终端),显式 pipe 住。
|
|
28
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
29
|
+
...opts,
|
|
30
|
+
encoding: 'utf-8',
|
|
31
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
32
|
+
});
|
|
33
|
+
return { status: 0, stdout: stdout ?? '', stderr: '' };
|
|
34
|
+
} catch (e) {
|
|
35
|
+
// launchctl bootout 找不到服务等:exit 非零但输出在 stderr。
|
|
36
|
+
if (e && typeof e === 'object' && 'status' in e) {
|
|
37
|
+
return {
|
|
38
|
+
status: e.status ?? 1,
|
|
39
|
+
stdout: typeof e.stdout === 'string' ? e.stdout : '',
|
|
40
|
+
stderr: typeof e.stderr === 'string' ? e.stderr : '',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (e && typeof e === 'object' && (e.code === 'ENOENT' || e.code === 'EACCES'
|
|
44
|
+
|| e.code === 'ETIMEDOUT')) {
|
|
45
|
+
return { status: 1, stdout: '', stderr: e.message ?? String(e) };
|
|
46
|
+
}
|
|
47
|
+
throw e;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function terminalWidth() {
|
|
52
|
+
return Math.max(60, process.stdout.columns ?? 100);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const deps = {
|
|
56
|
+
paths,
|
|
57
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
58
|
+
print: (...args) => process.stdout.write(`${args.join(' ')}\n`),
|
|
59
|
+
printErr: (...args) => process.stderr.write(`${args.join(' ')}\n`),
|
|
60
|
+
terminalWidth,
|
|
61
|
+
execFile,
|
|
62
|
+
spawn,
|
|
63
|
+
spawnSync,
|
|
64
|
+
};
|
package/src/list.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// `cron-up list`:逐工作区列出任务的下次触发、节奏、类型与摘要,标注
|
|
2
|
+
// 该目录当前有没有交互会话。
|
|
3
|
+
|
|
4
|
+
import { deps } from './internals.mjs';
|
|
5
|
+
import { loadConfig } from './config.mjs';
|
|
6
|
+
import {
|
|
7
|
+
clip,
|
|
8
|
+
pad,
|
|
9
|
+
dispWidth,
|
|
10
|
+
taskView,
|
|
11
|
+
fmtMDHM,
|
|
12
|
+
} from './display.mjs';
|
|
13
|
+
|
|
14
|
+
function rank(v) {
|
|
15
|
+
if (!v.valid) return 3;
|
|
16
|
+
if (v.missed) return 0;
|
|
17
|
+
return v.nxt ? 1 : 2;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function cmdList(args) {
|
|
21
|
+
const cfg = loadConfig(args?.config ?? deps.paths.configPath);
|
|
22
|
+
const now = new Date();
|
|
23
|
+
const cols = deps.terminalWidth();
|
|
24
|
+
const blocks = [];
|
|
25
|
+
let total = 0;
|
|
26
|
+
let filesSeen = 0;
|
|
27
|
+
let unreadable = 0;
|
|
28
|
+
const [consumers] = await deps.scanSessions();
|
|
29
|
+
for (const [ws] of deps.discover(cfg.roots, cfg.maxDepth)) {
|
|
30
|
+
filesSeen += 1;
|
|
31
|
+
const tasks = deps.readTasks(ws);
|
|
32
|
+
if (tasks === null) {
|
|
33
|
+
// 读不出不等于空:损坏时如实说任务不会执行。
|
|
34
|
+
unreadable += 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (tasks.length === 0) continue;
|
|
38
|
+
const views = tasks.map((t) => taskView(t, now));
|
|
39
|
+
// 错过待补执行排最前,然后按下次时间升序,一年内无安排的垫底,无效
|
|
40
|
+
// 的最后。
|
|
41
|
+
views.sort((a, b) => rank(a) - rank(b)
|
|
42
|
+
|| ((a.nxt?.getTime() ?? Infinity) - (b.nxt?.getTime() ?? Infinity)));
|
|
43
|
+
blocks.push([ws, consumers.has(ws), views]);
|
|
44
|
+
total += tasks.length;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (blocks.length === 0) {
|
|
48
|
+
if (filesSeen === 0) {
|
|
49
|
+
deps.print('配置的扫描目录下没有发现定时任务文件');
|
|
50
|
+
} else if (unreadable === filesSeen) {
|
|
51
|
+
deps.print(`发现 ${filesSeen} 个任务文件,全部读不出:其中的定时任务一个`
|
|
52
|
+
+ '都不会执行');
|
|
53
|
+
} else {
|
|
54
|
+
const tail = unreadable ? `,其中 ${unreadable} 个读不出` : '';
|
|
55
|
+
deps.print(`发现 ${filesSeen} 个任务文件${tail},其余的任务列表均为空`);
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
blocks.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
61
|
+
deps.print(`共 ${blocks.length} 个工作区、${total} 个定时任务`);
|
|
62
|
+
|
|
63
|
+
// 列宽从全部候选文案动态算(时间戳 "09-25 08:30" 恒为 11 列),改文案
|
|
64
|
+
// 不会静默错位;cadence 是截断列,取固定上限。
|
|
65
|
+
const statusW = Math.max(11,
|
|
66
|
+
...['cron 无效', '已错过', '一年内无'].map(dispWidth));
|
|
67
|
+
const kindW = Math.max(...['一次性', '周期', '周期·永久'].map(dispWidth)) + 1;
|
|
68
|
+
const cadW = 16;
|
|
69
|
+
// " " + status + " " + kind + " " + cadence + " " + summary
|
|
70
|
+
const summaryW = Math.max(12,
|
|
71
|
+
cols - (2 + statusW + 2 + kindW + 2 + cadW + 2));
|
|
72
|
+
for (const [ws, consumer, views] of blocks) {
|
|
73
|
+
deps.print('');
|
|
74
|
+
const label = consumer ? '交互会话:有' : '交互会话:无';
|
|
75
|
+
const pathW = cols - dispWidth(label) - 1;
|
|
76
|
+
deps.print(`${pad(clip(ws, pathW), pathW)} ${label}`);
|
|
77
|
+
for (const v of views) {
|
|
78
|
+
let status;
|
|
79
|
+
let kind;
|
|
80
|
+
let cadence;
|
|
81
|
+
let summary;
|
|
82
|
+
if (!v.valid) {
|
|
83
|
+
status = 'cron 无效';
|
|
84
|
+
kind = '—';
|
|
85
|
+
cadence = '—';
|
|
86
|
+
summary = `原字段值:${JSON.stringify(v.expr)};${v.summary}`;
|
|
87
|
+
} else {
|
|
88
|
+
// 已错过:一次性任务触发点已过,下次会话启动时补执行。missed 时
|
|
89
|
+
// permanent 恒为 false(taskView 对一次性任务剔除了该标记)。
|
|
90
|
+
status = v.missed ? '已错过'
|
|
91
|
+
: (v.nxt ? fmtMDHM(v.nxt) : '一年内无');
|
|
92
|
+
kind = v.permanent ? '周期·永久' : v.kind;
|
|
93
|
+
cadence = v.cadence;
|
|
94
|
+
summary = v.summary;
|
|
95
|
+
}
|
|
96
|
+
deps.print(' ' + pad(status, statusW) + ' ' + pad(kind, kindW) + ' '
|
|
97
|
+
+ pad(clip(cadence, cadW), cadW) + ' ' + clip(summary, summaryW));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/logs.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// `cron-up logs`:选日志文件并交给 tail。默认看巡检日志(它回答「最
|
|
2
|
+
// 近怎么样」),stderr 只是崩溃收集处,不能因为一次旧故障盖住此后所有健
|
|
3
|
+
// 康的巡检轮次。
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { deps, ExitError } from './internals.mjs';
|
|
9
|
+
import { onDiskCase } from './tasks.mjs';
|
|
10
|
+
import { sessionLogPath } from './sessions.mjs';
|
|
11
|
+
|
|
12
|
+
export function resolveLogPath(workspace, announce) {
|
|
13
|
+
const say = announce ?? ((m) => deps.printErr(m));
|
|
14
|
+
if (workspace) {
|
|
15
|
+
if (path.isAbsolute(workspace)) {
|
|
16
|
+
// 日志名由工作区路径折成,而 spawn 记的是 discover 规范化后的真实
|
|
17
|
+
// 大小写:敲错大小写的绝对路径必须认回同一个文件。
|
|
18
|
+
let real;
|
|
19
|
+
try {
|
|
20
|
+
real = fs.realpathSync(workspace);
|
|
21
|
+
} catch {
|
|
22
|
+
real = workspace;
|
|
23
|
+
}
|
|
24
|
+
const file = sessionLogPath(onDiskCase(real));
|
|
25
|
+
return fs.existsSync(file) ? file : null;
|
|
26
|
+
}
|
|
27
|
+
// 只匹配当前日志(*.log):.log.1 若参与匹配,轮转过一次的工作区会永
|
|
28
|
+
// 远被报成「匹配到多个」。
|
|
29
|
+
let matches = [];
|
|
30
|
+
try {
|
|
31
|
+
matches = fs.readdirSync(deps.paths.sessionLogDir)
|
|
32
|
+
.filter((f) => f.endsWith('.log'))
|
|
33
|
+
.map((f) => path.join(deps.paths.sessionLogDir, f))
|
|
34
|
+
.filter((f) => path.basename(f).includes(workspace));
|
|
35
|
+
} catch {
|
|
36
|
+
matches = [];
|
|
37
|
+
}
|
|
38
|
+
if (matches.length > 1) {
|
|
39
|
+
say(`'${workspace}' 匹配到多个会话日志,请给出更长的片段:`);
|
|
40
|
+
for (const f of matches.sort()) say(` ${path.basename(f)}`);
|
|
41
|
+
throw new ExitError(1);
|
|
42
|
+
}
|
|
43
|
+
return matches[0] ?? null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const out = path.join(deps.paths.logDir, 'launchd.out.log');
|
|
47
|
+
const err = path.join(deps.paths.logDir, 'launchd.err.log');
|
|
48
|
+
if (!fs.existsSync(out)) return err;
|
|
49
|
+
try {
|
|
50
|
+
if (fs.statSync(err).size > 0) {
|
|
51
|
+
const mtime = fs.statSync(err).mtime;
|
|
52
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
53
|
+
const stamp = `${p(mtime.getMonth() + 1)}-${p(mtime.getDate())} `
|
|
54
|
+
+ `${p(mtime.getHours())}:${p(mtime.getMinutes())}`;
|
|
55
|
+
say(`注:${err} 有内容(最后写入 ${stamp}),巡检若异常先看它`);
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// err 不存在:无事。
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function cmdLogs(args) {
|
|
64
|
+
const file = resolveLogPath(args.workspace);
|
|
65
|
+
if (!file) {
|
|
66
|
+
deps.printErr(`在 ${deps.paths.sessionLogDir} 中找不到与`
|
|
67
|
+
+ `「${args.workspace}」匹配的会话日志`);
|
|
68
|
+
throw new ExitError(1);
|
|
69
|
+
}
|
|
70
|
+
const tailArgs = args.follow ? ['-f'] : ['-n', '100'];
|
|
71
|
+
tailArgs.push(file);
|
|
72
|
+
const r = deps.spawnSync('/usr/bin/tail', tailArgs, { stdio: 'inherit' });
|
|
73
|
+
if (r.error) throw r.error;
|
|
74
|
+
if (r.status && r.status !== 0) throw new ExitError(r.status);
|
|
75
|
+
}
|