cron-up 26.10.25 → 26.10.27

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/service.mjs CHANGED
@@ -4,8 +4,14 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
 
7
- import { deps, ExitError } from './internals.mjs';
7
+ import {
8
+ deps,
9
+ ExitError,
10
+ isPlainObject,
11
+ ensurePrivateDir,
12
+ } from './internals.mjs';
8
13
  import { paths, LABEL } from './paths.mjs';
14
+ import { SPECS, thisCamel } from './args.mjs';
9
15
  // paths.home 仅用于 resolveLauncher 的默认 HOME;其余产物路径一律走
10
16
  // deps.paths(测试要整体重定向)。
11
17
  import { scriptProcLogPath } from './sessions.mjs';
@@ -22,7 +28,7 @@ import {
22
28
  configFieldErrors,
23
29
  } from './config.mjs';
24
30
 
25
- export function guiTarget() {
31
+ function guiTarget() {
26
32
  return `gui/${process.getuid()}`;
27
33
  }
28
34
 
@@ -33,7 +39,9 @@ export function launchctl(...args) {
33
39
  }
34
40
 
35
41
  export function launchctlInfo() {
36
- const r = launchctl('print', `${guiTarget()}/${LABEL}`);
42
+ // 走 deps 而非同文件裸名:与 bootout/bootstrap 的打桩面保持一致,测试可
43
+ // 在 launchctl 一层整体模拟 launchd。
44
+ const r = deps.launchctl('print', `${guiTarget()}/${LABEL}`);
37
45
  if (r.status !== 0) return null;
38
46
  const info = {};
39
47
  for (const key of ['state', 'last exit code']) {
@@ -51,7 +59,7 @@ const LAUNCHD_STATES = new Map([
51
59
  ['not running', '空闲中(按间隔触发)'],
52
60
  ]);
53
61
 
54
- export function launchdStateZh(state) {
62
+ function launchdStateZh(state) {
55
63
  // launchd 的 "not running" 是间隔触发型服务两轮之间的正常空闲态,直译
56
64
  // 会让人以为服务停了;未识别的值原样保留以便排查。
57
65
  if (!state) return '未知';
@@ -243,6 +251,13 @@ export function launcherHealth() {
243
251
 
244
252
  // ---- install / uninstall ----
245
253
 
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}`]);
260
+
246
261
  export async function cmdInstall(args) {
247
262
  // --config 在这里只会造成误解:install 写盘、launchd 每轮读取的都是固定
248
263
  // 路径的配置,指定别的路径不改变任何行为——静默忽略等于让人以为装到了
@@ -252,12 +267,8 @@ export async function cmdInstall(args) {
252
267
  + `使用 ${deps.paths.configPath},如需临时改配置请直接编辑该文件`);
253
268
  }
254
269
  fs.mkdirSync(deps.paths.dataDir, { recursive: true });
255
- fs.mkdirSync(deps.paths.sessionLogDir, { recursive: true });
256
- try {
257
- fs.chmodSync(deps.paths.sessionLogDir, 0o700);
258
- } catch {
259
- // 维持现状。
260
- }
270
+ // dataDir 只建不 chmod:本分支可能保留旧配置而不写盘,不搭车改权限。
271
+ ensurePrivateDir(deps.paths.sessionLogDir);
261
272
 
262
273
  let cfg;
263
274
  if (fs.existsSync(deps.paths.configPath) && !args.force) {
@@ -275,13 +286,8 @@ export async function cmdInstall(args) {
275
286
  deps.print(`保留现有配置:${deps.paths.configPath}(需要更新时加 --force)`);
276
287
  // 不带 --force 时配置原样保留,命令行显式给的参数一律不生效:点名说清
277
288
  // 被忽略的是哪些,而不是让用户以为已经改了。
278
- const ignored = [];
279
- if (args.roots !== undefined) ignored.push('--roots');
280
- if (args.interval !== undefined) ignored.push('--interval');
281
- if (args.lead !== undefined) ignored.push('--lead');
282
- if (args.autoRenew !== undefined) ignored.push('--auto-renew');
283
- if (args.autoMinId !== undefined) ignored.push('--auto-min-id');
284
- if (args.sessionRetain !== undefined) ignored.push('--session-retain');
289
+ const ignored = IGNORED_INSTALL_FLAGS
290
+ .filter(([k]) => args[k] !== undefined).map(([, flag]) => flag);
285
291
  if (ignored.length > 0) {
286
292
  deps.print(`警告:参数 ${ignored.join('、')} 未生效(现有配置保留);`
287
293
  + '要更新这些字段请带 --force 重跑,--force 只覆盖显式给出的字段');
@@ -296,10 +302,10 @@ export async function cmdInstall(args) {
296
302
  try {
297
303
  old = JSON.parse(fs.readFileSync(deps.paths.configPath, 'utf-8'));
298
304
  } catch {
299
- old = null;
305
+ // 读不出(损坏 JSON):没有可继承的旧值。
300
306
  }
301
- // 手改成非对象(123、["a"]):没有可继承的旧值,静默按默认走。
302
- if (old && typeof old === 'object' && !Array.isArray(old)) {
307
+ // 手改成非对象(123、["a"]):同样没有可继承的旧值,静默按默认走。
308
+ if (isPlainObject(old)) {
303
309
  // 类型坏的字段不继承(回到默认值)并当面说明,其余照旧——force 不
304
310
  // 该把类型错误写进新配置,也不该因一个字段坏了丢掉其余设置。
305
311
  const errs = configFieldErrors({ ...DEFAULT_CONFIG, ...old });
@@ -359,8 +365,9 @@ export async function cmdInstall(args) {
359
365
  validateConfig(cfg, (m) => deps.print(m));
360
366
  if (JSON.stringify(cfg.roots)
361
367
  === JSON.stringify(normalizeRoots(DEFAULT_CONFIG.roots))) {
362
- // 与 README 安全说明同一句话,在配置落定的这一刻当面再说一遍。
363
- deps.print('提示:未指定 --roots,默认扫描 ~/space、~/workspace、~/tasks'
368
+ // 与 README 安全说明同一句话,在配置落定的这一刻当面再说一遍。默认目
369
+ // 录清单从 DEFAULT_CONFIG 插值:改默认值时提示不会留在旧清单上。
370
+ deps.print(`提示:未指定 --roots,默认扫描 ${DEFAULT_CONFIG.roots.join('、')}`
364
371
  + '(不存在的目录跳过);今后 clone 进这些容器的仓库也会进入巡检,'
365
372
  + '自动续期会把其中的周期任务永久化。目录布局不同或需收窄时用 '
366
373
  + '--roots 指定(见 README 安全说明)');
@@ -406,25 +413,32 @@ export async function cmdUninstall(args) {
406
413
  if (r.status === 0) deps.print('已从 launchd 卸载');
407
414
 
408
415
  // 服务没了就没有回收者:默认结束自己拉起的保活会话,不留以 bypass 权限
409
- // 空转的孤儿。--keep-sessions 显式保留(例如马上重装)。
416
+ // 空转的孤儿。--keep-sessions 显式保留(例如马上重装)。各会话的停轮询
417
+ // (SIGTERM→等→SIGKILL)互不依赖,先逐个发信号再并行等齐,N 个会话不
418
+ // 用串行吃 N 轮等待。
410
419
  if (!args.keepSessions) {
420
+ const stopping = [];
421
+ const handled = new Set();
411
422
  for (const [ws, ent] of Object.entries(loadState())) {
412
423
  if (deps.trackedAlive(ent)) {
424
+ handled.add(ent.pid);
413
425
  deps.print(`正在结束保活会话 ${ws} pid=${ent.pid}`);
414
- await deps.stopSession(ent);
426
+ stopping.push(deps.stopSession(ent));
415
427
  }
416
428
  }
417
429
  // state 丢失时的兜底:没进登记的孤儿会话(卡在提问界面就不会出现在
418
430
  // state 里)从进程表按日志目录精确认领。
419
431
  for (const [pid, procArgs] of deps.listScriptProcesses()) {
432
+ if (handled.has(pid)) continue; // state 循环已发起,别对同 pid 二次发信号
420
433
  const log = scriptProcLogPath(procArgs);
421
434
  if (log === null
422
435
  || !log.startsWith(`${deps.paths.sessionLogDir}${path.sep}`)) continue;
423
436
  const ent = { pid, procStart: deps.procStartedAt(pid) };
424
437
  if (!deps.trackedAlive(ent)) continue;
425
438
  deps.print(`正在结束保活会话(state 已丢失) pid=${pid}`);
426
- await deps.stopSession(ent);
439
+ stopping.push(deps.stopSession(ent));
427
440
  }
441
+ await Promise.all(stopping);
428
442
  }
429
443
 
430
444
  if (fs.existsSync(deps.paths.plistPath)) {
package/src/sessions.mjs CHANGED
@@ -5,10 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
- import { deps } from './internals.mjs';
9
- import {
10
- SESSION_IDLE_SECONDS,
11
- } from './constants.mjs';
8
+ import { deps, ensurePrivateDir } from './internals.mjs';
12
9
 
13
10
  // pid 是否对应活进程。走到这儿的 pid 已经过入口范围校验。
14
11
  export function alive(pid) {
@@ -88,25 +85,25 @@ export function buildChildEnv(parentEnv) {
88
85
  return env;
89
86
  }
90
87
 
88
+ // 日志保留一代(.1):删旧代后把现役改名顶上。「.1」命名与 rm-先-于-
89
+ // rename 的顺序是两处调用(会话 typescript、launchd 巡检日志)共同的承重
90
+ // 约定——script(1) 打开即截断、launchd 按路径重开,轮转失败都不阻断调用方。
91
+ export function rotateOnce(file) {
92
+ const old = `${file}.1`;
93
+ fs.rmSync(old, { force: true });
94
+ fs.renameSync(file, old);
95
+ }
96
+
91
97
  // 拉起一个 detached 的 pty TUI,返回 {pid, procStart}。
92
98
  export function spawnSession(ws) {
93
99
  const logPath = sessionLogPath(ws);
94
- fs.mkdirSync(path.dirname(logPath), { recursive: true });
95
- try {
96
- fs.chmodSync(path.dirname(logPath), 0o700);
97
- } catch {
98
- // 目录权限维持现状。
99
- }
100
+ ensurePrivateDir(path.dirname(logPath));
100
101
  // window 模式下「一代会话 = 一次执行」:script(1) 打开即截断,不轮转的
101
102
  // 话上一代(上一次任务执行)的界面记录会随本次拉起被抹掉。无条件保留
102
103
  // 一代(.1)——总量上界两代,活着的一代增长再快也只影响自身,不会经
103
104
  // 轮转放大。
104
105
  try {
105
- if (fs.existsSync(logPath)) {
106
- const old = `${logPath}.1`;
107
- fs.rmSync(old, { force: true });
108
- fs.renameSync(logPath, old);
109
- }
106
+ if (fs.existsSync(logPath)) rotateOnce(logPath);
110
107
  } catch {
111
108
  // 轮转失败不阻断拉起。
112
109
  }
@@ -165,12 +162,14 @@ export async function stopSession(ent) {
165
162
  }
166
163
  }
167
164
 
168
- // typescript 多少秒没被写。文件缺失读作「没有活动证据」,不挡轮转。
165
+ // typescript 多少秒没被写。文件缺失读作「没有任何活动证据」:Infinity 大
166
+ // 于任何静默阈值,天然不挡换代与回收(哨兵值曾绑定 SESSION_IDLE_SECONDS
167
+ // + 1,阈值调低后会悄悄翻转语义)。
169
168
  export function sessionLogIdleSeconds(logPath, cur) {
170
169
  try {
171
170
  return Math.max(0, cur - Math.floor(fs.statSync(logPath).mtimeMs / 1000));
172
171
  } catch {
173
- return SESSION_IDLE_SECONDS + 1;
172
+ return Infinity;
174
173
  }
175
174
  }
176
175
 
package/src/tasks.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 } from './internals.mjs';
8
+ import { deps, isPlainObject } from './internals.mjs';
9
9
  import { TASK_REL } from './paths.mjs';
10
10
  import {
11
11
  PRUNE_DIRS,
@@ -20,7 +20,7 @@ export function expandHome(p) {
20
20
  return p; // ~user 形式不支持(macOS 实践中不会出现)
21
21
  }
22
22
 
23
- function isDir(p) {
23
+ export function isDir(p) {
24
24
  try {
25
25
  return fs.statSync(p).isDirectory();
26
26
  } catch {
@@ -36,22 +36,33 @@ function realpathOrNull(p) {
36
36
  }
37
37
  }
38
38
 
39
+ // 宽容版 realpath:失败时退回词法绝对路径(输入须已绝对)。与
40
+ // realpathOrNull 的「失败即 null」是两种降级口径:会话登记匹配(registry)
41
+ // 与日志定位(logs)要的是一个还能用的路径,而不是 null。
42
+ export function lexReal(p) {
43
+ try {
44
+ return fs.realpathSync(p);
45
+ } catch {
46
+ return path.resolve(p);
47
+ }
48
+ }
49
+
50
+ // 任务文件是否还在:readTasks 读出 null 时区分「文件已消失」(一次性任务
51
+ // 执行完被删的正常竞态)与「文件在但损坏」(要告警)。路径拼法归任务层,
52
+ // 巡检与总览不必各自重建。
53
+ export function taskFileExists(ws) {
54
+ return fs.existsSync(path.join(ws, TASK_REL));
55
+ }
56
+
39
57
  // 路径在磁盘上真实存储的大小写形态;任何一段读不出时原样返回。
40
58
  // macOS 默认文件系统大小写不敏感,而 realpath 不规范化大小写:敲错大小
41
59
  // 写的路径照样通过 isDirectory,却与会话登记里真实大小写的 cwd 永远匹配
42
60
  // 不上。在扫描边界认回一次真实写法,下游就不必各自提防。
43
61
  export function onDiskCase(p) {
44
- const parts = [];
45
- let cur = path.resolve(p).replace(/\/+$/, '') || '/';
46
- while (true) {
47
- const head = path.dirname(cur);
48
- const tail = path.basename(cur);
49
- if (!tail || head === cur) break;
50
- parts.push(tail);
51
- cur = head;
52
- }
53
- // cur 停在根,自顶向下逐段认回磁盘上的真实条目名。
54
- for (const name of parts.reverse()) {
62
+ // resolve 的输出不含双斜杠与尾部斜杠(根除外),按段拆开自顶向下认回。
63
+ const parts = path.resolve(p).split('/').filter(Boolean);
64
+ let cur = '/';
65
+ for (const name of parts) {
55
66
  let entries;
56
67
  try {
57
68
  entries = fs.readdirSync(cur);
@@ -91,16 +102,19 @@ export function* discover(roots, maxDepth) {
91
102
  ? []
92
103
  : entries.filter((e) => e.isDirectory() && !PRUNE_DIRS.has(e.name));
93
104
  const candidate = path.join(dirpath, TASK_REL);
94
- const ws = realpathOrNull(dirpath);
95
105
  let isFile = false;
96
106
  try {
97
107
  isFile = fs.statSync(candidate).isFile();
98
108
  } catch {
99
- isFile = false;
109
+ // 不存在按非文件处理。
100
110
  }
101
- if (ws && isFile && !seen.has(ws)) {
102
- seen.add(ws);
103
- yield [ws, candidate];
111
+ // 先判有没有任务文件再 realpath:绝大多数目录没有,省一次 syscall。
112
+ if (isFile) {
113
+ const ws = realpathOrNull(dirpath);
114
+ if (ws && !seen.has(ws)) {
115
+ seen.add(ws);
116
+ yield [ws, candidate];
117
+ }
104
118
  }
105
119
  for (const d of subdirs) {
106
120
  yield* walk(path.join(dirpath, d.name));
@@ -110,21 +124,11 @@ export function* discover(roots, maxDepth) {
110
124
  }
111
125
  }
112
126
 
113
- function isPlainObject(v) {
114
- return v !== null && typeof v === 'object' && !Array.isArray(v);
115
- }
116
-
117
- // 读一个工作区的任务列表,坏形状一律返回 null。
127
+ // 读一个工作区的任务列表,坏形状一律返回 null。读盘与顶层形状校验复用
128
+ // loadTaskDoc(renew 也要同一口径 round-trip 整份文档)。
118
129
  export function readTasks(ws) {
119
- let doc;
120
- try {
121
- doc = JSON.parse(fs.readFileSync(
122
- path.join(ws, TASK_REL), 'utf-8'));
123
- } catch {
124
- return null;
125
- }
126
- if (!isPlainObject(doc) || !Array.isArray(doc.tasks)) return null;
127
- return doc.tasks.filter(isPlainObject);
130
+ const doc = deps.loadTaskDoc(path.join(ws, TASK_REL));
131
+ return doc === null ? null : doc.tasks.filter(isPlainObject);
128
132
  }
129
133
 
130
134
  // 任务文件在「读到」与「写回」之间被别的写者改了(Claude Code 更新
@@ -245,11 +249,11 @@ export function minifyIds(tasks) {
245
249
  }
246
250
 
247
251
  // 一轮巡检对一个任务文件做的维护:renew(补 permanent)与 minify(id 改
248
- // 小)合并为同一次受 mtime 乐观锁保护的原子写。外壳与旧 renewWorkspace
249
- // 完全一致:先取 mtimeNs、清残留 .cron-up-tmp、loadTaskDoc round-trip 整
250
- // 文档;两项计数都为 0 时字节级不动。返回 {renewed,minified};null 表示
251
- // 文件缺失/损坏;读到写间被改抛 TaskFileChanged。
252
- export function maintainWorkspace(taskfile, { renew = false, minify = false } = {}) {
252
+ // 小)合并为同一次受 mtime 乐观锁保护的原子写。先取 mtimeNs、清残留
253
+ // .cron-up-tmp、loadTaskDoc round-trip 整文档;两项计数都为 0 时字节级
254
+ // 不动。返回 {renewed,minified};null 表示文件缺失/损坏;读到写间被改抛
255
+ // TaskFileChanged。
256
+ export function maintainWorkspace(taskfile, { renew = false, minify = false }) {
253
257
  let mtimeNs;
254
258
  try {
255
259
  mtimeNs = fs.statSync(taskfile, { bigint: true }).mtimeNs;
@@ -285,10 +289,4 @@ export function maintainWorkspace(taskfile, { renew = false, minify = false } =
285
289
  return { renewed, minified };
286
290
  }
287
291
 
288
- // 手动 renew 的薄封装:只补 permanent(不改 id),保持 null/0/N 三态契约。
289
- export function renewWorkspace(taskfile) {
290
- const r = maintainWorkspace(taskfile, { renew: true });
291
- return r === null ? null : r.renewed;
292
- }
293
-
294
292
  Object.assign(deps, { discover, readTasks, loadTaskDoc });