@migor/cli 0.9.1 → 0.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.
@@ -0,0 +1,223 @@
1
+ /**
2
+ * dev 环的**文件监视件**(C 阶段自 `dev.ts` 纯搬移,行为未改 ——
3
+ * 方案 docs/plans/2026-09-23-cli-structure.md §3 C)。
4
+ *
5
+ * 收编「代码变了要重启」的全部判据与原语:
6
+ * - `shouldWatch` / `shouldDescend`:哪些文件 / 目录进 watch 范围(允许清单,不是排除清单);
7
+ * - `makeNotifier`:两个 watch 共用的去抖通知器;
8
+ * - `watchTree`:递归 watch `src/` 目录树;
9
+ * - `watchRootEnvFiles`:单独盯项目根的 `.env` / `.env.local`。
10
+ *
11
+ * 本文件**不读机器状态**(`DevMachineState`):它只产出「哪个文件变了」这个事实,
12
+ * 「变了之后该不该重启」是 `dev-machine.ts` 的迁移规则,`dev.ts` 接线层负责把两边接上。
13
+ *
14
+ * 出口形状:`shouldWatch` / `watchTree` / `watchRootEnvFiles` 经 `dev.ts` re-export ——
15
+ * `packages/cli/test/panel-logic.test.mjs` 从 `dist/dev.js` 取它们,出口不能断。
16
+ */
17
+ import { readdirSync, statSync, watch } from 'node:fs';
18
+ import { join, sep } from 'node:path';
19
+ /** 文件事件去抖:编辑器保存常常连发好几个事件(写临时文件 + rename) */
20
+ const WATCH_DEBOUNCE_MS = 150;
21
+ /**
22
+ * 会触发重启的文件类型(**允许清单**,不是排除清单)。
23
+ *
24
+ * 用允许清单而不是「除了 node_modules 都算」:`.DS_Store` / 编辑器 swap / 日志会
25
+ * 让常驻进程反复重启,而那种重启看起来像「agent 自己在抖」。
26
+ * `.md` **必须在**里面 —— 文本资产(`@Prompt` 拉的 `.md`、子 agent 的 `system.md`)
27
+ * 不在 tsx 的 import 图里,旧实现因此对它们**静默无感**(D4 的 G3b)。
28
+ */
29
+ const WATCH_EXT = new Set(['.ts', '.mts', '.cts', '.tsx', '.js', '.mjs', '.cjs', '.json', '.md']);
30
+ /**
31
+ * 按文件名(而非扩展名)命中的环境变量文件。
32
+ *
33
+ * ⚠️ 它们**不在** `watchTree` 的根(`src/`)下 ⇒ 由 `watchRootEnvFiles` 单独盯。
34
+ * 早先只有 `shouldWatch` 认识这两个名字、却没有任何一个 watch 够得着它们,
35
+ * 于是「改 `.env` 会重启」是一句**没人执行**的承诺(真跑抓出来的,见 `watchRootEnvFiles`)。
36
+ */
37
+ const WATCH_NAMES = new Set(['.env', '.env.local']);
38
+ /**
39
+ * 不看的目录:产物、依赖、VCS、dev 自己的状态。
40
+ *
41
+ * ⚠️ 这道闸是**第二层**。第一层是**监视根**:`devServer` 只 `watchTree(<projectRoot>/src)`
42
+ * —— `.agentia/`(会话历史 + 当前会话 id)与 `dist/` 都在项目根,**根本不在范围内**。
43
+ * 两层都要有:只靠根的话,哪天把根改成项目根(一个很自然的想法),`.agentia/session.json`
44
+ * 就会立刻变成「每次多轮 run 重启一次子进程」的自噬循环。
45
+ */
46
+ const WATCH_SKIP = new Set(['node_modules', 'dist', '.git', '.agentia', 'coverage']);
47
+ /**
48
+ * 这个文件该不该触发重启?**导出是为了单测** —— 它是「改 `.md` 会不会重启」这条
49
+ * 承诺的唯一判据,而那条承诺正是旧实现静默失效的地方(G3b)。
50
+ */
51
+ export function shouldWatch(abs) {
52
+ const base = abs.slice(abs.lastIndexOf(sep) + 1);
53
+ if (WATCH_NAMES.has(base))
54
+ return true;
55
+ const dot = base.lastIndexOf('.');
56
+ return dot > 0 && WATCH_EXT.has(base.slice(dot));
57
+ }
58
+ /**
59
+ * 这个**目录**要不要进 watch 范围?
60
+ *
61
+ * ⚠️ 判据必须由 `addDir` 自己执行,不能只留在「初始递归」那一个调用点 ——
62
+ * 动态新增目录(watcher 回调里发现新目录时调 `addDir`)走的是**另一条路**,
63
+ * 只在一处判就等于「启动时就存在的 `dist/` 不看、启动后才出现的 `dist/` 看」。
64
+ *
65
+ * 真实影响面由**监视根**兜着(`devServer` 只看 `<projectRoot>/src`,见 `WATCH_SKIP`),
66
+ * 所以它今天多半打不到 `.agentia` —— 但那是「碰巧打不到」,不是「判据成立」。
67
+ *
68
+ * 判据只看**目录名**(不看路径段):项目根本身叫 `dist` / `node_modules` 是合法的
69
+ * (见 `shouldWatch` 的同一条教训),所以 root 由调用方显式豁免。
70
+ */
71
+ function shouldDescend(dir) {
72
+ const name = dir.slice(dir.lastIndexOf(sep) + 1);
73
+ return !WATCH_SKIP.has(name) && !name.startsWith('.');
74
+ }
75
+ /**
76
+ * 文件事件去抖通知器:同一批事件只回调**第一条**。
77
+ *
78
+ * 为什么抽出来共用:**两个** watch(递归目录树 `watchTree` / 项目根的 env 文件
79
+ * `watchRootEnvFiles`)都要同一套去抖与关闭语义。各写一份就会漂 —— 比如只有一处清理
80
+ * `timer`,关闭后仍会回调一次;而那种漂的后果是「停机时又重启了一次子进程」。
81
+ */
82
+ function makeNotifier(onFile) {
83
+ let closed = false;
84
+ let timer = null;
85
+ const pending = new Set();
86
+ const flush = () => {
87
+ timer = null;
88
+ if (closed || pending.size === 0)
89
+ return;
90
+ const first = [...pending][0];
91
+ pending.clear();
92
+ onFile(first);
93
+ };
94
+ return {
95
+ note: (abs) => {
96
+ if (closed)
97
+ return;
98
+ pending.add(abs);
99
+ if (timer === null)
100
+ timer = setTimeout(flush, WATCH_DEBOUNCE_MS);
101
+ },
102
+ dispose: () => {
103
+ closed = true;
104
+ if (timer !== null)
105
+ clearTimeout(timer);
106
+ },
107
+ };
108
+ }
109
+ /**
110
+ * 递归 watch 一个目录树(`fs.watch` 的 recursive 在 Linux 上到 Node 20 才稳,
111
+ * 这里自己递归 + 新目录动态加入,跨平台同一条代码路径)。
112
+ *
113
+ * 跳过目录(`WATCH_SKIP` / 点开头)的判据由 `addDir` **内部**执行 —— 初始递归与
114
+ * 动态新增两条路共用它,见 `shouldDescend`。
115
+ *
116
+ * ⚠️ 它只看**一棵**树:`.env` / `.env.local` 在项目根、不在 `src/` 下,所以那两份由
117
+ * `watchRootEnvFiles` 单独盯(见它的说明)。
118
+ *
119
+ * **导出是为了单测**:它是「改文本资产不用重启」那条承诺的落点。
120
+ */
121
+ export function watchTree(root, onFile) {
122
+ const watchers = new Map();
123
+ let closed = false;
124
+ const notifier = makeNotifier(onFile);
125
+ const note = (abs) => {
126
+ if (closed || !shouldWatch(abs))
127
+ return;
128
+ notifier.note(abs);
129
+ };
130
+ /** @param isRoot 项目根自己 —— 它可能就叫 `dist` / `.foo`,但必须看(唯一豁免处) */
131
+ const addDir = (dir, isRoot = false) => {
132
+ if (closed || watchers.has(dir))
133
+ return;
134
+ // 跳过目录的判据在**这里**(唯一一处),不在调用点 —— 见 shouldDescend 的说明
135
+ if (!isRoot && !shouldDescend(dir))
136
+ return;
137
+ let entries;
138
+ try {
139
+ entries = readdirSync(dir, { withFileTypes: true });
140
+ }
141
+ catch {
142
+ return; // 目录刚被删掉
143
+ }
144
+ let w;
145
+ try {
146
+ w = watch(dir, { persistent: true }, (_event, name) => {
147
+ if (name === null)
148
+ return;
149
+ const abs = join(dir, String(name));
150
+ note(abs);
151
+ // 新目录要加进 watch 范围(否则新加的能力目录永远不触发重启)。
152
+ // 跳过判据由 addDir 内部执行 ⇒ 这里**不要**再判一次(两处判就会漂)。
153
+ try {
154
+ if (statSync(abs).isDirectory())
155
+ addDir(abs);
156
+ }
157
+ catch {
158
+ /* 被删了 */
159
+ }
160
+ });
161
+ }
162
+ catch {
163
+ return; // 权限 / 已被删
164
+ }
165
+ // 空监听防爆是必要的(没有 error 监听的 FSWatcher 出错会抛穿进程),
166
+ // 但不能**静默** —— watcher 坏了 = 「改代码会重启」悄悄失效,必须留一行可诊断的话。
167
+ w.on('error', (err) => {
168
+ console.warn(`[agentia] 监视目录出错(${dir}):${err.message} —— 这之后的文件变更可能不再触发重启`);
169
+ });
170
+ watchers.set(dir, w);
171
+ for (const e of entries) {
172
+ if (e.isDirectory())
173
+ addDir(join(dir, e.name));
174
+ }
175
+ };
176
+ addDir(root, true);
177
+ return () => {
178
+ closed = true;
179
+ notifier.dispose();
180
+ for (const w of watchers.values())
181
+ w.close();
182
+ watchers.clear();
183
+ };
184
+ }
185
+ /**
186
+ * 只盯**项目根**下按文件名命中的那几份文件(`.env` / `.env.local`)。
187
+ *
188
+ * **为什么单开一个、而不是把 `watchTree` 的根抬到项目根**:`.env` 在项目根,而**其他**
189
+ * 该看的东西都在 `src/` —— 抬根会把 `README.md` / `docs/` / `examples/` 全收进来,
190
+ * 「改代码要重启」就变成「改任何文档也重启」。所以这里刻意**不递归**,且**只认名字**
191
+ * (不认扩展名:项目根的 `package.json` 同样不该由这里管)。
192
+ *
193
+ * ⚠️ 它存在的理由:`WATCH_NAMES` 曾经是一条**够不着**的判据 —— 允许清单里有 `.env`、
194
+ * `usage-guide` 也承诺「看 `.env`」,但 `watchTree` 的根是 `<projectRoot>/src`,
195
+ * 而 `.env` 在项目根 ⇒ 改 `.env` **静默无感**(与 G3b 同类:文档承诺了、代码够不着)。
196
+ * **这条只有真跑能守**:根是**调用点**决定的,`watchTree` 自己无从知道该看哪儿
197
+ * (`scripts/e2e-dev.ts` 第 9-bis 步钉着它)。
198
+ */
199
+ export function watchRootEnvFiles(root, onFile) {
200
+ const notifier = makeNotifier(onFile);
201
+ let w;
202
+ try {
203
+ w = watch(root, { persistent: true }, (_event, name) => {
204
+ if (name === null)
205
+ return;
206
+ const base = String(name);
207
+ if (!WATCH_NAMES.has(base))
208
+ return;
209
+ notifier.note(join(root, base));
210
+ });
211
+ }
212
+ catch {
213
+ return () => { }; // 项目根不可 watch(权限)—— 静默降级成「只看 src/」,与旧行为一致
214
+ }
215
+ // 同 watchTree:监听要留(防爆),但错误要响亮(静默 = 「改 .env 会重启」悄悄失效)
216
+ w.on('error', (err) => {
217
+ console.warn(`[agentia] 监视项目根出错(${root}):${err.message} —— 这之后改 .env 可能不再触发重启`);
218
+ });
219
+ return () => {
220
+ w.close();
221
+ notifier.dispose();
222
+ };
223
+ }