cron-up 26.10.30 → 26.10.32

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/src/registry.mjs CHANGED
@@ -1,13 +1,14 @@
1
1
  // Claude Code 会话登记(~/.claude/sessions/*.json)的读取与消费者判定。
2
2
  // 登记是另一个程序未文档化的输出:一旦形状变化,所有会话都会被读成「不
3
- // 存在」,巡检就会在用户会话旁边反复重拉。scanSessions 因此内置四枚烟
4
- // 雾告警,分别盯住四种漂移形态。
3
+ // 存在」,巡检就会反复重拉。scanSessions 因此内置三枚烟雾告警,分别盯住
4
+ // 三种漂移形态。
5
5
  //
6
- // 除「某目录有没有消费者」外,它还回报每个消费者的进程祖先链:cron-up
7
- // 拉起的预热会话是三层结构 script(S) → zsh(Z) → claude(C)(claude 在交
8
- // 互 shell 里是函数,函数帧不做 tail-exec),state 只记得到 S,登记里
9
- // 却是 C,pgid 又被交互 shell 的作业控制改过——唯一稳定的认亲方式是沿
10
- // ppid 上溯。
6
+ // 除「某目录有哪些活会话」外,它回报每个会话的进程祖先链:cron-up 拉起
7
+ // 的预热会话是三层结构 script(S) → zsh(Z) → claude(C)(claude 在交互
8
+ // shell 里是函数,函数帧不做 tail-exec),state 只记得到 S,登记里却是
9
+ // C,pgid 又被交互 shell 的作业控制改过——唯一稳定的认亲方式是沿 ppid
10
+ // 上溯。祖先链每轮恒拍(一次全机 ps,毫秒级):任务级模型下每个预热都
11
+ // 要靠它把 script pid 认成登记里的 sessionId,没有「不需要认亲」的轮次。
11
12
 
12
13
  import fs from 'node:fs';
13
14
  import path from 'node:path';
