cron-up 26.9.1 → 26.9.3
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 +13 -19
- package/package.json +2 -2
- package/src/config.mjs +43 -15
- package/src/constants.mjs +0 -3
- package/src/cron.mjs +5 -8
- package/src/overview.mjs +5 -22
- package/src/paths.mjs +6 -1
- package/src/patrol.mjs +2 -8
- package/src/service.mjs +120 -28
- package/src/tasks.mjs +1 -3
package/README.md
CHANGED
|
@@ -48,17 +48,7 @@ cron-up install --roots ~/code,~/work --interval 300 --lead 600
|
|
|
48
48
|
|
|
49
49
|
`--roots` 填写存放 agent 工作目录的父目录(逗号分隔)。不填时默认扫描家目录下两层以内(`~/` 自身、`~/*` 与 `~/*/*`),并自动跳过 `Library`、`Documents` 等系统目录和 `.git`、`node_modules`、`.cache` 等目录;建议显式指定,范围越小越安全、越快。使用默认范围安装时 install 会再打印一次安全提示:今后 clone 进家目录的任何仓库都会进入巡检,其中的周期任务会被自动续期永久化(原理见「安全说明」)。
|
|
50
50
|
|
|
51
|
-
launchd 的环境变量极少(PATH 只有 `/usr/bin:/bin:/usr/sbin:/sbin`),npm 全局命令那个 `#!/usr/bin/env node` 壳在它下面找不到 node。因此安装时会按当前机器的 Node 安装方式(fnm 的 `aliases/default`、volta 的 `~/.volta`、nvm 的 default 别名、Homebrew/官网路径)解析出**版本无关的 node
|
|
52
|
-
|
|
53
|
-
### 从旧版 doorman 迁移
|
|
54
|
-
|
|
55
|
-
```bash
|
|
56
|
-
doorman uninstall --keep-sessions # 卸掉旧的 Python 版 LaunchAgent(会话可保留)
|
|
57
|
-
npm i -g cron-up
|
|
58
|
-
cron-up install # 新服务接管;配置从头生成即可
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
新旧 label 不同(`local.doorman` / `local.cron-up`),安装新版时也会顺手 best-effort 踢掉旧服务,避免两个巡检并行。旧的 `~/Library/Application Support/doorman` 等目录不会被自动删除,确认无误后可手动清掉。
|
|
51
|
+
launchd 的环境变量极少(PATH 只有 `/usr/bin:/bin:/usr/sbin:/sbin`),npm 全局命令那个 `#!/usr/bin/env node` 壳在它下面找不到 node。因此安装时会按当前机器的 Node 安装方式(fnm 的 `aliases/default`、volta 的 `~/.volta`、nvm 的 default 别名、Homebrew/官网路径)解析出**版本无关的 node 稳定绝对路径**与包入口的绝对路径,写进一个自有的启动脚本(`cron-up-service`),LaunchAgent 只指向这个脚本。这样做的另一个原因是显示名:macOS 系统设置「允许在后台」按 launchd 拉起的第一个可执行文件给条目归组,直连 node 会显示在「Node.js Foundation」(node 的签名者)名下,指向自有脚本则显示 **cron-up-service**。node 路径在 fnm/nvm 切换默认版本后依然有效;**包入口路径在 fnm/nvm 下属于装包时的那个版本目录**,切换默认版本后巡检会继续运行旧版本目录里的旧包——总览页检测到这种分叉会提示重跑 `cron-up install`。Homebrew/官网安装的包路径本身稳定,无此问题。万一只能用上易变路径(非默认的多 shell 路径),安装时会明确提示。
|
|
62
52
|
|
|
63
53
|
### 自动续期(默认开启)
|
|
64
54
|
|
|
@@ -78,6 +68,7 @@ cron-up install --force --no-auto-renew
|
|
|
78
68
|
|---|---|
|
|
79
69
|
| 命令行与代码 | npm 全局目录(位置因安装方式而异,如 fnm 是 `~/.local/share/fnm/aliases/default/lib/node_modules/cron-up/`,命令在同级 `bin/cron-up`) |
|
|
80
70
|
| launchd 配置 | `~/Library/LaunchAgents/local.cron-up.plist` |
|
|
71
|
+
| 启动脚本 | `~/Library/Application Support/cron-up/cron-up-service`(launchd 拉起的入口,内含 node 与包入口的绝对路径;后台列表显示名即来自它) |
|
|
81
72
|
| 配置文件 | `~/Library/Application Support/cron-up/config.json` |
|
|
82
73
|
| 运行状态 | 同目录下的 `state.json` |
|
|
83
74
|
| 日志 | `~/Library/Logs/cron-up/`(巡检日志;各后台会话的界面记录在 `sessions/` 子目录下,该目录权限 700、日志文件 600) |
|
|
@@ -108,9 +99,9 @@ cron-up uninstall --purge # 结束后台会话并删除配置与日志(
|
|
|
108
99
|
npm i -g cron-up@latest
|
|
109
100
|
```
|
|
110
101
|
|
|
111
|
-
launchd 每轮按 plist 里写死的路径重新拉起 node 与入口文件,npm
|
|
102
|
+
launchd 每轮按 plist 里写死的路径重新拉起 node 与入口文件,npm 替换包文件后下一轮巡检自然跑新版,无需重载服务;配置与任务文件都不动(fnm/nvm 切换默认 Node 版本是例外,见「维护」)。
|
|
112
103
|
|
|
113
|
-
`cron-up list` 按工作区分组,每个任务一行:状态列可能是下次执行时间、`已错过`(一次性任务触发点已过,下次会话启动时会补执行)、`一年内无`(一年的展示查找窗口内没有触发点,如 2 月 30 日这类永不满足的合法表达式)或 `cron 无效`(表达式无法解析,或字段越界、反向区间等任何分钟都不可能匹配的写法);内容摘要取任务 prompt 的首个非空行;已续期的周期任务类型列显示为 `周期·永久`。自动续期关闭且仍有周期任务未打标时,无参数总览的「需要留意」会提示这些任务将于创建满 7 天后失效。不带任何参数的 `cron-up` 是一页总览,把「任务已进入提前启动窗口却没有会话」「保活冷却中(若同时有任务待执行,会合并成一条带排查指引的告警,不会再承诺『下轮启动』)」「cron 无效」「配置与 launchd 实际间隔不一致」「launchd 配置里的 node
|
|
104
|
+
`cron-up list` 按工作区分组,每个任务一行:状态列可能是下次执行时间、`已错过`(一次性任务触发点已过,下次会话启动时会补执行)、`一年内无`(一年的展示查找窗口内没有触发点,如 2 月 30 日这类永不满足的合法表达式)或 `cron 无效`(表达式无法解析,或字段越界、反向区间等任何分钟都不可能匹配的写法);内容摘要取任务 prompt 的首个非空行;已续期的周期任务类型列显示为 `周期·永久`。自动续期关闭且仍有周期任务未打标时,无参数总览的「需要留意」会提示这些任务将于创建满 7 天后失效。不带任何参数的 `cron-up` 是一页总览,把「任务已进入提前启动窗口却没有会话」「保活冷却中(若同时有任务待执行,会合并成一条带排查指引的告警,不会再承诺『下轮启动』)」「cron 无效」「配置与 launchd 实际间隔不一致」「launchd 配置里的 node/入口路径已失效或分属不同 node 版本目录」「会话登记格式疑似变更」等需要处理的情况集中列在「需要留意」里。
|
|
114
105
|
|
|
115
106
|
## 使用定时任务时的三个约定
|
|
116
107
|
|
|
@@ -136,8 +127,8 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
|
|
|
136
127
|
- **修改扫描范围**:直接编辑配置文件的 `roots`、`maxDepth`,最多 5 分钟后的下一轮巡检自动生效,无需重新加载服务。
|
|
137
128
|
- **开关自动续期**:编辑配置文件的 `autoRenew`(`true`/`false`)同样在下一轮巡检生效;或运行 `cron-up install --force --auto-renew`(`--no-auto-renew` 关闭)。想立即处理一次而不改长期开关,用 `cron-up renew`。
|
|
138
129
|
- **修改巡检间隔或提前量**:重新运行 `cron-up install --force --interval 600`,会重新生成 launchd 配置。`--force` 只更新命令行里显式给出的字段,其余配置(如 `roots`)保留;路径参数建议都加引号书写,例如 `--roots "$HOME/code,$HOME/work"`,避免 shell 对逗号后的波浪号不展开。
|
|
139
|
-
- **升级**:`npm i -g cron-up@latest
|
|
140
|
-
-
|
|
130
|
+
- **升级**:`npm i -g cron-up@latest`,下一轮巡检自然跑新版。**例外**:fnm/nvm 下切换默认 Node 版本后,包入口仍指向旧版本目录(见「安装」),巡检会继续跑旧版包,总览页会提示,重跑一次 `cron-up install` 即可。从 26.9.2 及更早升级后建议也重跑一次 `cron-up install --force`:把 LaunchAgent 切到启动脚本形态(后台列表显示名由 Node.js Foundation 变为 cron-up-service),旧形态 plist 在此之前照常巡检。版本号按「年.月.序号」:两位年份、月份、月内发布序号(序号每月重置),如 2026 年 9 月第一次发布是 `26.9.1`,同月再发是 `26.9.2`,到 10 月是 `26.10.1`。发布方式:先把 package.json 的 version 改成新版本并合入 main,再打同名 `v<version>` tag(如 `v26.9.1`);GitHub Actions 会跑通测试(Node 22/24)、校验 package.json 版本与 tag 一致后 publish,版本不符直接失败,杜绝「代码是 26.9.2、tag 叫 26.9.3」这类漂移。push 到 main 的每次提交也会先跑一遍测试(ci workflow)。
|
|
131
|
+
- **配置校验**:手改配置时字段类型写错(`roots` 不是数组、数值字段写成字符串或布尔、`autoRenew` 不是布尔)不会静默回退默认值——巡检会以退出码 2 响亮失败并在总览页列出具体字段,修好或重跑 `cron-up install --force`(坏字段回默认,其余保留)。软问题只警告不中止:提前量小于间隔(可能来不及在任务前启动会话)、扫描目录不存在。无参数的 `cron-up` 总览随时也会显示这些问题;其中 interval 显示的是 launchd **实际生效**的间隔,手动改过 config 但与实际不一致时会提示需要重新 install。
|
|
141
132
|
- **路径大小写**:macOS 默认文件系统大小写不敏感,而 `realpath` 并不规范化大小写。扫描入口会把 `roots` 认回磁盘上的真实写法,`cron-up logs` 给绝对路径时也一样——大小写敲错不再是「警告一声然后匹配失效」,而是直接按同一路径工作。
|
|
142
133
|
- **提前量与间隔的关系**:请保持 `leadSeconds ≥ intervalSeconds`,否则极端情况下任务会在两轮巡检之间到期而来不及启动。默认 600 ≥ 300 有一倍余量。`--lead 0` 是合法取值(表示不提前,只靠错过补执行兜底),会如实写入配置;`--interval 0` 非法,安装时直接报错中止。
|
|
143
134
|
- **长驻会话自动换代**:被续期任务保活的会话会长期常驻,但常驻不等于长生不老——连续运行超过 7 天、且界面记录静默超过 1 小时(没有任务在执行、没有界面活动的证据)的会话会被主动结束,任务需要时自然重拉。换代让会话用上新的 claude 版本、界面记录从头写起,也让长期累积的上下文回到干净起点。日志静默是「没在跑任务」的判据,执行中的任务不会被砍;换代不计入失败次数。
|
|
@@ -164,25 +155,28 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
|
|
|
164
155
|
- 手写的任务文件若缺 `createdAt` 字段,错过补执行的判定不生效:错过的一次性任务不会被预热补救,`list` 也不显示「已错过」。`CronCreate` 创建的任务始终带该字段,不受影响。
|
|
165
156
|
- 工作区从巡检视野里消失(`.claude/scheduled_tasks.json` 被整个删除,或目录被移出 `roots`)后,cron-up 不再巡检它:其中仍在运行的保活会话不会被回收或换代,会一直驻留到进程自己退出。无参数 `cron-up` 的「需要留意」会列出这类会话,不需要时请手动结束对应进程(`kill <pid>`)。
|
|
166
157
|
- 同一目录同时存在两个交互会话时,定时任务是否会被各执行一次,官方没有明确说明。cron-up 的策略是「只在没有任何会话时才启动」,正常流程不会制造第二个会话。
|
|
167
|
-
-
|
|
158
|
+
- 系统设置「登录项与扩展 → 允许在后台」里,巡检条目显示为 **cron-up-service**:macOS 按 launchd 拉起的第一个可执行文件(启动脚本)给条目起名。条目小字可能标注「来自未识别的开发者」——启动脚本没有 Apple 签发证书的签名,这行小字没有本地手段改;主名称与开关行为不受影响。
|
|
159
|
+
- 若升级 Node 时删掉了 plist 引用的版本目录(多见于手动删除 nvm/fnm 版本),launchd 会静默拉不起巡检;总览页会报「Node 或入口路径已失效」,重跑一次 `cron-up install` 即可修复。fnm/nvm 切换默认版本但旧版本还在时更隐蔽:路径都有效、巡检照跑,只是跑的是旧版本目录里的旧包,总览页会报「入口路径属于另一个 node 版本目录」。
|
|
168
160
|
|
|
169
161
|
## 故障排查
|
|
170
162
|
|
|
171
163
|
| 现象 | 排查方法 |
|
|
172
164
|
|---|---|
|
|
173
165
|
| 总览提示「冷却中」 | 运行 `cron-up logs <目录名>` 查看启动界面:要么停在信任确认或工具授权提问(日志里能看到提问界面),要么 shell 环境缺少命令(日志里是 `command not found`) |
|
|
174
|
-
| 总览提示「Node 或入口路径已失效」 | Node
|
|
166
|
+
| 总览提示「Node 或入口路径已失效」 | Node 版本目录被删或移动后启动脚本里的绝对路径失效,重跑 `cron-up install` 重新解析即可 |
|
|
167
|
+
| 总览提示「启动脚本已失效 / 内容无法解读」 | 启动脚本(`~/Library/Application Support/cron-up/cron-up-service`)被删或手改,重跑 `cron-up install` 覆盖重生成 |
|
|
168
|
+
| 总览提示「入口路径属于另一个 node 版本目录」 | fnm/nvm 切换了默认 Node 版本,巡检在跑旧版本目录里的旧包;重跑 `cron-up install` 更新路径 |
|
|
175
169
|
| 日志里出现「卡死」 | 会话进程起来了但没能变成可用会话,绝大多数是停在信任确认或授权提问。先手动 `cd <目录> && claude` 走完确认,再等下一轮巡检 |
|
|
176
170
|
| 日志里出现「任务文件读不出 \<目录\>」 | 该目录的 `.claude/scheduled_tasks.json` 存在却解析不了(格式损坏),巡检跳过这一个目录,**其中的定时任务一个都不会执行**;其余目录照常。检查该文件 |
|
|
177
171
|
| 日志里出现「错误 \<目录\>」 | 巡检该目录时抛出未预期的异常,已按目录隔离、不影响其余目录。这通常意味着 cron-up 自身的 bug,请带上该行反馈 |
|
|
178
172
|
| 任务到点没有执行 | `cron-up list` 查看下次执行时间和是否有会话;`launchctl print gui/$(id -u)/local.cron-up` 查看 last exit code |
|
|
179
173
|
| 手动 `cron-up run` 正常、定时执行不正常 | 基本都是 launchd 环境下 shell 初始化不一致(PATH、fnm、模型路由),会话日志里会有直接报错;注意 plist 用的是安装时解析的绝对 node 路径,换 Node 安装方式后要重跑 install |
|
|
180
|
-
| `last exit code=2` | config.json 缺失、不是合法 JSON、顶层不是对象,或字段类型不对(如 `leadSeconds` 写成字符串);无参数 `cron-up` 总览的配置行或「需要留意」会给出具体原因 |
|
|
174
|
+
| `last exit code=2` | config.json 缺失、不是合法 JSON、顶层不是对象,或字段类型不对(如 `leadSeconds` 写成字符串、`autoRenew` 写成字符串);无参数 `cron-up` 总览的配置行或「需要留意」会给出具体原因 |
|
|
181
175
|
|
|
182
176
|
## 开发与测试
|
|
183
177
|
|
|
184
178
|
```bash
|
|
185
|
-
git clone git@github.com:adaex/
|
|
179
|
+
git clone git@github.com:adaex/cron-up.git && cd cron-up
|
|
186
180
|
node --test # 全部测试(需要 Node 22+;进程/launchctl 相关用例在 macOS 上运行)
|
|
187
181
|
npm link # 本机把开发版链接成全局 cron-up,迭代即生效
|
|
188
182
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cron-up",
|
|
3
|
-
"version": "26.9.
|
|
3
|
+
"version": "26.9.3",
|
|
4
4
|
"description": "为命令行 AI agent 的 cron 定时任务提前预热并保活交互式会话",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"license": "MIT",
|
|
20
20
|
"repository": {
|
|
21
21
|
"type": "git",
|
|
22
|
-
"url": "git+ssh://git@github.com/adaex/
|
|
22
|
+
"url": "git+ssh://git@github.com/adaex/cron-up.git"
|
|
23
23
|
},
|
|
24
24
|
"keywords": [
|
|
25
25
|
"cron",
|
package/src/config.mjs
CHANGED
|
@@ -47,10 +47,41 @@ export function lenientInt(v) {
|
|
|
47
47
|
throw new Error(`无法整型化:${String(v)}`);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// 配置字段的类型规则,loadConfig 与 install --force 继承旧配置共用。返回
|
|
51
|
+
// [字段名, 人话错误][]:手改把字段写坏时绝不静默回退——回退的两个可能方向
|
|
52
|
+
// (roots 坏 → 回扫整个家目录、autoRenew 坏 → 回开续期)都比停机更危险。
|
|
53
|
+
export function configFieldErrors(cfg) {
|
|
54
|
+
const errs = [];
|
|
55
|
+
if (!Array.isArray(cfg.roots)) {
|
|
56
|
+
errs.push(['roots', `roots 应是字符串数组,当前为 ${JSON.stringify(cfg.roots)}`]);
|
|
57
|
+
}
|
|
58
|
+
if (!isInt(cfg.maxDepth) || cfg.maxDepth < 0) {
|
|
59
|
+
errs.push(['maxDepth', `maxDepth 应是非负整数,当前为 ${JSON.stringify(cfg.maxDepth)}`]);
|
|
60
|
+
}
|
|
61
|
+
if (!isInt(cfg.intervalSeconds) || cfg.intervalSeconds <= 0) {
|
|
62
|
+
errs.push(['intervalSeconds',
|
|
63
|
+
`intervalSeconds 应是正整数秒,当前为 ${JSON.stringify(cfg.intervalSeconds)}`]);
|
|
64
|
+
}
|
|
65
|
+
if (!isInt(cfg.leadSeconds) || cfg.leadSeconds < 0) {
|
|
66
|
+
errs.push(['leadSeconds',
|
|
67
|
+
`leadSeconds 应是非负整数秒,当前为 ${JSON.stringify(cfg.leadSeconds)}`]);
|
|
68
|
+
}
|
|
69
|
+
if (typeof cfg.autoRenew !== 'boolean') {
|
|
70
|
+
errs.push(['autoRenew',
|
|
71
|
+
`autoRenew 应是布尔值(true/false),当前为 ${JSON.stringify(cfg.autoRenew)}`]);
|
|
72
|
+
}
|
|
73
|
+
return errs;
|
|
74
|
+
}
|
|
75
|
+
|
|
50
76
|
// opts.missingOk:缺失文件把异常抛给调用方(总览页把「缺失」当页面内容的
|
|
51
|
-
// 一部分);opts.corruptOk:损坏 JSON
|
|
52
|
-
// run
|
|
77
|
+
// 一部分);opts.corruptOk:损坏 JSON/顶层非对象/字段类型错抛普通错误而非
|
|
78
|
+
// exit(总览页把它们当页面内容展示)。launchd 的 run 入口两个都不要——它
|
|
79
|
+
// 该死成一行原因而不是 traceback。
|
|
53
80
|
export function loadConfig(file, opts = {}) {
|
|
81
|
+
const fail = (message) => {
|
|
82
|
+
if (opts.corruptOk) throw new Error(message);
|
|
83
|
+
throw new ExitError(2, message);
|
|
84
|
+
};
|
|
54
85
|
let cfg;
|
|
55
86
|
try {
|
|
56
87
|
cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
@@ -66,22 +97,19 @@ export function loadConfig(file, opts = {}) {
|
|
|
66
97
|
}
|
|
67
98
|
if (!isPlainObject(cfg)) {
|
|
68
99
|
// 合法 JSON 但顶层不是对象(123、["a"]):与「不是合法 JSON」同等对待。
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
throw new ExitError(2,
|
|
73
|
-
`配置文件顶层应是 JSON 对象而不是${Array.isArray(cfg) ? '数组' : typeof cfg}:`
|
|
74
|
-
+ `${file}\n 运行 cron-up install 生成,或用 --config 指定路径`);
|
|
100
|
+
// 单行消息:总览页按宽度截断展示,断行会失去告警前缀。
|
|
101
|
+
fail(`配置文件顶层应是 JSON 对象而不是${Array.isArray(cfg) ? '数组' : typeof cfg}:`
|
|
102
|
+
+ `${file};运行 cron-up install 生成,或用 --config 指定路径`);
|
|
75
103
|
}
|
|
76
104
|
const merged = { ...DEFAULT_CONFIG, ...cfg };
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
merged.autoRenew = DEFAULT_CONFIG.autoRenew;
|
|
105
|
+
const errs = configFieldErrors(merged);
|
|
106
|
+
if (errs.length > 0) {
|
|
107
|
+
// 单行消息(字段在前、修复指引在后):总览页按宽度截断展示,截尾只
|
|
108
|
+
// 伤修复指引,不伤字段清单。
|
|
109
|
+
fail(`配置字段类型不对:${errs.map(([, m]) => m).join(';')}`
|
|
110
|
+
+ `;修复 ${file} 或重跑 cron-up install --force`);
|
|
84
111
|
}
|
|
112
|
+
merged.roots = normalizeRoots(merged.roots);
|
|
85
113
|
return merged;
|
|
86
114
|
}
|
|
87
115
|
|
package/src/constants.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// 全部调参常量集中一处,改动时便于通盘核对。
|
|
2
2
|
|
|
3
3
|
export const DEFAULT_CONFIG = {
|
|
4
|
-
version: 1,
|
|
5
4
|
// 默认扫家目录(两层深覆盖 ~/<root>/<workspace>);安装时可用 roots 收窄。
|
|
6
5
|
roots: ['~'],
|
|
7
6
|
maxDepth: 2,
|
|
@@ -51,7 +50,5 @@ export const SEARCH_DAYS = 7;
|
|
|
51
50
|
// list/总览回答「下次何时触发」,要看到一年后:年度任务必须显示真实日期,
|
|
52
51
|
// 不能误报成「无安排」。nextAfter 的日历快进让长窗口依然便宜。
|
|
53
52
|
export const DISPLAY_SEARCH_DAYS = 366;
|
|
54
|
-
// 补执行检测从任务创建时间向前搜,需要独立的上界。
|
|
55
|
-
export const MISSED_LOOKBACK_DAYS = 30;
|
|
56
53
|
// list 问「一次性任务是否被错过」,与巡检同一个补执行判定,只是不带前瞻。
|
|
57
54
|
export const ZERO_LEAD_MS = 0;
|
package/src/cron.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// 分钟级 cron 解析与下一次触发计算。字段全是别的程序写的,畸形表达式在
|
|
2
2
|
// 边界处返回 null/不可满足,绝不抛进巡检循环。
|
|
3
3
|
|
|
4
|
-
import { SEARCH_DAYS
|
|
4
|
+
import { SEARCH_DAYS } from './constants.mjs';
|
|
5
5
|
|
|
6
6
|
const MS_DAY = 86400_000;
|
|
7
7
|
const MS_MINUTE = 60_000;
|
|
@@ -159,13 +159,10 @@ function wanted(cron, task, now, leadMs) {
|
|
|
159
159
|
if (taskIsOneshot(task)) {
|
|
160
160
|
const created = createdAtDate(task);
|
|
161
161
|
if (!created) return false;
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
const span = Math.
|
|
166
|
-
Math.floor((now.getTime() - created.getTime()) / MS_DAY) + 1,
|
|
167
|
-
MISSED_LOOKBACK_DAYS,
|
|
168
|
-
);
|
|
162
|
+
// 从创建时间向前搜到当前:要找的就是 created..now 之间的第一个触发
|
|
163
|
+
// 点,不封顶——「下个月某天」这类远跨度一次性任务错过时同样该补执行。
|
|
164
|
+
// 陈年条目也不贵:nextAfter 的日历快进按月跳,十年也只有百余次迭代。
|
|
165
|
+
const span = Math.floor((now.getTime() - created.getTime()) / MS_DAY) + 1;
|
|
169
166
|
const first = cron.nextAfter(
|
|
170
167
|
new Date(created.getTime() - MS_MINUTE),
|
|
171
168
|
span,
|
package/src/overview.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
|
|
8
8
|
import { deps } from './internals.mjs';
|
|
9
|
-
import { loadConfig, loadState, validateConfig
|
|
9
|
+
import { loadConfig, loadState, validateConfig } from './config.mjs';
|
|
10
10
|
import { packageVersion, TASK_REL } from './paths.mjs';
|
|
11
11
|
import { serviceLine, launcherHealth } from './service.mjs';
|
|
12
12
|
import {
|
|
@@ -175,17 +175,6 @@ export async function cmdOverview(args) {
|
|
|
175
175
|
} catch (e) {
|
|
176
176
|
if (e.code !== 'ENOENT') cfgError = e;
|
|
177
177
|
}
|
|
178
|
-
// 数值字段被手改成非整数:巡检入口会以退出码 2 失败,总览若不先挡下会
|
|
179
|
-
// 在 leadMs 计算处炸掉。判定式与 cmdRun 的守卫保持一致。
|
|
180
|
-
const badFields = [];
|
|
181
|
-
if (cfg !== null) {
|
|
182
|
-
if (!isInt(cfg.intervalSeconds) || cfg.intervalSeconds <= 0) {
|
|
183
|
-
badFields.push('intervalSeconds');
|
|
184
|
-
}
|
|
185
|
-
if (!isInt(cfg.leadSeconds) || cfg.leadSeconds < 0) {
|
|
186
|
-
badFields.push('leadSeconds');
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
178
|
|
|
190
179
|
const info = deps.launchctlInfo();
|
|
191
180
|
deps.print(serviceLine(info, fs.existsSync(deps.paths.plistPath)));
|
|
@@ -202,18 +191,12 @@ export async function cmdOverview(args) {
|
|
|
202
191
|
let discovered = null;
|
|
203
192
|
|
|
204
193
|
if (cfgError !== null) {
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
deps.print(
|
|
208
|
-
alerts.push(
|
|
209
|
-
+ `运行 cron-up install。解析错误:${cfgError.message}`);
|
|
194
|
+
// 页面不能死在半路:损坏或字段类型不对的配置是「需要留意」的一种;巡
|
|
195
|
+
// 检本身仍会以退出码 2 失败(loadConfig 与这里共用同一套校验)。
|
|
196
|
+
deps.print(`配置:无法生效(${cfgPath})`);
|
|
197
|
+
alerts.push(`配置无法生效(巡检将以退出码 2 失败):${cfgError.message}`);
|
|
210
198
|
} else if (cfg === null) {
|
|
211
199
|
deps.print('配置:缺失,运行 cron-up install 生成');
|
|
212
|
-
} else if (badFields.length) {
|
|
213
|
-
const vals = badFields.map((k) => `${k}=${JSON.stringify(cfg[k])}`).join('、');
|
|
214
|
-
deps.print(`配置:${badFields.join('、')} 应为整数秒,当前 ${vals}`);
|
|
215
|
-
alerts.push(`配置字段类型不对(${vals}),请修复 ${cfgPath};leadSeconds `
|
|
216
|
-
+ '不对时每轮巡检都会以退出码 2 失败');
|
|
217
200
|
} else {
|
|
218
201
|
const actualIv = info ? info.interval : null;
|
|
219
202
|
const renewTag = cfg.autoRenew ? ',自动续期开' : ',自动续期关';
|
package/src/paths.mjs
CHANGED
|
@@ -7,7 +7,6 @@ import fs from 'node:fs';
|
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
|
|
9
9
|
export const LABEL = 'local.cron-up';
|
|
10
|
-
export const OLD_LABEL = 'local.doorman';
|
|
11
10
|
|
|
12
11
|
const HOME = os.homedir();
|
|
13
12
|
// 被预热的 agent 工作区里,定时任务文件的相对路径。
|
|
@@ -31,6 +30,12 @@ export const paths = {
|
|
|
31
30
|
get statePath() {
|
|
32
31
|
return path.join(this.appSupport, 'state.json');
|
|
33
32
|
},
|
|
33
|
+
// launchd 拉起的启动脚本。ProgramArguments[0] 直接指向它而非 node:
|
|
34
|
+
// macOS 后台项目列表按可执行文件名显示条目,直连 node 会归到「Node.js
|
|
35
|
+
// Foundation」名下(node 的签名者),自有脚本则显示脚本自己的名字。
|
|
36
|
+
get serviceScriptPath() {
|
|
37
|
+
return path.join(this.appSupport, 'cron-up-service');
|
|
38
|
+
},
|
|
34
39
|
};
|
|
35
40
|
|
|
36
41
|
// 版本号读 package.json(用 fs 读而非 JSON import attribute,避免对 Node
|
package/src/patrol.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
|
|
8
|
-
import { deps
|
|
8
|
+
import { deps } from './internals.mjs';
|
|
9
9
|
import {
|
|
10
10
|
FAIL_LIMIT,
|
|
11
11
|
COOLDOWN_SECONDS,
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
SESSION_IDLE_SECONDS,
|
|
15
15
|
PATROL_LOG_ROTATE_BYTES,
|
|
16
16
|
} from './constants.mjs';
|
|
17
|
-
import { loadConfig, loadState, saveState
|
|
17
|
+
import { loadConfig, loadState, saveState } from './config.mjs';
|
|
18
18
|
import { renewWorkspace, TaskFileChanged } from './tasks.mjs';
|
|
19
19
|
import { taskWanted } from './cron.mjs';
|
|
20
20
|
import {
|
|
@@ -187,7 +187,6 @@ export async function patrolWorkspace(
|
|
|
187
187
|
if (ent !== null) {
|
|
188
188
|
if (mineAlive) {
|
|
189
189
|
ent.fails = 0;
|
|
190
|
-
state[ws] = ent;
|
|
191
190
|
} else if (!cooling) {
|
|
192
191
|
delete state[ws];
|
|
193
192
|
}
|
|
@@ -273,11 +272,6 @@ export async function cmdRun(args) {
|
|
|
273
272
|
try {
|
|
274
273
|
deps.rotatePatrolLogs();
|
|
275
274
|
const cfg = loadConfig(args?.config ?? deps.paths.configPath);
|
|
276
|
-
if (!isInt(cfg.leadSeconds) || cfg.leadSeconds < 0) {
|
|
277
|
-
deps.printErr('配置错误:leadSeconds 必须是非负整数秒,当前为 '
|
|
278
|
-
+ `${cfg.leadSeconds}`);
|
|
279
|
-
throw new ExitError(2);
|
|
280
|
-
}
|
|
281
275
|
const now = new Date();
|
|
282
276
|
const leadMs = cfg.leadSeconds * 1000;
|
|
283
277
|
const state = loadState();
|
package/src/service.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
|
|
8
8
|
import { deps, ExitError } from './internals.mjs';
|
|
9
|
-
import { paths, LABEL
|
|
9
|
+
import { paths, LABEL } from './paths.mjs';
|
|
10
10
|
// paths.home 仅用于 resolveLauncher 的默认 HOME;其余产物路径一律走
|
|
11
11
|
// deps.paths(测试要整体重定向)。
|
|
12
12
|
import {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
validateConfig,
|
|
20
20
|
normalizeRoots,
|
|
21
21
|
isInt,
|
|
22
|
+
configFieldErrors,
|
|
22
23
|
} from './config.mjs';
|
|
23
24
|
|
|
24
25
|
export function guiTarget() {
|
|
@@ -214,12 +215,25 @@ function xmlUnescape(s) {
|
|
|
214
215
|
}
|
|
215
216
|
|
|
216
217
|
// key 按字母序排列。ProgramArguments 是
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
218
|
+
// 生成 launchd 拉起的启动脚本:ProgramArguments[0] 指向它而非 node。
|
|
219
|
+
// macOS 的后台项目列表(系统设置 → 登录项与扩展 → 允许在后台)按
|
|
220
|
+
// ProgramArguments[0] 的文件名归组条目:直连 node 会与所有 node 系后台服务
|
|
221
|
+
// 合并显示在「Node.js Foundation」(node 的签名者)名下;指向自有脚本则
|
|
222
|
+
// 显示 cron-up-service。路径变化(换 Node 安装方式、升级)后重跑
|
|
223
|
+
// cron-up install 覆盖重生成。
|
|
224
|
+
export function writeServiceScript(launcher) {
|
|
225
|
+
const script = deps.paths.serviceScriptPath;
|
|
226
|
+
fs.mkdirSync(path.dirname(script), { recursive: true });
|
|
227
|
+
fs.writeFileSync(script,
|
|
228
|
+
'#!/bin/bash\n'
|
|
229
|
+
+ '# 由 cron-up install 生成;重跑 cron-up install --force 覆盖重生成\n'
|
|
230
|
+
+ `exec "${launcher.nodePath}" "${launcher.entryPath}" run\n`);
|
|
231
|
+
fs.chmodSync(script, 0o755);
|
|
232
|
+
return script;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ProgramArguments 单参数(启动脚本路径)——node 与入口路径在脚本里。
|
|
236
|
+
export function renderPlist(intervalSeconds, scriptPath) {
|
|
223
237
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
224
238
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
225
239
|
<plist version="1.0">
|
|
@@ -228,7 +242,7 @@ export function renderPlist(intervalSeconds, launcher) {
|
|
|
228
242
|
\t<string>${LABEL}</string>
|
|
229
243
|
\t<key>ProgramArguments</key>
|
|
230
244
|
\t<array>
|
|
231
|
-
|
|
245
|
+
\t\t<string>${xmlEscape(scriptPath)}</string>
|
|
232
246
|
\t</array>
|
|
233
247
|
\t<key>RunAtLoad</key>
|
|
234
248
|
\t<true/>
|
|
@@ -243,8 +257,10 @@ ${args}
|
|
|
243
257
|
`;
|
|
244
258
|
}
|
|
245
259
|
|
|
246
|
-
// 从已安装 plist 的 ProgramArguments
|
|
247
|
-
//
|
|
260
|
+
// 从已安装 plist 的 ProgramArguments 里抠出参数(plist 是我们自己写的,
|
|
261
|
+
// 正则足够,不引 plist 解析器)。两种形态:单参数(当前,启动脚本路径)
|
|
262
|
+
// 与三参数 [node, 入口, 'run'](旧版 plist,升级后重跑 install 前的过渡
|
|
263
|
+
// 形态)。
|
|
248
264
|
export function readPlistProgramArgs() {
|
|
249
265
|
let text;
|
|
250
266
|
try {
|
|
@@ -257,21 +273,85 @@ export function readPlistProgramArgs() {
|
|
|
257
273
|
if (!block) return null;
|
|
258
274
|
const parts = [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)]
|
|
259
275
|
.map((m) => xmlUnescape(m[1]));
|
|
260
|
-
return parts.length
|
|
276
|
+
return parts.length > 0 ? parts : null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 从启动脚本内容里解出 [node 路径, 入口路径];脚本是我们生成的固定格
|
|
280
|
+
// 式,解不出(手改坏)返回 null。
|
|
281
|
+
function readServiceScriptPaths(scriptPath) {
|
|
282
|
+
let text;
|
|
283
|
+
try {
|
|
284
|
+
text = fs.readFileSync(scriptPath, 'utf-8');
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
const m = text.match(/^exec "(.+)" "(.+)" run$/m);
|
|
289
|
+
return m ? [m[1], m[2]] : null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// fnm 与 nvm 的全局包装在版本化目录里(…/node-versions/v<X>/… 与
|
|
293
|
+
// …/versions/node/v<X>/…)。default 别名切换后 node 路径依然有效,但入口
|
|
294
|
+
// 路径仍指向装包时的旧版本目录——巡检会静默继续跑旧版 cron-up,直到旧版
|
|
295
|
+
// 本被卸载才表现为「路径失效」。这里提前一步提示。版本段固定以 v 开头
|
|
296
|
+
// (fnm/nvm 的目录命名),避免路径里恰好出现 node-versions 字样的普通目
|
|
297
|
+
// 录误报;volta/brew/pkg 的包路径不含版本化目录,返回 null 跳过检查
|
|
298
|
+
// (npm link 的工作副本同理)。
|
|
299
|
+
function nodeVersionRoot(p) {
|
|
300
|
+
const m = p.match(/(?:node-versions|versions\/node)\/v[^/]+/);
|
|
301
|
+
return m ? m[0] : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// node/入口路径的存在性与版本分叉检查,新(启动脚本内)旧(plist 三参
|
|
305
|
+
// 数)两种形态共用。
|
|
306
|
+
function pathHealthAlert(nodePath, entryPath) {
|
|
307
|
+
const missing = [nodePath, entryPath].filter((p) => !fs.existsSync(p));
|
|
308
|
+
if (missing.length > 0) {
|
|
309
|
+
return 'launchd 配置中的 Node 或入口路径已失效('
|
|
310
|
+
+ `${missing.map((p) => path.basename(p)).join('、')} 不存在),巡检当前拉不`
|
|
311
|
+
+ '起来;请重新运行 cron-up install';
|
|
312
|
+
}
|
|
313
|
+
let nodeReal = nodePath;
|
|
314
|
+
try {
|
|
315
|
+
nodeReal = fs.realpathSync(nodePath);
|
|
316
|
+
} catch {
|
|
317
|
+
// 存在性已确认,保留原路径。
|
|
318
|
+
}
|
|
319
|
+
const nodeRoot = nodeVersionRoot(nodeReal);
|
|
320
|
+
const entryRoot = nodeVersionRoot(entryPath);
|
|
321
|
+
if (nodeRoot !== null && entryRoot !== null && nodeRoot !== entryRoot) {
|
|
322
|
+
return 'launchd 配置里的入口路径属于另一个 node 版本目录'
|
|
323
|
+
+ `(${entryRoot}),而当前 node 是 ${nodeReal};巡检可能在运行旧版`
|
|
324
|
+
+ ' cron-up,重跑 cron-up install 更新路径';
|
|
325
|
+
}
|
|
326
|
+
return null;
|
|
261
327
|
}
|
|
262
328
|
|
|
263
|
-
// 总览页健康检查:plist
|
|
264
|
-
//
|
|
329
|
+
// 总览页健康检查:plist 指定的启动链若已断(脚本或其中的 node/入口路径
|
|
330
|
+
// 不存在,如 fnm default 被删、nvm 版本目录被清),launchd 每轮都会静默
|
|
331
|
+
// 拉不起来;路径都在但分属不同 node 版本目录时,巡检跑的多半是旧版包。
|
|
265
332
|
export function launcherHealth() {
|
|
266
333
|
if (!fs.existsSync(deps.paths.plistPath)) return null;
|
|
267
334
|
const args = readPlistProgramArgs();
|
|
268
335
|
if (!args) return null;
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
336
|
+
if (args.length === 1) {
|
|
337
|
+
const [scriptPath] = args;
|
|
338
|
+
if (!fs.existsSync(scriptPath)) {
|
|
339
|
+
return `launchd 配置中的启动脚本已失效(${path.basename(scriptPath)} `
|
|
340
|
+
+ '不存在),巡检当前拉不起来;请重新运行 cron-up install';
|
|
341
|
+
}
|
|
342
|
+
const paths = readServiceScriptPaths(scriptPath);
|
|
343
|
+
if (paths === null) {
|
|
344
|
+
return `启动脚本 ${scriptPath} 内容无法解读(可能被手改),`
|
|
345
|
+
+ '重跑 cron-up install 覆盖重生成';
|
|
346
|
+
}
|
|
347
|
+
return pathHealthAlert(...paths);
|
|
348
|
+
}
|
|
349
|
+
if (args.length === 3) {
|
|
350
|
+
// 旧版 plist(ProgramArguments 直写 node 与入口):升级后重跑
|
|
351
|
+
// install 前的过渡形态,照常检查。
|
|
352
|
+
return pathHealthAlert(args[0], args[1]);
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
275
355
|
}
|
|
276
356
|
|
|
277
357
|
// ---- install / uninstall ----
|
|
@@ -303,6 +383,7 @@ export async function cmdInstall(args) {
|
|
|
303
383
|
// 即使 --force 也从现有配置(全新安装则默认值)起步,只覆盖命令行给
|
|
304
384
|
// 的字段——否则 --force --interval 600 会悄悄把 roots 重置成默认。
|
|
305
385
|
cfg = { ...DEFAULT_CONFIG };
|
|
386
|
+
let inherited = false;
|
|
306
387
|
if (fs.existsSync(deps.paths.configPath)) {
|
|
307
388
|
let old = null;
|
|
308
389
|
try {
|
|
@@ -312,7 +393,15 @@ export async function cmdInstall(args) {
|
|
|
312
393
|
}
|
|
313
394
|
// 手改成非对象(123、["a"]):没有可继承的旧值,静默按默认走。
|
|
314
395
|
if (old && typeof old === 'object' && !Array.isArray(old)) {
|
|
396
|
+
// 类型坏的字段不继承(回到默认值)并当面说明,其余照旧——force 不
|
|
397
|
+
// 该把类型错误写进新配置,也不该因一个字段坏了丢掉其余设置。
|
|
398
|
+
const errs = configFieldErrors({ ...DEFAULT_CONFIG, ...old });
|
|
399
|
+
for (const [key, msg] of errs) {
|
|
400
|
+
delete old[key];
|
|
401
|
+
deps.print(`提示:旧配置的 ${msg},未继承,采用默认值`);
|
|
402
|
+
}
|
|
315
403
|
cfg = { ...cfg, ...old };
|
|
404
|
+
inherited = true;
|
|
316
405
|
}
|
|
317
406
|
}
|
|
318
407
|
const overrides = {};
|
|
@@ -332,12 +421,15 @@ export async function cmdInstall(args) {
|
|
|
332
421
|
cfg = { ...cfg, ...overrides };
|
|
333
422
|
cfg.roots = normalizeRoots(cfg.roots ?? []);
|
|
334
423
|
saveConfig(cfg);
|
|
335
|
-
const updated = Object.keys(overrides).join('、') || '
|
|
336
|
-
|
|
424
|
+
const updated = Object.keys(overrides).join('、') || '无';
|
|
425
|
+
const rest = inherited ? '其余继承现有配置' : '全部默认值';
|
|
426
|
+
deps.print(`已写入配置:${deps.paths.configPath}(更新字段:${updated},${rest})`);
|
|
337
427
|
}
|
|
338
428
|
|
|
339
|
-
//
|
|
340
|
-
//
|
|
429
|
+
// 动系统前先校验生效配置:文件继承的值由 loadConfig / configFieldErrors
|
|
430
|
+
// 保证类型,但命令行给的 --interval/--lead 只经过整数解析,取值范围要在
|
|
431
|
+
// 这里拦(--interval 0 非法、--lead 0 是合法选择);软问题(提前量过
|
|
432
|
+
// 小、目录缺失)只警告。
|
|
341
433
|
if (!isInt(cfg.intervalSeconds) || cfg.intervalSeconds <= 0) {
|
|
342
434
|
deps.printErr(`intervalSeconds 必须是正整数秒,当前为 ${JSON.stringify(cfg.intervalSeconds)}`);
|
|
343
435
|
throw new ExitError(1);
|
|
@@ -356,16 +448,16 @@ export async function cmdInstall(args) {
|
|
|
356
448
|
}
|
|
357
449
|
|
|
358
450
|
// npm 包没有「安装二进制」这一步:node 与入口文件已由 npm 就位,这里只
|
|
359
|
-
//
|
|
451
|
+
// 解析它们的稳定绝对路径,写进启动脚本(plist 只指向脚本,让后台项目
|
|
452
|
+
// 列表显示 cron-up-service 而非 node 的签名者)。
|
|
360
453
|
const launcher = resolveLauncher();
|
|
361
454
|
if (launcher.note) deps.print(`提示:${launcher.note}`);
|
|
362
455
|
|
|
456
|
+
const script = writeServiceScript(launcher);
|
|
363
457
|
fs.writeFileSync(deps.paths.plistPath,
|
|
364
|
-
renderPlist(cfg.intervalSeconds,
|
|
365
|
-
deps.print(
|
|
458
|
+
renderPlist(cfg.intervalSeconds, script));
|
|
459
|
+
deps.print(`已写入启动脚本与 LaunchAgent:${script}、${deps.paths.plistPath}`);
|
|
366
460
|
|
|
367
|
-
// doorman 旧 label 若还在,先踢掉,避免两个巡检并行互相换代/回收。
|
|
368
|
-
deps.launchctl('bootout', `${guiTarget()}/${OLD_LABEL}`);
|
|
369
461
|
deps.launchctl('bootout', `${guiTarget()}/${LABEL}`);
|
|
370
462
|
const r = deps.launchctl('bootstrap', guiTarget(), deps.paths.plistPath);
|
|
371
463
|
if (r.status !== 0) {
|
package/src/tasks.mjs
CHANGED
|
@@ -12,9 +12,7 @@ import { PRUNE_DIRS } from './constants.mjs';
|
|
|
12
12
|
export function expandHome(p) {
|
|
13
13
|
const home = deps.paths.home;
|
|
14
14
|
if (p === '~') return home;
|
|
15
|
-
if (p.startsWith('~/')
|
|
16
|
-
return home + p.slice(1);
|
|
17
|
-
}
|
|
15
|
+
if (p.startsWith('~/')) return home + p.slice(1);
|
|
18
16
|
return p; // ~user 形式不支持(macOS 实践中不会出现)
|
|
19
17
|
}
|
|
20
18
|
|