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
package/src/registry.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Claude Code 会话登记(~/.claude/sessions/*.json)的读取与消费者判定。
|
|
2
|
+
// 登记是另一个程序未文档化的输出:一旦形状变化,所有会话都会被读成「不
|
|
3
|
+
// 存在」,巡检就会在用户会话旁边反复重拉。scanSessions 因此内置三枚烟
|
|
4
|
+
// 雾告警,分别盯住三种漂移形态。
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { deps } from './internals.mjs';
|
|
10
|
+
import { lenientInt } from './config.mjs';
|
|
11
|
+
import { PID_T_MAX } from './constants.mjs';
|
|
12
|
+
|
|
13
|
+
// npm/bun 形态的 claude 是解释器跑的脚本:进程 comm 是 node/bun/deno,
|
|
14
|
+
// 只能从完整命令行认 claude 入口(bin/claude 符号链接或包目录 cli.js)。
|
|
15
|
+
// 回退只对已知解释器开放——命令行里碰巧提到 claude 的进程(vim claude.md)
|
|
16
|
+
// 不会被误认。
|
|
17
|
+
const INTERPRETER_COMMS = new Set(['node', 'nodejs', 'bun', 'deno']);
|
|
18
|
+
const CLAUDE_ENTRY_RE = /(?:^|\/)claude(?:-code)?(?:\.js)?(?:$|[\/\s])/;
|
|
19
|
+
|
|
20
|
+
function ps(pid, fmt) {
|
|
21
|
+
try {
|
|
22
|
+
const r = deps.execFile('/bin/ps', ['-p', String(pid), '-o', fmt],
|
|
23
|
+
{ timeout: 5000 });
|
|
24
|
+
return (r.stdout ?? '').trim();
|
|
25
|
+
} catch {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 活 pid 是否像 claude 进程;调用方先判存活,这里用来排除回收 pid 的无关
|
|
31
|
+
// 进程。
|
|
32
|
+
export function pidIsClaude(pid) {
|
|
33
|
+
const comm = path.basename(ps(pid, 'comm=')).toLowerCase();
|
|
34
|
+
if (comm.includes('claude')) return true;
|
|
35
|
+
if (INTERPRETER_COMMS.has(comm)) {
|
|
36
|
+
return CLAUDE_ENTRY_RE.test(ps(pid, 'args=').toLowerCase());
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 读一个登记文件;读不出返回 null。Claude Code 会就地(非原子)重写登
|
|
42
|
+
// 记,读写相撞读到半个 JSON 时睡一拍重读一次;文件消失不是竞态,不重
|
|
43
|
+
// 试。
|
|
44
|
+
export async function readSessionFile(file) {
|
|
45
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
46
|
+
let text;
|
|
47
|
+
try {
|
|
48
|
+
text = fs.readFileSync(file, 'utf-8');
|
|
49
|
+
} catch {
|
|
50
|
+
return null; // 文件消失不是竞态,不重试
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return deps.parseJson(text);
|
|
54
|
+
} catch {
|
|
55
|
+
if (attempt === 1) return null;
|
|
56
|
+
}
|
|
57
|
+
// 读到半成品文件:睡一拍等 Claude Code 写完再读一次。
|
|
58
|
+
await deps.sleep(100);
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 宽容版 realpath:fs.realpathSync 对不存在的路径抛 ENOENT,这里失败时退
|
|
64
|
+
// 回词法绝对路径(调用方已保证输入是绝对路径)。
|
|
65
|
+
function lexReal(p) {
|
|
66
|
+
try {
|
|
67
|
+
return fs.realpathSync(p);
|
|
68
|
+
} catch {
|
|
69
|
+
return path.resolve(p);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 返回 [拥有活消费者会话的工作区集合, 告警字符串|null]。
|
|
74
|
+
export async function scanSessions() {
|
|
75
|
+
let files = [];
|
|
76
|
+
try {
|
|
77
|
+
files = fs.readdirSync(deps.paths.sessionDir)
|
|
78
|
+
.filter((f) => f.endsWith('.json'))
|
|
79
|
+
.map((f) => path.join(deps.paths.sessionDir, f));
|
|
80
|
+
} catch {
|
|
81
|
+
files = [];
|
|
82
|
+
}
|
|
83
|
+
const interactive = [];
|
|
84
|
+
for (const f of files) {
|
|
85
|
+
const s = await readSessionFile(f);
|
|
86
|
+
if (s && typeof s === 'object' && s.kind === 'interactive') {
|
|
87
|
+
interactive.push(s);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const consumers = new Set();
|
|
92
|
+
const commCache = new Map();
|
|
93
|
+
let liveOther = 0;
|
|
94
|
+
// interactive 认得出来,却读不出有效 pid 或工作目录的条目数:pid/cwd
|
|
95
|
+
// 字段改名就是这种形态,而下面两条自检只看 kind 与进程形态,兜不住。
|
|
96
|
+
let suspect = 0;
|
|
97
|
+
for (const s of interactive) {
|
|
98
|
+
// pid 也是外部程序写的字段:null、非数值、非正数、超 pid_t 的巨大整
|
|
99
|
+
// 数都读不出活进程。这段代码在 per-workspace 保护圈之外,一个畸形登
|
|
100
|
+
// 记抛出去就是整轮巡检失败;缺失时兜底 -1 还会被 kill(-1,0)(对全
|
|
101
|
+
// 部进程的权限探测,不是存在性检查)误读为存活。
|
|
102
|
+
let pid;
|
|
103
|
+
try {
|
|
104
|
+
pid = lenientInt(s.pid);
|
|
105
|
+
} catch {
|
|
106
|
+
suspect += 1; // 正常登记必带可解析 pid:缺失/变质即字段漂移
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!(pid > 0 && pid <= PID_T_MAX)) {
|
|
110
|
+
suspect += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!deps.alive(pid)) continue; // 陈旧登记(崩溃留下):正常,不计
|
|
114
|
+
if (!commCache.has(pid)) {
|
|
115
|
+
commCache.set(pid, deps.pidIsClaude(pid)); // ps 调用按 pid 缓存
|
|
116
|
+
}
|
|
117
|
+
if (commCache.get(pid)) {
|
|
118
|
+
// cwd 同样不可信:非字符串当定位不到工作区;空串与相对路径会被
|
|
119
|
+
// resolve 拼到巡检进程自己的 cwd 下,凭空造出消费者目录,可能令预
|
|
120
|
+
// 热被静默跳过。
|
|
121
|
+
const cwd = s.cwd;
|
|
122
|
+
if (typeof cwd === 'string' && path.isAbsolute(cwd)) {
|
|
123
|
+
const real = lexReal(cwd);
|
|
124
|
+
if (real) {
|
|
125
|
+
consumers.add(real);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
suspect += 1; // 活着的 claude 却没有可用 cwd:cwd 字段漂移
|
|
130
|
+
} else {
|
|
131
|
+
liveOther += 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let alert = null;
|
|
136
|
+
if (files.length > 0 && interactive.length === 0) {
|
|
137
|
+
alert = `会话登记目录 ${deps.paths.sessionDir} 下有 ${files.length} 个登记文件,`
|
|
138
|
+
+ '但没有一个是 interactive 会话;Claude Code 的会话登记格式可能已变更,'
|
|
139
|
+
+ 'cron-up 将识别不到任何现有会话';
|
|
140
|
+
} else if (liveOther > 0 && consumers.size === 0) {
|
|
141
|
+
alert = '会话登记中的存活进程都不是 claude 命令;Claude Code 的启动形态可能'
|
|
142
|
+
+ '已变更,cron-up 可能反复重拉会话';
|
|
143
|
+
} else if (suspect > 0 && consumers.size === 0) {
|
|
144
|
+
alert = '会话登记中的 interactive 会话都读不出有效的 pid 或工作目录;'
|
|
145
|
+
+ 'Claude Code 的会话登记字段可能已变更,cron-up 会把已有会话误判为'
|
|
146
|
+
+ '不存在,在同目录重复拉起会话';
|
|
147
|
+
}
|
|
148
|
+
return [consumers, alert];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
Object.assign(deps, {
|
|
152
|
+
parseJson: (text) => JSON.parse(text),
|
|
153
|
+
pidIsClaude,
|
|
154
|
+
scanSessions,
|
|
155
|
+
});
|
package/src/service.mjs
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// launchd 服务面:launchctl 封装、服务状态查询、node 启动路径的通用解析、
|
|
2
|
+
// plist 渲染、install/uninstall,以及「Node 路径是否失效」的健康检查。
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
import { deps, ExitError } from './internals.mjs';
|
|
9
|
+
import { paths, LABEL, OLD_LABEL } from './paths.mjs';
|
|
10
|
+
// paths.home 仅用于 resolveLauncher 的默认 HOME;其余产物路径一律走
|
|
11
|
+
// deps.paths(测试要整体重定向)。
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_CONFIG,
|
|
14
|
+
} from './constants.mjs';
|
|
15
|
+
import {
|
|
16
|
+
loadConfig,
|
|
17
|
+
saveConfig,
|
|
18
|
+
loadState,
|
|
19
|
+
validateConfig,
|
|
20
|
+
normalizeRoots,
|
|
21
|
+
isInt,
|
|
22
|
+
} from './config.mjs';
|
|
23
|
+
|
|
24
|
+
export function guiTarget() {
|
|
25
|
+
return `gui/${process.getuid()}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// launchctl 调用统一走不抛的 execFile,判成败看 status。
|
|
29
|
+
export function launchctl(...args) {
|
|
30
|
+
return deps.execFile('/bin/launchctl', args);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function launchctlInfo() {
|
|
34
|
+
const r = launchctl('print', `${guiTarget()}/${LABEL}`);
|
|
35
|
+
if (r.status !== 0) return null;
|
|
36
|
+
const info = {};
|
|
37
|
+
for (const key of ['state', 'last exit code']) {
|
|
38
|
+
const m = r.stdout.match(
|
|
39
|
+
new RegExp(`^\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=\\s*(.+)$`, 'm'));
|
|
40
|
+
if (m) info[key] = m[1].trim();
|
|
41
|
+
}
|
|
42
|
+
const iv = r.stdout.match(/^\s*run interval\s*=\s*(\d+)\s*seconds/m);
|
|
43
|
+
if (iv) info.interval = parseInt(iv[1], 10);
|
|
44
|
+
return info;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const LAUNCHD_STATES = new Map([
|
|
48
|
+
['running', '运行中'],
|
|
49
|
+
['not running', '空闲中(按间隔触发)'],
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
export function launchdStateZh(state) {
|
|
53
|
+
// launchd 的 "not running" 是间隔触发型服务两轮之间的正常空闲态,直译
|
|
54
|
+
// 会让人以为服务停了;未识别的值原样保留以便排查。
|
|
55
|
+
if (!state) return '未知';
|
|
56
|
+
return LAUNCHD_STATES.get(state) ?? state;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function serviceLine(info, plistExists) {
|
|
60
|
+
if (info) {
|
|
61
|
+
return `服务:launchd 已加载,${launchdStateZh(info.state)},`
|
|
62
|
+
+ `上次退出码 ${info['last exit code'] ?? '?'}`;
|
|
63
|
+
}
|
|
64
|
+
if (plistExists) {
|
|
65
|
+
return '服务:plist 文件存在但未加载,运行 cron-up install 重新加载';
|
|
66
|
+
}
|
|
67
|
+
return '服务:未安装,运行 cron-up install 安装';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---- node 启动路径解析 ----
|
|
71
|
+
//
|
|
72
|
+
// npm 全局 bin 的 shebang 是 #!/usr/bin/env node,而 launchd 的最小 PATH
|
|
73
|
+
// (/usr/bin:/bin:/usr/sbin:/sbin)里没有 node,所以 plist 必须写死
|
|
74
|
+
// 「node 绝对路径 + 入口脚本绝对路径」。不同 node 安装方式的稳定路径各
|
|
75
|
+
// 不相同,按固定布局逐个探测;都不认识时退回本次安装进程的 execPath。
|
|
76
|
+
|
|
77
|
+
function isExecutable(p) {
|
|
78
|
+
try {
|
|
79
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function semverCompare(a, b) {
|
|
87
|
+
const pa = a.replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0);
|
|
88
|
+
const pb = b.replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0);
|
|
89
|
+
for (let i = 0; i < 3; i++) {
|
|
90
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
91
|
+
}
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// nvm 的 default 别名是文本文件('node'、'lts/*' 或 'v22.2.0'),不是
|
|
96
|
+
// 符号链接;解析不出具体版本时取已安装的最高版本目录(lts/* 的近似)。
|
|
97
|
+
function nvmNodePath(nvmDir) {
|
|
98
|
+
const versionsDir = path.join(nvmDir, 'versions', 'node');
|
|
99
|
+
const pick = (v) => {
|
|
100
|
+
const p = path.join(versionsDir, v, 'bin', 'node');
|
|
101
|
+
return isExecutable(p) ? p : null;
|
|
102
|
+
};
|
|
103
|
+
let pref = null;
|
|
104
|
+
try {
|
|
105
|
+
pref = fs.readFileSync(path.join(nvmDir, 'alias', 'default'), 'utf-8').trim();
|
|
106
|
+
} catch {
|
|
107
|
+
pref = null;
|
|
108
|
+
}
|
|
109
|
+
if (/^v/.test(pref ?? '')) {
|
|
110
|
+
const p = pick(pref);
|
|
111
|
+
if (p) return p;
|
|
112
|
+
}
|
|
113
|
+
if (pref === 'lts/*') {
|
|
114
|
+
try {
|
|
115
|
+
const v = fs.readFileSync(path.join(nvmDir, 'alias', 'lts', '*'), 'utf-8').trim();
|
|
116
|
+
const p = pick(v);
|
|
117
|
+
if (p) return p;
|
|
118
|
+
} catch {
|
|
119
|
+
// 落到最高版本兜底。
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
let all = [];
|
|
123
|
+
try {
|
|
124
|
+
all = fs.readdirSync(versionsDir);
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const versions = all.filter((d) => /^v/.test(d)).sort(semverCompare);
|
|
129
|
+
for (let i = versions.length - 1; i >= 0; i--) {
|
|
130
|
+
const p = pick(versions[i]);
|
|
131
|
+
if (p) return p;
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 返回 {nodePath, entryPath, manager}。注入 home/execPath/entryPath 仅为
|
|
137
|
+
// 测试在临时 HOME 里伪造各安装形态。
|
|
138
|
+
export function resolveLauncher(opts = {}) {
|
|
139
|
+
const home = opts.home ?? paths.home;
|
|
140
|
+
const execPath = opts.execPath ?? process.execPath;
|
|
141
|
+
const entryPath = opts.entryPath
|
|
142
|
+
?? fileURLToPath(new URL('../bin/cron-up.mjs', import.meta.url));
|
|
143
|
+
|
|
144
|
+
// 1. fnm:aliases/default 是跟随 `fnm default` 切换的稳定 symlink,切勿
|
|
145
|
+
// realpath 到版本化目录(fnm 升级后那里会失效)。包含 FNM_DIR 与新
|
|
146
|
+
// 旧两个默认位置。
|
|
147
|
+
const fnmDirs = [
|
|
148
|
+
process.env.FNM_DIR,
|
|
149
|
+
path.join(home, '.local', 'share', 'fnm'),
|
|
150
|
+
path.join(home, '.fnm'),
|
|
151
|
+
].filter(Boolean);
|
|
152
|
+
for (const d of fnmDirs) {
|
|
153
|
+
const nodePath = path.join(d, 'aliases', 'default', 'bin', 'node');
|
|
154
|
+
if (isExecutable(nodePath)) return { nodePath, entryPath, manager: 'fnm' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 2. volta:~/.volta/bin/node 是稳定的版本无关 shim。
|
|
158
|
+
const voltaNode = path.join(home, '.volta', 'bin', 'node');
|
|
159
|
+
if (isExecutable(voltaNode)) {
|
|
160
|
+
return { nodePath: voltaNode, entryPath, manager: 'volta' };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 3. nvm。
|
|
164
|
+
const nvmPath = nvmNodePath(path.join(home, '.nvm'));
|
|
165
|
+
if (nvmPath) return { nodePath: nvmPath, entryPath, manager: 'nvm' };
|
|
166
|
+
|
|
167
|
+
// 4. Homebrew(Apple silicon 与 Intel)与官网 pkg 共用的系统路径。这些
|
|
168
|
+
// 本身就是版本无关的稳定 symlink。systemPaths 仅供测试注入。
|
|
169
|
+
const systemPaths = opts.systemPaths
|
|
170
|
+
?? ['/opt/homebrew/bin/node', '/usr/local/bin/node'];
|
|
171
|
+
for (const cand of systemPaths) {
|
|
172
|
+
if (isExecutable(cand)) {
|
|
173
|
+
return {
|
|
174
|
+
nodePath: cand,
|
|
175
|
+
entryPath,
|
|
176
|
+
manager: cand.includes('homebrew') || isBrewCellar(cand) ? 'brew' : 'pkg',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 5. 兜底:当前安装进程的 node 至少现在能跑;可能是 fnm 多 shell 的易变
|
|
182
|
+
// 路径,标记 note 提示日后核对。
|
|
183
|
+
return {
|
|
184
|
+
nodePath: execPath,
|
|
185
|
+
entryPath,
|
|
186
|
+
manager: 'exec-path',
|
|
187
|
+
note: '未能定位版本无关的 node 路径,已使用当前 node 的绝对路径;'
|
|
188
|
+
+ '切换 Node 版本后请重新运行 cron-up install',
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isBrewCellar(p) {
|
|
193
|
+
try {
|
|
194
|
+
return fs.realpathSync(p).includes(`${path.sep}Cellar${path.sep}`);
|
|
195
|
+
} catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---- plist ----
|
|
201
|
+
|
|
202
|
+
function xmlEscape(s) {
|
|
203
|
+
return String(s)
|
|
204
|
+
.replace(/&/g, '&')
|
|
205
|
+
.replace(/</g, '<')
|
|
206
|
+
.replace(/>/g, '>');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function xmlUnescape(s) {
|
|
210
|
+
return s
|
|
211
|
+
.replace(/</g, '<')
|
|
212
|
+
.replace(/>/g, '>')
|
|
213
|
+
.replace(/&/g, '&');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// key 按字母序排列。ProgramArguments 是
|
|
217
|
+
// [node 绝对路径, 入口脚本绝对路径, 'run']——launchd 的最小 PATH 里没有
|
|
218
|
+
// node,不能写 npm shim。
|
|
219
|
+
export function renderPlist(intervalSeconds, launcher) {
|
|
220
|
+
const args = [launcher.nodePath, launcher.entryPath, 'run']
|
|
221
|
+
.map((a) => `\t\t<string>${xmlEscape(a)}</string>`)
|
|
222
|
+
.join('\n');
|
|
223
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
224
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
225
|
+
<plist version="1.0">
|
|
226
|
+
<dict>
|
|
227
|
+
\t<key>Label</key>
|
|
228
|
+
\t<string>${LABEL}</string>
|
|
229
|
+
\t<key>ProgramArguments</key>
|
|
230
|
+
\t<array>
|
|
231
|
+
${args}
|
|
232
|
+
\t</array>
|
|
233
|
+
\t<key>RunAtLoad</key>
|
|
234
|
+
\t<true/>
|
|
235
|
+
\t<key>StandardErrorPath</key>
|
|
236
|
+
\t<string>${xmlEscape(path.join(deps.paths.logDir, 'launchd.err.log'))}</string>
|
|
237
|
+
\t<key>StandardOutPath</key>
|
|
238
|
+
\t<string>${xmlEscape(path.join(deps.paths.logDir, 'launchd.out.log'))}</string>
|
|
239
|
+
\t<key>StartInterval</key>
|
|
240
|
+
\t<integer>${intervalSeconds}</integer>
|
|
241
|
+
</dict>
|
|
242
|
+
</plist>
|
|
243
|
+
`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 从已安装 plist 的 ProgramArguments 里抠出三个参数(plist 是我们自己写
|
|
247
|
+
// 的,正则足够,不引 plist 解析器)。
|
|
248
|
+
export function readPlistProgramArgs() {
|
|
249
|
+
let text;
|
|
250
|
+
try {
|
|
251
|
+
text = fs.readFileSync(deps.paths.plistPath, 'utf-8');
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
const block = text.match(
|
|
256
|
+
/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
|
|
257
|
+
if (!block) return null;
|
|
258
|
+
const parts = [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)]
|
|
259
|
+
.map((m) => xmlUnescape(m[1]));
|
|
260
|
+
return parts.length === 3 ? parts : null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 总览页健康检查:plist 里写死的 node/入口路径若已不存在(fnm default 被
|
|
264
|
+
// 删、nvm 版本目录被清),launchd 每轮都会静默拉不起来。
|
|
265
|
+
export function launcherHealth() {
|
|
266
|
+
if (!fs.existsSync(deps.paths.plistPath)) return null;
|
|
267
|
+
const args = readPlistProgramArgs();
|
|
268
|
+
if (!args) return null;
|
|
269
|
+
const [nodePath, entryPath] = args;
|
|
270
|
+
const missing = [nodePath, entryPath].filter((p) => !fs.existsSync(p));
|
|
271
|
+
if (missing.length === 0) return null;
|
|
272
|
+
return 'launchd 配置中的 Node 或入口路径已失效('
|
|
273
|
+
+ `${missing.map((p) => path.basename(p)).join('、')} 不存在),巡检当前拉不`
|
|
274
|
+
+ '起来;请重新运行 cron-up install';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---- install / uninstall ----
|
|
278
|
+
|
|
279
|
+
export async function cmdInstall(args) {
|
|
280
|
+
fs.mkdirSync(deps.paths.appSupport, { recursive: true });
|
|
281
|
+
fs.mkdirSync(deps.paths.sessionLogDir, { recursive: true });
|
|
282
|
+
try {
|
|
283
|
+
fs.chmodSync(deps.paths.sessionLogDir, 0o700);
|
|
284
|
+
} catch {
|
|
285
|
+
// 维持现状。
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let cfg;
|
|
289
|
+
if (fs.existsSync(deps.paths.configPath) && !args.force) {
|
|
290
|
+
try {
|
|
291
|
+
cfg = loadConfig(deps.paths.configPath);
|
|
292
|
+
} catch (e) {
|
|
293
|
+
// 损坏配置最自然的修复动作就是重跑 install——当面给出 --force 出口,
|
|
294
|
+
// 而不是只丢下一行解析错误。
|
|
295
|
+
if (e instanceof ExitError) {
|
|
296
|
+
throw new ExitError(e.code,
|
|
297
|
+
`${e.message}\n 丢弃损坏配置重新安装:cron-up install --force`);
|
|
298
|
+
}
|
|
299
|
+
throw e;
|
|
300
|
+
}
|
|
301
|
+
deps.print(`保留现有配置:${deps.paths.configPath}(需要更新时加 --force)`);
|
|
302
|
+
} else {
|
|
303
|
+
// 即使 --force 也从现有配置(全新安装则默认值)起步,只覆盖命令行给
|
|
304
|
+
// 的字段——否则 --force --interval 600 会悄悄把 roots 重置成默认。
|
|
305
|
+
cfg = { ...DEFAULT_CONFIG };
|
|
306
|
+
if (fs.existsSync(deps.paths.configPath)) {
|
|
307
|
+
let old = null;
|
|
308
|
+
try {
|
|
309
|
+
old = JSON.parse(fs.readFileSync(deps.paths.configPath, 'utf-8'));
|
|
310
|
+
} catch {
|
|
311
|
+
old = null;
|
|
312
|
+
}
|
|
313
|
+
// 手改成非对象(123、["a"]):没有可继承的旧值,静默按默认走。
|
|
314
|
+
if (old && typeof old === 'object' && !Array.isArray(old)) {
|
|
315
|
+
cfg = { ...cfg, ...old };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const overrides = {};
|
|
319
|
+
if (args.roots !== undefined) {
|
|
320
|
+
const roots = normalizeRoots(args.roots.split(','));
|
|
321
|
+
if (roots.length === 0) {
|
|
322
|
+
deps.printErr(`--roots 解析后没有有效目录:${JSON.stringify(args.roots)}`);
|
|
323
|
+
throw new ExitError(1);
|
|
324
|
+
}
|
|
325
|
+
overrides.roots = roots;
|
|
326
|
+
}
|
|
327
|
+
// is not undefined,而非 truthiness:--lead 0(「不要提前量」)是合法
|
|
328
|
+
// 选择,不能被悄悄丢掉;--interval 0 也要留到下面的校验被响亮拒绝。
|
|
329
|
+
if (args.interval !== undefined) overrides.intervalSeconds = args.interval;
|
|
330
|
+
if (args.lead !== undefined) overrides.leadSeconds = args.lead;
|
|
331
|
+
if (args.autoRenew !== undefined) overrides.autoRenew = args.autoRenew;
|
|
332
|
+
cfg = { ...cfg, ...overrides };
|
|
333
|
+
cfg.roots = normalizeRoots(cfg.roots ?? []);
|
|
334
|
+
saveConfig(cfg);
|
|
335
|
+
const updated = Object.keys(overrides).join('、') || '全部默认值';
|
|
336
|
+
deps.print(`已写入配置:${deps.paths.configPath}(更新字段:${updated})`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 动系统前先校验生效配置:非法值中止;软问题(提前量过小、目录缺失)
|
|
340
|
+
// 只警告。
|
|
341
|
+
if (!isInt(cfg.intervalSeconds) || cfg.intervalSeconds <= 0) {
|
|
342
|
+
deps.printErr(`intervalSeconds 必须是正整数秒,当前为 ${JSON.stringify(cfg.intervalSeconds)}`);
|
|
343
|
+
throw new ExitError(1);
|
|
344
|
+
}
|
|
345
|
+
if (!isInt(cfg.leadSeconds) || cfg.leadSeconds < 0) {
|
|
346
|
+
deps.printErr(`leadSeconds 必须是非负整数秒,当前为 ${JSON.stringify(cfg.leadSeconds)}`);
|
|
347
|
+
throw new ExitError(1);
|
|
348
|
+
}
|
|
349
|
+
validateConfig(cfg, (m) => deps.print(m));
|
|
350
|
+
if (JSON.stringify(cfg.roots)
|
|
351
|
+
=== JSON.stringify(normalizeRoots(DEFAULT_CONFIG.roots))) {
|
|
352
|
+
// 与 README 安全说明同一句话,在配置落定的这一刻当面再说一遍。
|
|
353
|
+
deps.print('提示:未指定 --roots,扫描范围是整个用户目录,今后 clone 的'
|
|
354
|
+
+ '仓库也会进入巡检;自动续期会把其中的周期任务永久化。建议用 '
|
|
355
|
+
+ '--roots 显式限定范围(见 README 安全说明)');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// npm 包没有「安装二进制」这一步:node 与入口文件已由 npm 就位,这里只
|
|
359
|
+
// 解析它们的稳定绝对路径写给 launchd。
|
|
360
|
+
const launcher = resolveLauncher();
|
|
361
|
+
if (launcher.note) deps.print(`提示:${launcher.note}`);
|
|
362
|
+
|
|
363
|
+
fs.writeFileSync(deps.paths.plistPath,
|
|
364
|
+
renderPlist(cfg.intervalSeconds, launcher));
|
|
365
|
+
deps.print(`已写入 LaunchAgent:${deps.paths.plistPath}`);
|
|
366
|
+
|
|
367
|
+
// doorman 旧 label 若还在,先踢掉,避免两个巡检并行互相换代/回收。
|
|
368
|
+
deps.launchctl('bootout', `${guiTarget()}/${OLD_LABEL}`);
|
|
369
|
+
deps.launchctl('bootout', `${guiTarget()}/${LABEL}`);
|
|
370
|
+
const r = deps.launchctl('bootstrap', guiTarget(), deps.paths.plistPath);
|
|
371
|
+
if (r.status !== 0) {
|
|
372
|
+
deps.printErr(`加载到 launchd 失败:\n${r.stderr.trim()}`);
|
|
373
|
+
throw new ExitError(1);
|
|
374
|
+
}
|
|
375
|
+
deps.launchctl('enable', `${guiTarget()}/${LABEL}`);
|
|
376
|
+
deps.print('已加载到 launchd');
|
|
377
|
+
|
|
378
|
+
// install 与巡检撞车时首轮会被跳过,不能照常说「完成」。
|
|
379
|
+
if (await deps.runPatrol({ config: undefined })) {
|
|
380
|
+
deps.print('首轮巡检完成,查看状态:cron-up');
|
|
381
|
+
} else {
|
|
382
|
+
deps.print('首轮巡检暂未执行(已有一轮在进行),launchd 会在下个间隔自动'
|
|
383
|
+
+ '补跑;查看状态:cron-up');
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export async function cmdUninstall(args) {
|
|
388
|
+
const r = deps.launchctl('bootout', `${guiTarget()}/${LABEL}`);
|
|
389
|
+
if (r.status === 0) deps.print('已从 launchd 卸载');
|
|
390
|
+
|
|
391
|
+
// 服务没了就没有回收者:默认结束自己拉起的保活会话,不留以 bypass 权限
|
|
392
|
+
// 空转的孤儿。--keep-sessions 显式保留(例如马上重装)。
|
|
393
|
+
if (!args.keepSessions) {
|
|
394
|
+
for (const [ws, ent] of Object.entries(loadState())) {
|
|
395
|
+
if (deps.trackedAlive(ent)) {
|
|
396
|
+
deps.print(`正在结束保活会话 ${ws} pid=${ent.pid}`);
|
|
397
|
+
await deps.stopSession(ent);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (fs.existsSync(deps.paths.plistPath)) {
|
|
403
|
+
fs.rmSync(deps.paths.plistPath, { force: true });
|
|
404
|
+
deps.print(`已删除 ${deps.paths.plistPath}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (args.purge) {
|
|
408
|
+
for (const p of [deps.paths.appSupport, deps.paths.logDir]) {
|
|
409
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
410
|
+
deps.print(`已删除 ${p}`);
|
|
411
|
+
}
|
|
412
|
+
// npm 管的文件不能手删:命令行本身交给 npm 卸载。
|
|
413
|
+
deps.print('命令行本身请运行 npm uninstall -g cron-up 卸载');
|
|
414
|
+
} else {
|
|
415
|
+
deps.print('配置与日志已保留(--purge 删除全部产物,--keep-sessions 保留'
|
|
416
|
+
+ '保活会话)');
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
Object.assign(deps, { launchctl, launchctlInfo });
|