@@ -35,7 +36,7 @@ function ps(pid, fmt) {
35
36
 
36
37
  // 一次 ps 同时拿进程名与父 pid:{isClaude, ppid}。ppid 按「末尾空白+
37
38
  // 数字」取——comm 可能是含空格的路径,但不会以「空白+数字」结尾;列缺
38
- // 失(ps 失败/输出漂移)时 ppid 为 null,调用方按歧义保守处理。
39
+ // 失(ps 失败/输出漂移)时 ppid 为 null。
39
40
  export function processShape(pid) {
40
41
  const line = ps(pid, 'comm=,ppid=');
41
42
  const m = line.match(/^(.*\S)\s+(\d+)\s*$/);
@@ -49,7 +50,7 @@ export function processShape(pid) {
49
50
  }
50
51
 
51
52
  // 全机进程父子表:一次 ps -axo pid=,ppid= 拍快照,Map<pid, ppid>。任何
52
- // 异常都返回 null(调用方退化为「全部歧义」的保守行为,绝不抛出)。
53
+ // 异常都返回 null(祖先链一律按空链处理:认亲下轮再试,不阻断 spawn)。
53
54
  export function processParentMap() {
54
55
  const r = deps.execFile('/bin/ps', ['-axo', 'pid=,ppid='], { timeout: 5000 });
55
56
  if (r.status !== 0) return null;
@@ -62,8 +63,7 @@ export function processParentMap() {
62
63
  }
63
64
 
64
65
  // 沿父子表上溯(不含 pid 自身),直到 launchd(1;其父记为 0)。中途父 pid
65
- // 在快照里缺失(ps 退化/进程刚好退出)或成环时 chainComplete=false——
66
- // 「没找到目标」与「证据链断了」必须由调用方区分:后者按歧义不双开。
66
+ // 在快照里缺失(ps 退化/进程刚好退出)或成环时 chainComplete=false。
67
67
  export function ancestorChain(pid, parentMap) {
68
68
  const ancestors = [];
69
69
  const seen = new Set([pid]);
@@ -79,9 +79,9 @@ export function ancestorChain(pid, parentMap) {
79
79
  }
80
80
  }
81
81
 
82
- // 本目录活着的消费者会话 id 集合。窗口任务的 createdBySessionId 命中其中
83
- // 任一个,就说明创建它的会话还开着——stampOwner 的最高优先级是尊重活创
84
- // 建者,既不另拉也不过户。
82
+ // 本目录活着的会话 id 集合。任务的 createdBySessionId 命中其中任一个,
83
+ // 就说明创建它的会话还开着——活创建者享有最高优先级:任务铁定投进它,
84
+ // 巡检不预热、不盖印。
85
85
  export function liveSessionIds(details, ws) {
86
86
  return new Set((details?.get(ws) ?? [])
87
87
  .map((d) => d.sessionId)
@@ -89,38 +89,51 @@ export function liveSessionIds(details, ws) {
89
89
  }
90
90
 
91
91
  // 逐任务本原谓词:这条任务的创建者会话还活着吗?liveSids 是同工作区的
92
- // liveSessionIds 快照。认亲分类(聚合布尔)与盖印(逐任务)都从它派生。
92
+ // liveSessionIds 快照。
93
93
  export function creatorSessionAlive(task, liveSids) {
94
94
  return typeof task?.createdBySessionId === 'string'
95
95
  && liveSids.has(task.createdBySessionId);
96
96
  }
97
97
 
98
- // stampOwner 的认亲结论。details/ws 是 scanSessions 的第三元素;ownPid
99
- // 是调用方按指纹判活的本代 script pid(没有活条目时给 null,registry 不
100
- // 认识 state 条目的语义);creatorAlive 表示「至少一个窗口内任务的创建
101
- // 者会话还在本目录活着」:
102
- // own 祖先链里有 ownPid——在场消费者就是我们的预热;
103
- // target 给出可盖印详情,身份字段缺失时为 null;
104
- // creatorAlive 窗口任务的创建者会话还活着——任务按 Claude Code 规则必
105
- // 然投给它,不另拉、不盖印(用户显式要求的最高优先级);
106
- // ambiguous 没有认到自己的预热,但在场会话父链不完整——无法证明不
107
- // 是自己(如 state 丢失的孤儿预热),保守不双开;
108
- // user 以上皆否——创建者已关闭/崩溃,在场的只是无关用户会话
109
- // (或根本没有消费者),可以另拉独立预热承接。
110
- export function classifyWorkspace(details, ws, ownPid, creatorAlive) {
98
+ // 认亲:我们 spawn 的 script pid 是不是本目录某个活登记会话的祖先。命中
99
+ // 即该预热已完成登记、是健康会话,返回登记详情(claude 的 pid/sessionId/
100
+ // procStart);认不到返回 null(会话还在启动,或卡在提问界面)。
101
+ export function claimWarm(details, ws, scriptPid) {
111
102
  const list = details?.get(ws) ?? [];
112
- if (ownPid !== null) {
113
- const own = list.find((d) => d.ancestors.includes(ownPid));
114
- if (own) {
115
- return {
116
- kind: 'own',
117
- target: own.sessionId && own.procStart ? own : null,
118
- };
103
+ return list.find((d) => d.ancestors.includes(scriptPid)) ?? null;
104
+ }
105
+
106
+ // 轻量单会话存活检查:按 sessionId 找登记、pid 活、cwd 匹配。轮末死亡复
107
+ // 核用——轮初还活的预热 script 轮中死去时,要区分「真的死了」与「用户在
108
+ // 终端 --resume 接管成裸 claude」(同一 sessionId、新 pid),后者不算会
109
+ // 话退出。不给 ws 时只看 sid 活不活。
110
+ export function sessionAliveNow(sid, ws = null) {
111
+ if (typeof sid !== 'string') return false;
112
+ let files = [];
113
+ try {
114
+ files = fs.readdirSync(deps.paths.sessionDir)
115
+ .filter((f) => f.endsWith('.json'))
116
+ .map((f) => path.join(deps.paths.sessionDir, f));
117
+ } catch {
118
+ return false;
119
+ }
120
+ for (const f of files) {
121
+ let s;
122
+ try {
123
+ s = JSON.parse(fs.readFileSync(f, 'utf-8'));
124
+ } catch {
125
+ continue; // 可能正被 Claude Code 就地重写
119
126
  }
127
+ if (!isPlainObject(s) || s.sessionId !== sid) continue;
128
+ const pid = parsePid(s.pid);
129
+ if (pid === null || !deps.alive(pid)) continue;
130
+ if (ws !== null) {
131
+ if (typeof s.cwd !== 'string' || !path.isAbsolute(s.cwd)
132
+ || lexReal(s.cwd) !== ws) continue;
133
+ }
134
+ return true;
120
135
  }
121
- if (creatorAlive) return { kind: 'creatorAlive', target: null };
122
- if (list.some((d) => !d.chainComplete)) return { kind: 'ambiguous', target: null };
123
- return { kind: 'user', target: null };
136
+ return false;
124
137
  }
125
138
 
126
139
  // 读一个登记文件;读不出返回 null。Claude Code 会就地(非原子)重写登
@@ -147,10 +160,9 @@ async function readSessionFile(file) {
147
160
 
148
161
  // 返回 [拥有活消费者会话的工作区集合, 告警字符串|null, 会话详情 Map]。
149
162
  // 第三个元素是 Map<cwd, Array<{pid, sessionId, procStart, ancestors,
150
- // chainComplete}>>;祖先链要付一次全机 ps,只有 stampOwner 需要,所以
151
- // lineage 默认关:关时恒为空 Map,总览/list/平静巡检不增加任何进程开销
152
- // (与本仓「全量 ps 按需现拍」的既有纪律一致)。
153
- export async function scanSessions({ lineage = false } = {}) {
163
+ // chainComplete}>>。祖先链恒拍:快照失败给空链,相关预热本轮认不到亲、
164
+ // 下轮自动重试;consumers 判定不受影响。
165
+ export async function scanSessions() {
154
166
  let files = [];
155
167
  try {
156
168
  files = fs.readdirSync(deps.paths.sessionDir)
@@ -167,17 +179,13 @@ export async function scanSessions({ lineage = false } = {}) {
167
179
  }
168
180
  }
169
181
 
170
- // 全轮一张父子表快照:所有会话的祖先链取自同一时刻,且只付一次全量
171
- // ps。仅在 lineage 显式开启时拍——快照失败给 null,祖先链一律按断链
172
- // 处理(stampOwner 退化为「全部歧义、绝不双开」),consumers 判定不受
173
- // 影响。
182
+ // 全轮一张父子表快照:所有会话的祖先链取自同一时刻,且只付一次全机
183
+ // ps。失败给 null,祖先链一律按断链处理(本轮认不到亲)。
174
184
  let parentMap = null;
175
- if (lineage) {
176
- try {
177
- parentMap = deps.processParentMap();
178
- } catch {
179
- parentMap = null;
180
- }
185
+ try {
186
+ parentMap = deps.processParentMap();
187
+ } catch {
188
+ parentMap = null;
181
189
  }
182
190
 
183
191
  const consumers = new Set();
@@ -187,9 +195,6 @@ export async function scanSessions({ lineage = false } = {}) {
187
195
  // interactive 认得出来,却读不出有效 pid 或工作目录的条目数:pid/cwd
188
196
  // 字段改名就是这种形态,而下面两条自检只看 kind 与进程形态,兜不住。
189
197
  let suspect = 0;
190
- // 已是消费者、但进程父 pid 或祖先链读不完整的条目数:stampOwner 会因
191
- // 此无法认亲,只能保守不双开(过户暂停),需要一行告警让人看见退化。
192
- let lineageMissing = 0;
193
198
  for (const s of interactive) {
194
199
  // pid 也是外部程序写的字段:null、非数值、非正数、超 pid_t 的巨大整
195
200
  // 数都读不出活进程。这段代码在 per-workspace 保护圈之外,一个畸形登
@@ -207,29 +212,25 @@ export async function scanSessions({ lineage = false } = {}) {
207
212
  const shape = shapeCache.get(pid);
208
213
  if (shape.isClaude) {
209
214
  // cwd 同样不可信:非字符串当定位不到工作区;空串与相对路径会被
210
- // resolve 拼到巡检进程自己的 cwd 下,凭空造出消费者目录,可能令预
211
- // 热被静默跳过。
215
+ // resolve 拼到巡检进程自己的 cwd 下,凭空造出消费者目录。
212
216
  const cwd = s.cwd;
213
217
  if (typeof cwd === 'string' && path.isAbsolute(cwd)) {
214
218
  const ws = lexReal(cwd);
215
219
  consumers.add(ws);
216
- if (lineage) {
217
- const chain = parentMap
218
- ? ancestorChain(pid, parentMap)
219
- : { ancestors: [], chainComplete: false };
220
- if (shape.ppid === null || !chain.chainComplete) lineageMissing += 1;
221
- const list = details.get(ws) ?? [];
222
- list.push({
223
- pid,
224
- sessionId: typeof s.sessionId === 'string' ? s.sessionId : null,
225
- // procStart 是 ps lstart 原文(单位数日期含双空格),只许原样
226
- // 传递给任务文件,绝不能自己拼。
227
- procStart: typeof s.procStart === 'string' ? s.procStart : null,
228
- ancestors: chain.ancestors,
229
- chainComplete: chain.chainComplete,
230
- });
231
- details.set(ws, list);
232
- }
220
+ const chain = parentMap
221
+ ? ancestorChain(pid, parentMap)
222
+ : { ancestors: [], chainComplete: false };
223
+ const list = details.get(ws) ?? [];
224
+ list.push({
225
+ pid,
226
+ sessionId: typeof s.sessionId === 'string' ? s.sessionId : null,
227
+ // procStart 是 ps lstart 原文(单位数日期含双空格),只许原样
228
+ // 传递给任务文件,绝不能自己拼。
229
+ procStart: typeof s.procStart === 'string' ? s.procStart : null,
230
+ ancestors: chain.ancestors,
231
+ chainComplete: chain.chainComplete,
232
+ });
233
+ details.set(ws, list);
233
234
  continue;
234
235
  }
235
236
  suspect += 1; // 活着的 claude 却没有可用 cwd:cwd 字段漂移
@@ -249,11 +250,7 @@ export async function scanSessions({ lineage = false } = {}) {
249
250
  } else if (suspect > 0 && consumers.size === 0) {
250
251
  alert = '会话登记中的 interactive 会话都读不出有效的 pid 或工作目录;'
251
252
  + 'Claude Code 的会话登记字段可能已变更,cron-up 会把已有会话误判为'
252
- + '不存在,在同目录重复拉起会话';
253
- } else if (lineage && lineageMissing > 0) {
254
- alert = `${lineageMissing} 个存活会话的进程父子关系读不完整(ps 输出异常或`
255
- + '会话进程正在退出);stampOwner 本轮按保守策略不另拉预热会话、暂停'
256
- + '任务过户,下轮自动恢复';
253
+ + '不存在,重复拉起预热会话';
257
254
  }
258
255
  return [consumers, alert, details];
259
256
  }
@@ -264,6 +261,7 @@ Object.assign(deps, {
264
261
  ancestorChain,
265
262
  liveSessionIds,
266
263
  creatorSessionAlive,
267
- classifyWorkspace,
264
+ claimWarm,
265
+ sessionAliveNow,
268
266
  scanSessions,
269
267
  });
package/src/service.mjs CHANGED
@@ -7,26 +7,22 @@ import path from 'node:path';
7
7
  import {
8
8
  deps,
9
9
  ExitError,
10
- isPlainObject,
11
10
  ensurePrivateDir,
12
11
  } from './internals.mjs';
13
12
  import { paths, LABEL } from './paths.mjs';
14
- import { SPECS, thisCamel } from './args.mjs';
15
- // paths.home 仅用于 resolveLauncher 的默认 HOME;其余产物路径一律走
16
- // deps.paths(测试要整体重定向)。
17
- import { scriptProcLogPath } from './sessions.mjs';
18
13
  import {
19
14
  DEFAULT_CONFIG,
15
+ INTERVAL_SECONDS,
20
16
  } from './constants.mjs';
21
17
  import {
22
18
  loadConfig,
23
19
  saveConfig,
24
20
  loadState,
21
+ loadLegacyEntries,
25
22
  validateConfig,
26
23
  normalizeRoots,
27
- isInt,
28
- configFieldErrors,
29
24
  } from './config.mjs';
25
+ import { scriptProcLogPath } from './sessions.mjs';
30
26
 
31
27
  function guiTarget() {
32
28
  return `gui/${process.getuid()}`;
@@ -82,7 +78,7 @@ export function serviceLine(info, plistExists) {
82
78
  // npm 全局 bin 的 shebang 是 #!/usr/bin/env node,而 launchd 的最小 PATH
83
79
  // (/usr/bin:/bin:/usr/sbin:/sbin)里没有 node。安装时把「node 与 cron-up
84
80
  // 所在的 bin 目录」写进启动脚本的 PATH,之后每轮由脚本自己解析:npm 升
85
- // 级、切换默认版本都自动跟随,且 node 与包永远同版本。
81
+ // 级、切换默认版本都自动跟随。
86
82
 
87
83
  function isExecutable(p) {
88
84
  try {
@@ -98,9 +94,7 @@ function isExecutable(p) {
98
94
  // aliases/default/bin 是跟随 `fnm default` 切换的稳定 symlink(切勿
99
95
  // realpath 到版本化目录,fnm 升级后那里会失效);volta 的 ~/.volta/bin、
100
96
  // Homebrew 与官网 pkg 的系统 bin 同理是版本无关的稳定入口。都不认识时退
101
- // 回当前安装进程自己的 bin 目录:nvm 等按版本目录安装的形态由此覆盖(node
102
- // 与 cron-up 同目录,npm 升级自动跟随),代价是绑定装包时的版本目录——切
103
- // 换默认版本后要重跑 install 才切过去。
97
+ // 回当前安装进程自己的 bin 目录:nvm 等按版本目录安装的形态由此覆盖。
104
98
  export function resolveLauncher(opts = {}) {
105
99
  const home = opts.home ?? paths.home;
106
100
  const execPath = opts.execPath ?? process.execPath;
@@ -144,27 +138,25 @@ function xmlUnescape(s) {
144
138
  }
145
139
 
146
140
  // 生成 launchd 拉起的启动脚本:ProgramArguments[0] 指向它而非 node。
147
- // macOS 的后台项目列表(系统设置 → 登录项与扩展 → 允许在后台)按
148
- // ProgramArguments[0] 的文件名归组条目:直连 node 会与所有 node 系后台服务
149
- // 合并显示在「Node.js Foundation」(node 的签名者)名下;指向自有脚本则
150
- // 显示 cron-up-service。脚本把 node 所在 bin 目录放进 PATH 后直接
151
- // exec cron-up run:npm 升级自动跟随,无需重跑 install(node 与 cron-up
152
- // 同目录,天然同版本)。
141
+ // macOS 的后台项目列表按 ProgramArguments[0] 的文件名归组条目:直连
142
+ // node 会归到「Node.js Foundation」(node 的签名者)名下;指向自有脚本
143
+ // 则显示 cron-up-service。脚本把 node 所在 bin 目录放进 PATH 后直接
144
+ // exec cron-up run:npm 升级自动跟随,无需重跑 install。
153
145
  export function writeServiceScript(launcher) {
154
146
  const script = deps.paths.serviceScriptPath;
155
147
  fs.mkdirSync(path.dirname(script), { recursive: true });
156
148
  const dirs = launcher.pathDirs.map((d) => `"${d}"`).join(':');
157
149
  fs.writeFileSync(script,
158
150
  '#!/bin/bash\n'
159
- + '# 由 cron-up install 生成;重跑 cron-up install --force 覆盖重生成\n'
151
+ + '# 由 cron-up install 生成;重跑 cron-up install 覆盖重生成\n'
160
152
  + `export PATH=${dirs}:"$PATH"\n`
161
153
  + 'exec cron-up run\n');
162
154
  fs.chmodSync(script, 0o755);
163
155
  return script;
164
156
  }
165
157
 
166
- // ProgramArguments 单参数(启动脚本路径)。
167
- export function renderPlist(intervalSeconds, scriptPath) {
158
+ // ProgramArguments 单参数(启动脚本路径)。间隔固定为 INTERVAL_SECONDS。
159
+ export function renderPlist(scriptPath) {
168
160
  return `<?xml version="1.0" encoding="UTF-8"?>
169
161
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
170
162
  <plist version="1.0">
@@ -182,7 +174,7 @@ export function renderPlist(intervalSeconds, scriptPath) {
182
174
  \t<key>StandardOutPath</key>
183
175
  \t<string>${xmlEscape(path.join(deps.paths.logDir, 'launchd.out.log'))}</string>
184
176
  \t<key>StartInterval</key>
185
- \t<integer>${intervalSeconds}</integer>
177
+ \t<integer>${INTERVAL_SECONDS}</integer>
186
178
  </dict>
187
179
  </plist>
188
180
  `;
@@ -219,9 +211,7 @@ function scriptPathDirs(scriptPath) {
219
211
  }
220
212
 
221
213
  // 总览页健康检查:launchd 每轮按启动脚本拉起巡检,脚本丢失、被手改或
222
- // PATH 目录里解析不到 cron-up/node(典型断点:切换默认 Node 版本后忘了
223
- // 在新版本里装包、版本目录被删)时都会静默拉不起来,这是总览唯一能发现
224
- // 它的地方。
214
+ // PATH 目录里解析不到 cron-up/node 时都会静默拉不起来。
225
215
  export function launcherHealth() {
226
216
  if (!fs.existsSync(deps.paths.plistPath)) return null;
227
217
  const args = readPlistProgramArgs();
@@ -251,138 +241,60 @@ export function launcherHealth() {
251
241
 
252
242
  // ---- install / uninstall ----
253
243
 
254
- // install 的可覆盖项(配置键 → 命令行旗标):--force 缺席时逐项点名「未
255
- // 生效的参数」。名单直接从解析器的选项表派生——force 不落盘,其余选项都
256
- // 会写进配置;新加可持久化选项时这里自动跟进,不会静默漏报。
257
- const IGNORED_INSTALL_FLAGS = Object.keys(SPECS.install.options)
258
- .filter((name) => name !== 'force')
259
- .map((name) => [thisCamel(name), `--${name}`]);
244
+ // roots 三选一:命令行显式值 > 现有配置 > 默认。现有配置损坏不阻断安
245
+ // 装——install 本身就是最自然的修复动作,回退默认并当面说明。
246
+ function resolveRoots(args) {
247
+ if (args.roots !== undefined) {
248
+ const roots = normalizeRoots(args.roots.split(','));
249
+ if (roots.length === 0) {
250
+ deps.printErr(`--roots 解析后没有有效目录:${JSON.stringify(args.roots)}`);
251
+ throw new ExitError(1);
252
+ }
253
+ return roots;
254
+ }
255
+ if (fs.existsSync(deps.paths.configPath)) {
256
+ try {
257
+ return loadConfig(deps.paths.configPath).roots;
258
+ } catch (e) {
259
+ deps.print(`提示:现有配置无法解读(${e.message}),本次按默认扫描目录`
260
+ + '重写;如需其他范围用 cron-up install --roots 指定');
261
+ }
262
+ }
263
+ return normalizeRoots(DEFAULT_CONFIG.roots);
264
+ }
260
265
 
261
266
  export async function cmdInstall(args) {
262
- // --config 在这里只会造成误解:install 写盘、launchd 每轮读取的都是固定
263
- // 路径的配置,指定别的路径不改变任何行为——静默忽略等于让人以为装到了
264
- // 别处。查询与巡检命令(run/renew/list/总览)才真正支持 --config。
267
+ // install 写盘、launchd 每轮读取的都是固定路径配置,--config 只会造成
268
+ // 误解。
265
269
  if (args.config !== undefined) {
266
270
  throw new ExitError(2, 'install 不支持 --config:配置与 LaunchAgent 固定'
267
- + `使用 ${deps.paths.configPath},如需临时改配置请直接编辑该文件`);
271
+ + `使用 ${deps.paths.configPath}`);
268
272
  }
269
273
  fs.mkdirSync(deps.paths.dataDir, { recursive: true });
270
- // dataDir 只建不 chmod:本分支可能保留旧配置而不写盘,不搭车改权限。
271
274
  ensurePrivateDir(deps.paths.sessionLogDir);
272
275
 
273
- let cfg;
274
- if (fs.existsSync(deps.paths.configPath) && !args.force) {
275
- try {
276
- cfg = loadConfig(deps.paths.configPath);
277
- } catch (e) {
278
- // 损坏配置最自然的修复动作就是重跑 install——当面给出 --force 出口,
279
- // 而不是只丢下一行解析错误。
280
- if (e instanceof ExitError) {
281
- throw new ExitError(e.code,
282
- `${e.message}\n 丢弃损坏配置重新安装:cron-up install --force`);
283
- }
284
- throw e;
285
- }
286
- deps.print(`保留现有配置:${deps.paths.configPath}(需要更新时加 --force)`);
287
- // 不带 --force 时配置原样保留,命令行显式给的参数一律不生效:点名说清
288
- // 被忽略的是哪些,而不是让用户以为已经改了。
289
- const ignored = IGNORED_INSTALL_FLAGS
290
- .filter(([k]) => args[k] !== undefined).map(([, flag]) => flag);
291
- if (ignored.length > 0) {
292
- deps.print(`警告:参数 ${ignored.join('、')} 未生效(现有配置保留);`
293
- + '要更新这些字段请带 --force 重跑,--force 只覆盖显式给出的字段');
294
- }
295
- } else {
296
- // 即使 --force 也从现有配置(全新安装则默认值)起步,只覆盖命令行给
297
- // 的字段——否则 --force --interval 600 会悄悄把 roots 重置成默认。
298
- cfg = { ...DEFAULT_CONFIG };
299
- let inherited = false;
300
- if (fs.existsSync(deps.paths.configPath)) {
301
- let old = null;
302
- try {
303
- old = JSON.parse(fs.readFileSync(deps.paths.configPath, 'utf-8'));
304
- } catch {
305
- // 读不出(损坏 JSON):没有可继承的旧值。
306
- }
307
- // 手改成非对象(123、["a"]):同样没有可继承的旧值,静默按默认走。
308
- if (isPlainObject(old)) {
309
- // 类型坏的字段不继承(回到默认值)并当面说明,其余照旧——force 不
310
- // 该把类型错误写进新配置,也不该因一个字段坏了丢掉其余设置。
311
- const errs = configFieldErrors({ ...DEFAULT_CONFIG, ...old });
312
- for (const [key, msg] of errs) {
313
- delete old[key];
314
- deps.print(`提示:旧配置的 ${msg},未继承,采用默认值`);
315
- }
316
- cfg = { ...cfg, ...old };
317
- inherited = true;
318
- }
319
- }
320
- const overrides = {};
321
- if (args.roots !== undefined) {
322
- const roots = normalizeRoots(args.roots.split(','));
323
- if (roots.length === 0) {
324
- deps.printErr(`--roots 解析后没有有效目录:${JSON.stringify(args.roots)}`);
325
- throw new ExitError(1);
326
- }
327
- overrides.roots = roots;
328
- }
329
- // is not undefined,而非 truthiness:--lead 0(「不要提前量」)是合法
330
- // 选择,不能被悄悄丢掉。值域就地拦且必须在 saveConfig 之前:文件继承
331
- // 的值已由 loadConfig / configFieldErrors 把关,命令行值只经过整数解
332
- // 析——被拒的安装若把非法值写进配置,此后每轮巡检都会以退出码 2 死掉。
333
- if (args.interval !== undefined) {
334
- if (!isInt(args.interval) || args.interval <= 0) {
335
- deps.printErr(`--interval 必须是正整数秒,当前为 ${JSON.stringify(args.interval)}`);
336
- throw new ExitError(1);
337
- }
338
- overrides.intervalSeconds = args.interval;
339
- }
340
- if (args.lead !== undefined) {
341
- if (!isInt(args.lead) || args.lead < 0) {
342
- deps.printErr(`--lead 必须是非负整数秒,当前为 ${JSON.stringify(args.lead)}`);
343
- throw new ExitError(1);
344
- }
345
- overrides.leadSeconds = args.lead;
346
- }
347
- if (args.autoRenew !== undefined) overrides.autoRenew = args.autoRenew;
348
- if (args.autoMinId !== undefined) overrides.autoMinId = args.autoMinId;
349
- if (args.stampOwner !== undefined) overrides.stampOwner = args.stampOwner;
350
- if (args.sessionRetain !== undefined) {
351
- if (args.sessionRetain !== 'window' && args.sessionRetain !== 'always') {
352
- deps.printErr(`--session-retain 只接受 window 或 always,当前为 ${JSON.stringify(args.sessionRetain)}`);
353
- throw new ExitError(1);
354
- }
355
- overrides.sessionRetain = args.sessionRetain;
356
- }
357
- cfg = { ...cfg, ...overrides };
358
- cfg.roots = normalizeRoots(cfg.roots ?? []);
359
- saveConfig(cfg);
360
- const updated = Object.keys(overrides).join('、') || '无';
361
- const rest = inherited ? '其余继承现有配置' : '全部默认值';
362
- deps.print(`已写入配置:${deps.paths.configPath}(更新字段:${updated},${rest})`);
363
- }
276
+ // 每次 install 都重写配置(纯化旧版本留下的已废弃键)、脚本与 plist:
277
+ // 这也是升级 Node、改扫描范围后的统一修复入口。
278
+ const cfg = { roots: resolveRoots(args) };
279
+ saveConfig(cfg);
280
+ deps.print(`已写入配置:${deps.paths.configPath}(扫描目录:${cfg.roots.join('、')})`);
364
281
 
365
- // 命令行值的硬校验已在落盘前完成;软问题(提前量过小、目录缺失)只警告。
282
+ // 软问题(目录不存在)只警告。
366
283
  validateConfig(cfg, (m) => deps.print(m));
367
284
  if (JSON.stringify(cfg.roots)
368
285
  === JSON.stringify(normalizeRoots(DEFAULT_CONFIG.roots))) {
369
- // 与 README 安全说明同一句话,在配置落定的这一刻当面再说一遍。默认目
370
- // 录清单从 DEFAULT_CONFIG 插值:改默认值时提示不会留在旧清单上。
286
+ // 与 README 安全说明同一句话,在配置落定的这一刻当面再说一遍。
371
287
  deps.print(`提示:未指定 --roots,默认扫描 ${DEFAULT_CONFIG.roots.join('、')}`
372
288
  + '(不存在的目录跳过);今后 clone 进这些容器的仓库也会进入巡检,'
373
- + '自动续期会把其中的周期任务永久化。目录布局不同或需收窄时用 '
289
+ + '其中的周期任务会被自动续期永久化。目录布局不同或需收窄时用 '
374
290
  + '--roots 指定(见 README 安全说明)');
375
291
  }
376
292
 
377
- // npm 包没有「安装二进制」这一步:node 与入口文件已由 npm 就位,这里只
378
- // 解析它们所在的 bin 目录,写进启动脚本(plist 只指向脚本,让后台项目
379
- // 列表显示 cron-up-service 而非 node 的签名者)。
380
293
  const launcher = resolveLauncher();
381
294
  if (launcher.note) deps.print(`提示:${launcher.note}`);
382
295
 
383
296
  const script = writeServiceScript(launcher);
384
- fs.writeFileSync(deps.paths.plistPath,
385
- renderPlist(cfg.intervalSeconds, script));
297
+ fs.writeFileSync(deps.paths.plistPath, renderPlist(script));
386
298
  deps.print(`已写入启动脚本与 LaunchAgent:${script}、${deps.paths.plistPath}`);
387
299
 
388
300
  deps.launchctl('bootout', `${guiTarget()}/${LABEL}`);
@@ -403,9 +315,40 @@ export async function cmdInstall(args) {
403
315
  }
404
316
  }
405
317
 
318
+ // 遍历全部 tracked 预热:新版嵌套 state、旧版扁平 state、state 丢失时的
319
+ // ps 孤儿(日志位于会话日志目录即可,不区分两代命名)。
320
+ function* allTrackedWarmups() {
321
+ const handled = new Set();
322
+ for (const bucket of Object.values(loadState())) {
323
+ for (const [taskId, ent] of Object.entries(bucket)) {
324
+ if (deps.trackedAlive(ent)) {
325
+ handled.add(ent.pid);
326
+ yield { label: `${ent.ws} 任务 ${taskId}`, ent, orphan: false };
327
+ }
328
+ }
329
+ }
330
+ for (const ent of loadLegacyEntries()) {
331
+ if (ent.pid) handled.add(ent.pid);
332
+ if (deps.trackedAlive(ent)) {
333
+ yield { label: ent.ws, ent, orphan: false };
334
+ }
335
+ }
336
+ // state 丢失的兜底:没进登记的孤儿从进程表按日志目录认。
337
+ for (const [pid, procArgs] of deps.listScriptProcesses()) {
338
+ if (handled.has(pid)) continue;
339
+ const log = scriptProcLogPath(procArgs);
340
+ if (log === null
341
+ || !log.startsWith(`${deps.paths.sessionLogDir}${path.sep}`)) continue;
342
+ const ent = { pid, procStart: deps.procStartedAt(pid) };
343
+ if (deps.trackedAlive(ent)) {
344
+ yield { label: `pid=${pid}(state 已丢失)`, ent, orphan: true };
345
+ }
346
+ }
347
+ }
348
+
406
349
  export async function cmdUninstall(args) {
407
- // 同 install:uninstall 动的是本机固定的安装产物,--config 只会让人以为
408
- // 卸载的是另一套安装(--purge 删的也是真实目录)。
350
+ // uninstall 动的是本机固定的安装产物,--config 只会让人以为卸载的是
351
+ // 另一套安装。
409
352
  if (args.config !== undefined) {
410
353
  throw new ExitError(2, 'uninstall 不支持 --config:卸载操作的是本机固定'
411
354
  + `的安装产物(${deps.paths.plistPath} 等)`);
@@ -413,34 +356,15 @@ export async function cmdUninstall(args) {
413
356
  const r = deps.launchctl('bootout', `${guiTarget()}/${LABEL}`);
414
357
  if (r.status === 0) deps.print('已从 launchd 卸载');
415
358
 
416
- // 服务没了就没有回收者:默认结束自己拉起的保活会话,不留以 bypass 权限
417
- // 空转的孤儿。--keep-sessions 显式保留(例如马上重装)。各会话的停轮询
418
- // (SIGTERM→等→SIGKILL)互不依赖,先逐个发信号再并行等齐,N 个会话不
419
- // 用串行吃 N 轮等待。
420
- if (!args.keepSessions) {
421
- const stopping = [];
422
- const handled = new Set();
423
- for (const [ws, ent] of Object.entries(loadState())) {
424
- if (deps.trackedAlive(ent)) {
425
- handled.add(ent.pid);
426
- deps.print(`正在结束保活会话 ${ws} pid=${ent.pid}`);
427
- stopping.push(deps.stopSession(ent));
428
- }
429
- }
430
- // state 丢失时的兜底:没进登记的孤儿会话(卡在提问界面就不会出现在
431
- // state 里)从进程表按日志目录精确认领。
432
- for (const [pid, procArgs] of deps.listScriptProcesses()) {
433
- if (handled.has(pid)) continue; // state 循环已发起,别对同 pid 二次发信号
434
- const log = scriptProcLogPath(procArgs);
435
- if (log === null
436
- || !log.startsWith(`${deps.paths.sessionLogDir}${path.sep}`)) continue;
437
- const ent = { pid, procStart: deps.procStartedAt(pid) };
438
- if (!deps.trackedAlive(ent)) continue;
439
- deps.print(`正在结束保活会话(state 已丢失) pid=${pid}`);
440
- stopping.push(deps.stopSession(ent));
441
- }
442
- await Promise.all(stopping);
359
+ // 服务没了就没有回收者:默认结束全部预热会话,不留以 bypass 权限空转
360
+ // 的孤儿。各会话的停轮询(SIGTERM→等→SIGKILL)互不依赖,先逐个发信
361
+ // 号再并行等齐。
362
+ const stopping = [];
363
+ for (const { label, ent } of allTrackedWarmups()) {
364
+ deps.print(`正在结束预热会话 ${label}`);
365
+ stopping.push(deps.stopSession(ent));
443
366
  }
367
+ await Promise.all(stopping);
444
368
 
445
369
  if (fs.existsSync(deps.paths.plistPath)) {
446
370
  fs.rmSync(deps.paths.plistPath, { force: true });
@@ -455,8 +379,7 @@ export async function cmdUninstall(args) {
455
379
  // npm 管的文件不能手删:命令行本身交给 npm 卸载。
456
380
  deps.print('命令行本身请运行 npm uninstall -g cron-up 卸载');
457
381
  } else {
458
- deps.print('配置与日志已保留(--purge 删除全部产物,--keep-sessions 保留'
459
- + '保活会话)');
382
+ deps.print('配置与日志已保留(--purge 可删除全部产物)');
460
383
  }
461
384
  }
462
385