ims-flow-dashboard 1.2.0
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 +99 -0
- package/bin/imsflow.mjs +37 -0
- package/bin/preview.mjs +92 -0
- package/package.json +36 -0
- package/projects.example.json +5 -0
- package/scan.mjs +779 -0
- package/template.html +1699 -0
package/scan.mjs
ADDED
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ims-flow-dashboard 扫描器
|
|
3
|
+
// 用法: node scan.mjs [serve] [过滤词] [--open] [--port=N]
|
|
4
|
+
// 快照模式: node scan.mjs [过滤词] [--open] → 生成单文件 dashboard.html
|
|
5
|
+
// 服务模式: node scan.mjs serve [--port=N] → 本地服务, 页面内「重新扫描」按钮生效
|
|
6
|
+
// 扫描 projects.json 注册的根目录下所有 openspec/ 工作区,
|
|
7
|
+
// 收集变更/工件/任务进度/健康检查数据, 生成单文件 dashboard.html
|
|
8
|
+
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import * as os from 'node:os';
|
|
12
|
+
import * as http from 'node:http';
|
|
13
|
+
import { exec } from 'node:child_process';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const TOOL_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const USER_CONFIG_DIR = path.join(os.homedir(), '.imsflow');
|
|
18
|
+
// 配置查找顺序: 包目录 projects.json (clone/开发模式) → ~/.imsflow/projects.json (npm 全局安装模式)
|
|
19
|
+
const PROJECTS_FILE = fs.existsSync(path.join(TOOL_DIR, 'projects.json'))
|
|
20
|
+
? path.join(TOOL_DIR, 'projects.json')
|
|
21
|
+
: path.join(USER_CONFIG_DIR, 'projects.json');
|
|
22
|
+
const OUT_HTML = path.join(TOOL_DIR, 'dashboard.html');
|
|
23
|
+
|
|
24
|
+
// ---------- 工具函数 ----------
|
|
25
|
+
|
|
26
|
+
function readText(p) {
|
|
27
|
+
try { return fs.readFileSync(p, 'utf-8'); } catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function exists(p) {
|
|
31
|
+
try { return fs.statSync(p).isDirectory() || fs.statSync(p).isFile(); } catch { return false; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function listDir(p) {
|
|
35
|
+
try { return fs.readdirSync(p, { withFileTypes: true }); } catch { return []; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 官方 spec-driven 工件兜底(仓库没有自定义 schema 时)
|
|
39
|
+
const OFFICIAL_ARTIFACTS = [
|
|
40
|
+
{ id: 'proposal', generates: 'proposal.md' },
|
|
41
|
+
{ id: 'specs', generates: 'specs/**/*.md' },
|
|
42
|
+
{ id: 'design', generates: 'design.md' },
|
|
43
|
+
{ id: 'tasks', generates: 'tasks.md' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// ---------- schema.yaml 解析(行级状态机, 只提取需要的字段) ----------
|
|
47
|
+
|
|
48
|
+
function parseSchemaYaml(yamlText) {
|
|
49
|
+
const artifacts = [];
|
|
50
|
+
const lines = yamlText.split(/\r?\n/);
|
|
51
|
+
let current = null;
|
|
52
|
+
let inGates = false;
|
|
53
|
+
let inRequires = false;
|
|
54
|
+
|
|
55
|
+
for (const raw of lines) {
|
|
56
|
+
const line = raw; // 保留缩进
|
|
57
|
+
const trimmed = line.trim();
|
|
58
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
59
|
+
|
|
60
|
+
// 新工件: " - id: xxx"
|
|
61
|
+
const m = trimmed.match(/^-\s+id:\s*(\S+)/);
|
|
62
|
+
if (m && /^-\s/.test(trimmed)) {
|
|
63
|
+
current = { id: m[1], generates: null, gates: [], requires: [] };
|
|
64
|
+
artifacts.push(current);
|
|
65
|
+
inGates = false;
|
|
66
|
+
inRequires = false;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!current) continue;
|
|
71
|
+
|
|
72
|
+
// requires 块列表项: "- proposal" (无冒号)
|
|
73
|
+
if (inRequires && /^-\s+[^:#]+$/.test(trimmed)) {
|
|
74
|
+
current.requires.push(trimmed.replace(/^-\s+/, '').trim());
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const kv = trimmed.match(/^(?:-\s*)?([\w-]+):\s*(.*)$/);
|
|
79
|
+
if (!kv) continue;
|
|
80
|
+
const [, key, value] = kv;
|
|
81
|
+
|
|
82
|
+
if (key === 'requires') {
|
|
83
|
+
inGates = false;
|
|
84
|
+
inRequires = true;
|
|
85
|
+
current.requires = [];
|
|
86
|
+
if (value.startsWith('[')) {
|
|
87
|
+
current.requires = value.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean);
|
|
88
|
+
inRequires = false;
|
|
89
|
+
} else if (value) {
|
|
90
|
+
current.requires = [value.trim()];
|
|
91
|
+
inRequires = false;
|
|
92
|
+
}
|
|
93
|
+
} else if (key === 'generates') {
|
|
94
|
+
current.generates = value.replace(/^["']|["']$/g, '');
|
|
95
|
+
inGates = false;
|
|
96
|
+
inRequires = false;
|
|
97
|
+
} else if (key === 'gates') {
|
|
98
|
+
inGates = true;
|
|
99
|
+
inRequires = false;
|
|
100
|
+
} else if (inGates && (key === 'sections-present' || key === 'must-contain')) {
|
|
101
|
+
// 行内: - sections-present: [A, B, C] 或 - must-contain: "erDiagram"
|
|
102
|
+
if (value.startsWith('[')) {
|
|
103
|
+
const inner = value.slice(1, -1);
|
|
104
|
+
const items = inner.split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
105
|
+
current.gates.push({ 'sections-present': items });
|
|
106
|
+
} else {
|
|
107
|
+
const v = value.replace(/^["']|["']$/g, '');
|
|
108
|
+
if (v) current.gates.push({ 'must-contain': v });
|
|
109
|
+
}
|
|
110
|
+
} else if (key === 'id' && !value) {
|
|
111
|
+
// 忽略
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return artifacts.filter(a => a.generates);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function loadWorkspaceSchema(openspecDir) {
|
|
118
|
+
// 1. config.yaml 拿 schema 名
|
|
119
|
+
const configText = readText(path.join(openspecDir, 'config.yaml'));
|
|
120
|
+
let schemaName = 'spec-driven';
|
|
121
|
+
if (configText) {
|
|
122
|
+
const m = configText.match(/^schema:\s*(\S+)/m);
|
|
123
|
+
if (m) schemaName = m[1];
|
|
124
|
+
}
|
|
125
|
+
// 2. 读 schemas/<name>/schema.yaml
|
|
126
|
+
const schemaText = readText(path.join(openspecDir, 'schemas', schemaName, 'schema.yaml'));
|
|
127
|
+
let artifacts = null;
|
|
128
|
+
if (schemaText) {
|
|
129
|
+
artifacts = parseSchemaYaml(schemaText);
|
|
130
|
+
}
|
|
131
|
+
if (!artifacts || artifacts.length === 0) {
|
|
132
|
+
artifacts = schemaName === 'spec-driven'
|
|
133
|
+
? OFFICIAL_ARTIFACTS
|
|
134
|
+
: OFFICIAL_ARTIFACTS; // 找不到 schema 定义一律按官方 4 工件兜底
|
|
135
|
+
}
|
|
136
|
+
return { schemaName, artifacts };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------- 单个变更的工件与任务收集 ----------
|
|
140
|
+
|
|
141
|
+
function artifactFiles(changeDir, generates) {
|
|
142
|
+
// 返回 [{rel, content}];glob 工件收集所有匹配文件
|
|
143
|
+
const files = [];
|
|
144
|
+
if (generates.includes('*')) {
|
|
145
|
+
const base = generates.split('/')[0];
|
|
146
|
+
const specsDir = path.join(changeDir, base);
|
|
147
|
+
(function walk(dir) {
|
|
148
|
+
for (const e of listDir(dir)) {
|
|
149
|
+
const full = path.join(dir, e.name);
|
|
150
|
+
if (e.isDirectory()) walk(full);
|
|
151
|
+
else if (e.name.endsWith('.md')) {
|
|
152
|
+
files.push({ rel: path.relative(changeDir, full).replace(/\\/g, '/'), content: readText(full) || '' });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
})(specsDir);
|
|
156
|
+
} else {
|
|
157
|
+
const full = path.join(changeDir, generates);
|
|
158
|
+
if (fs.existsSync(full)) {
|
|
159
|
+
files.push({ rel: generates, content: readText(full) || '' });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return files;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function artifactExists(changeDir, generates) {
|
|
166
|
+
if (generates.includes('*')) {
|
|
167
|
+
return artifactFiles(changeDir, generates).length > 0;
|
|
168
|
+
}
|
|
169
|
+
return fs.existsSync(path.join(changeDir, generates));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function collectTasks(changeDir) {
|
|
173
|
+
const text = readText(path.join(changeDir, 'tasks.md'));
|
|
174
|
+
if (text === null) return null;
|
|
175
|
+
const groups = [];
|
|
176
|
+
let group = null;
|
|
177
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
178
|
+
const hm = raw.match(/^##\s+(.*)/);
|
|
179
|
+
if (hm) {
|
|
180
|
+
group = { title: hm[1].trim(), items: [] };
|
|
181
|
+
groups.push(group);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const tm = raw.match(/^(-|\s*)-\s+\[( |x|X)\]\s+(.*)$/);
|
|
185
|
+
if (tm) {
|
|
186
|
+
if (!group) { group = { title: '任务', items: [] }; groups.push(group); }
|
|
187
|
+
group.items.push({ text: tm[3].trim(), done: tm[2].toLowerCase() === 'x' });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const items = groups.flatMap(g => g.items);
|
|
191
|
+
const done = items.filter(i => i.done).length;
|
|
192
|
+
return { open: items.length - done, done, total: items.length, groups };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------- 健康检查 ----------
|
|
196
|
+
|
|
197
|
+
const DDL_RE = /^\s*(ALTER TABLE|CREATE TABLE|CREATE INDEX|DROP TABLE|DROP INDEX)/im;
|
|
198
|
+
|
|
199
|
+
function healthCheck(change, artifacts) {
|
|
200
|
+
const issues = []; // {level: 'error'|'warn', msg}
|
|
201
|
+
const checks = []; // {name, pass, detail} 用于页面展示
|
|
202
|
+
|
|
203
|
+
const byGen = {};
|
|
204
|
+
for (const a of artifacts) byGen[a.generates] = a;
|
|
205
|
+
|
|
206
|
+
// 1. gates 检查(规则来自 schema.yaml, 与源码门禁同源)
|
|
207
|
+
for (const a of artifacts) {
|
|
208
|
+
if (!a.gates || a.gates.length === 0) continue;
|
|
209
|
+
const existsNow = change.artifacts.find(x => x.id === a.id)?.exists;
|
|
210
|
+
if (!existsNow) continue; // 工件未产出, 由进度反映, 不算违规
|
|
211
|
+
|
|
212
|
+
const file = path.join(change.dir, a.generates);
|
|
213
|
+
const content = readText(file) || '';
|
|
214
|
+
for (const gate of a.gates) {
|
|
215
|
+
if (gate['sections-present']) {
|
|
216
|
+
for (const s of gate['sections-present']) {
|
|
217
|
+
const pass = content.includes(s);
|
|
218
|
+
checks.push({ name: `${a.generates} 含检查节「${s}」`, pass });
|
|
219
|
+
if (!pass) issues.push({ level: 'error', msg: `${a.generates} 缺检查节「${s}」` });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (gate['must-contain']) {
|
|
223
|
+
const pass = content.includes(gate['must-contain']);
|
|
224
|
+
checks.push({ name: `${a.generates} 含「${gate['must-contain']}」`, pass });
|
|
225
|
+
if (!pass) issues.push({ level: 'error', msg: `${a.generates} 缺少「${gate['must-contain']}」` });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 2. 时序检查: apply 后工件(有 requires: [tasks] 的)已产出但 tasks 未全勾
|
|
231
|
+
const applyArtifacts = artifacts.filter(a => a.requiresTasks);
|
|
232
|
+
// requiresTasks 由 parseSchemaYaml 补充 —— 简化: generates 以 ims- 开头且非 api/db 的(test-plan/release-steps/retrospective)
|
|
233
|
+
const postApply = artifacts.filter(a => /^ims-(test-plan|release-steps|retrospective)\.md$/.test(a.generates));
|
|
234
|
+
if (change.tasks && change.tasks.open > 0) {
|
|
235
|
+
for (const a of postApply) {
|
|
236
|
+
const existsNow = change.artifacts.find(x => x.id === a.id)?.exists;
|
|
237
|
+
if (existsNow) {
|
|
238
|
+
const msg = `时序违约: tasks 还有 ${change.tasks.open} 项未勾选, 但 ${a.generates} 已产出`;
|
|
239
|
+
checks.push({ name: `${a.generates} 产出时序`, pass: false });
|
|
240
|
+
issues.push({ level: 'warn', msg });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 3. DDL 权威源漂移: release-steps 有 DDL 而 db-changes 没有
|
|
246
|
+
const rs = change.artifacts.find(x => /release-steps/.test(x.generates) && x.exists);
|
|
247
|
+
const db = change.artifacts.find(x => /db-changes/.test(x.generates) && x.exists);
|
|
248
|
+
if (rs && db) {
|
|
249
|
+
const rsContent = readText(path.join(change.dir, rs.generates)) || '';
|
|
250
|
+
const dbContent = readText(path.join(change.dir, db.generates)) || '';
|
|
251
|
+
if (DDL_RE.test(rsContent) && !DDL_RE.test(dbContent)) {
|
|
252
|
+
const msg = 'DDL 漂移: DDL 只出现在 release-steps, ims-db-changes.md 未记录';
|
|
253
|
+
checks.push({ name: 'DDL 权威源唯一', pass: false });
|
|
254
|
+
issues.push({ level: 'error', msg });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return { issues, checks };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------- 产物完整性(对照模板节结构) ----------
|
|
262
|
+
|
|
263
|
+
// 人工 review 标记文件(change 目录存在其一即视为已审查)
|
|
264
|
+
const REVIEW_FILES = ['review.md', 'reviewed.md'];
|
|
265
|
+
|
|
266
|
+
function stripSectionNo(title) {
|
|
267
|
+
// 去掉 "1. " / "一、" / "2、" 等节编号
|
|
268
|
+
return title.replace(/^\s*\d+[.、)]?\s*/, '').replace(/^[一二三四五六七八九十]+\s*[、.]\s*/, '').replace(/[*`]/g, '').trim();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function extractSections(text) {
|
|
272
|
+
// 提取 ## 级标题(跳过代码块),返回去编号后的节名数组
|
|
273
|
+
const names = new Set();
|
|
274
|
+
let inCode = false;
|
|
275
|
+
for (const raw of (text || '').split(/\r?\n/)) {
|
|
276
|
+
if (raw.trim().startsWith('```')) { inCode = !inCode; continue; }
|
|
277
|
+
if (inCode) continue;
|
|
278
|
+
const m = raw.match(/^##\s+(.+)$/);
|
|
279
|
+
if (m) {
|
|
280
|
+
const name = stripSectionNo(m[1]);
|
|
281
|
+
// 过滤模板占位标题: <!-- xxx --> / {xxx} 等
|
|
282
|
+
if (name && !/[<>{}]/.test(name)) names.add(name);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return [...names];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function artifactCompleteness(changeDir, artifactMeta, templateDir) {
|
|
289
|
+
// 已声明 sections-present gates 的工件以 gates 为权威源, 跳过模板比对避免重复
|
|
290
|
+
if (!templateDir || artifactMeta.generates.includes('*')) return null;
|
|
291
|
+
if ((artifactMeta.gates || []).some(g => g['sections-present'])) return null;
|
|
292
|
+
const tpl = readText(path.join(templateDir, path.basename(artifactMeta.generates)));
|
|
293
|
+
if (!tpl) return null;
|
|
294
|
+
const tplSecs = extractSections(tpl);
|
|
295
|
+
if (!tplSecs.length) return null;
|
|
296
|
+
const content = readText(path.join(changeDir, artifactMeta.generates)) || '';
|
|
297
|
+
const cSecs = new Set(extractSections(content));
|
|
298
|
+
const missing = tplSecs.filter(s => !cSecs.has(s) && !content.includes(s));
|
|
299
|
+
return { total: tplSecs.length, missing };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------- 生命周期阶段 ----------
|
|
303
|
+
|
|
304
|
+
const STAGE_LABEL = {
|
|
305
|
+
created: '刚创建', proposing: '提议中', ready: '待实施', applying: '实施中',
|
|
306
|
+
applied: '待审查', reviewed: '已审查', archived: '已归档',
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
function computeLifecycle(change, artifactsMeta, archived) {
|
|
310
|
+
const requiresOf = id => (artifactsMeta.find(a => a.id === id) || {}).requires || [];
|
|
311
|
+
// apply 后工件 = 直接 requires tasks 的工件(ims-test-plan / ims-release-steps)
|
|
312
|
+
const postApplyIds = artifactsMeta.filter(a => requiresOf(a.id).includes('tasks')).map(a => a.id);
|
|
313
|
+
const planningArts = artifactsMeta.filter(a => !postApplyIds.includes(a.id));
|
|
314
|
+
const produced = new Set(change.artifacts.filter(a => a.exists).map(a => a.id));
|
|
315
|
+
const planningProduced = planningArts.filter(a => produced.has(a.id)).length;
|
|
316
|
+
const postApplyProduced = postApplyIds.filter(id => produced.has(id)).length;
|
|
317
|
+
const tasks = change.tasks;
|
|
318
|
+
const tasksAllDone = !!tasks && tasks.total > 0 && tasks.open === 0;
|
|
319
|
+
const tasksStarted = !!tasks && tasks.done > 0;
|
|
320
|
+
const planningAll = planningArts.length > 0 && planningProduced === planningArts.length;
|
|
321
|
+
const postApplyAll = postApplyIds.length === 0 || postApplyProduced === postApplyIds.length;
|
|
322
|
+
|
|
323
|
+
let stage;
|
|
324
|
+
if (archived) stage = 'archived';
|
|
325
|
+
else if (change.reviewed) stage = 'reviewed';
|
|
326
|
+
else if (planningAll && tasksAllDone && postApplyAll) stage = 'applied';
|
|
327
|
+
else if (planningAll && (tasksStarted || postApplyProduced > 0)) stage = 'applying';
|
|
328
|
+
else if (planningAll) stage = 'ready';
|
|
329
|
+
else if (planningProduced > 0) stage = 'proposing';
|
|
330
|
+
else stage = 'created';
|
|
331
|
+
|
|
332
|
+
const missingPlanning = planningArts.filter(a => !produced.has(a.id)).map(a => a.generates);
|
|
333
|
+
const missingPost = postApplyIds.filter(id => !produced.has(id)).map(id => (artifactsMeta.find(a => a.id === id) || {}).generates);
|
|
334
|
+
const incomplete = change.artifacts
|
|
335
|
+
.filter(a => a.exists && a.missing && a.missing.length)
|
|
336
|
+
.map(a => ({ id: a.id, file: a.generates, missing: a.missing }));
|
|
337
|
+
|
|
338
|
+
let nextAction = '';
|
|
339
|
+
switch (stage) {
|
|
340
|
+
case 'created': nextAction = '刚创建 — 运行 /opsx:propose 开始生成提议工件'; break;
|
|
341
|
+
case 'proposing': nextAction = '提议中 — 待产出: ' + missingPlanning.join('、'); break;
|
|
342
|
+
case 'ready': nextAction = '规划工件齐全,可执行 /opsx:apply 开始实施' + (incomplete.length ? `(注意:${incomplete.length} 个产物结构不完整)` : ''); break;
|
|
343
|
+
case 'applying': {
|
|
344
|
+
const parts = [];
|
|
345
|
+
if (tasks && tasks.open > 0) parts.push(`剩 ${tasks.open} 项任务未完成`);
|
|
346
|
+
if (missingPost.length) parts.push('待产出: ' + missingPost.join('、'));
|
|
347
|
+
nextAction = '实施中 — ' + (parts.join(';') || '进行中');
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
case 'applied': nextAction = '全部产物已产出 — 等待人工 review(在变更目录创建 review.md 即标记已审查)'; break;
|
|
351
|
+
case 'reviewed': nextAction = '已审查 — 可执行 openspec archive 归档'; break;
|
|
352
|
+
case 'archived': nextAction = '已归档'; break;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
stage,
|
|
357
|
+
label: STAGE_LABEL[stage],
|
|
358
|
+
reviewed: !!change.reviewed,
|
|
359
|
+
planning: { total: planningArts.length, produced: planningProduced },
|
|
360
|
+
postApply: { total: postApplyIds.length, produced: postApplyProduced },
|
|
361
|
+
tasksAllDone,
|
|
362
|
+
incomplete,
|
|
363
|
+
nextAction,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------- 需求标题提取(proposal.md 的 H1 + 引用行 + 禅道/story 单号) ----------
|
|
368
|
+
|
|
369
|
+
function extractTitle(changeDir, artifacts) {
|
|
370
|
+
const proposal = artifacts.find(a => a.id === 'proposal');
|
|
371
|
+
const file = proposal ? path.join(changeDir, proposal.generates) : path.join(changeDir, 'proposal.md');
|
|
372
|
+
const text = readText(file);
|
|
373
|
+
if (!text) return null;
|
|
374
|
+
|
|
375
|
+
const lines = text.split(/\r?\n/);
|
|
376
|
+
|
|
377
|
+
// 1. H1 标题(在进入 ## 正文前找)
|
|
378
|
+
let title = null;
|
|
379
|
+
for (const l of lines) {
|
|
380
|
+
if (/^##\s+/.test(l)) break; // 无 H1
|
|
381
|
+
const m = l.match(/^#\s+(.+)$/);
|
|
382
|
+
if (m) { title = m[1].trim(); break; }
|
|
383
|
+
}
|
|
384
|
+
if (title) {
|
|
385
|
+
title = title.replace(/^提案[::]\s*/, '');
|
|
386
|
+
title = title.replace(/[((][\w#-]+[))]\s*$/i, '').trim(); // 去尾部英文 slug 括注
|
|
387
|
+
} else {
|
|
388
|
+
// 2. 无 H1: 用 ## Why 首行做标题(常见形态: "需求 568937【模块】标题:正文…" 或长句)
|
|
389
|
+
let why = '';
|
|
390
|
+
for (let i = 0; i < lines.length; i++) {
|
|
391
|
+
if (!/^##\s+Why/.test(lines[i])) continue;
|
|
392
|
+
for (let j = i + 1; j < lines.length && j < i + 8; j++) {
|
|
393
|
+
const t = lines[j].trim();
|
|
394
|
+
if (t && !t.startsWith('#')) { why = t; break; }
|
|
395
|
+
}
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
if (why) {
|
|
399
|
+
const ci = why.indexOf(':');
|
|
400
|
+
title = (ci > 0 && ci <= 45) ? why.slice(0, ci)
|
|
401
|
+
: (why.split('。')[0] || why).slice(0, 45);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// 3. meta 行: 引用行(> …)或 "(对应)禅道需求…" 开头的行
|
|
406
|
+
let meta = '';
|
|
407
|
+
for (const l of lines.slice(0, 12)) {
|
|
408
|
+
const t = l.trim();
|
|
409
|
+
if (/^>\s*\S/.test(t)) { meta = t.replace(/^>\s*/, ''); break; }
|
|
410
|
+
if (/^(对应)?禅道需求/.test(t)) { meta = t; break; }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// 4. 需求单号: 前 15 行内按优先级匹配
|
|
414
|
+
const src = lines.slice(0, 15).join(' ');
|
|
415
|
+
const m = src.match(/禅道(?:需求)?[::#\s]*(\d{4,})/)
|
|
416
|
+
|| src.match(/需求\s*[#::]?\s*(\d{4,})/)
|
|
417
|
+
|| src.match(/story\s*#?(\d{4,})/i)
|
|
418
|
+
|| src.match(/#(\d{5,})/);
|
|
419
|
+
const reqId = m ? m[1] : null;
|
|
420
|
+
|
|
421
|
+
if (!title && !meta && !reqId) return null;
|
|
422
|
+
return { text: title || '', meta, reqId };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ---------- 工作区扫描 ----------
|
|
426
|
+
|
|
427
|
+
// 变更来源元数据: change 目录下 meta.yaml 的 created-by / created-at
|
|
428
|
+
// 约定值: trae | tuanjieai | workbuddy | human (其他值原样显示)
|
|
429
|
+
function extractMeta(changeDir) {
|
|
430
|
+
const text = readText(path.join(changeDir, 'meta.yaml'));
|
|
431
|
+
if (!text) return null;
|
|
432
|
+
const by = text.match(/^created-by:\s*(\S+)/m);
|
|
433
|
+
if (!by) return null;
|
|
434
|
+
const at = text.match(/^created-at:\s*([^\s]+)/m);
|
|
435
|
+
return { createdBy: by[1], createdAt: at ? at[1] : null };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function scanChange(changeDir, artifacts, opts = {}) {
|
|
439
|
+
const includeContent = opts.includeContent !== false;
|
|
440
|
+
const name = path.basename(changeDir);
|
|
441
|
+
const change = {
|
|
442
|
+
name,
|
|
443
|
+
dir: changeDir,
|
|
444
|
+
artifacts: [],
|
|
445
|
+
tasks: null,
|
|
446
|
+
reviewed: false,
|
|
447
|
+
health: { issues: [], checks: [] },
|
|
448
|
+
};
|
|
449
|
+
// 人工 review 标记
|
|
450
|
+
change.reviewed = listDir(changeDir).some(e => e.isFile() && REVIEW_FILES.includes(e.name.toLowerCase()));
|
|
451
|
+
for (const a of artifacts) {
|
|
452
|
+
const files = includeContent ? artifactFiles(changeDir, a.generates) : [];
|
|
453
|
+
const entry = {
|
|
454
|
+
id: a.id,
|
|
455
|
+
generates: a.generates,
|
|
456
|
+
description: a.description || '',
|
|
457
|
+
exists: includeContent ? files.length > 0 : artifactExists(changeDir, a.generates),
|
|
458
|
+
files,
|
|
459
|
+
missing: null,
|
|
460
|
+
sectionsTotal: null,
|
|
461
|
+
};
|
|
462
|
+
if (entry.exists && includeContent) {
|
|
463
|
+
const comp = artifactCompleteness(changeDir, a, opts.templateDir);
|
|
464
|
+
if (comp) { entry.missing = comp.missing; entry.sectionsTotal = comp.total; }
|
|
465
|
+
}
|
|
466
|
+
change.artifacts.push(entry);
|
|
467
|
+
}
|
|
468
|
+
change.tasks = collectTasks(changeDir);
|
|
469
|
+
change.title = extractTitle(changeDir, artifacts);
|
|
470
|
+
change.meta = extractMeta(changeDir);
|
|
471
|
+
change.health = healthCheck(change, artifacts);
|
|
472
|
+
change.lifecycle = computeLifecycle(change, artifacts, !!opts.archived);
|
|
473
|
+
return change;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function scanWorkspace(rootDir, openspecDir, baseRoot) {
|
|
477
|
+
const { schemaName, artifacts } = loadWorkspaceSchema(openspecDir);
|
|
478
|
+
const changesDir = path.join(openspecDir, 'changes');
|
|
479
|
+
const archiveDir = path.join(changesDir, 'archive');
|
|
480
|
+
const templateDir = path.join(openspecDir, 'schemas', schemaName, 'templates');
|
|
481
|
+
|
|
482
|
+
const active = listDir(changesDir)
|
|
483
|
+
.filter(e => e.isDirectory() && e.name !== 'archive')
|
|
484
|
+
.map(e => scanChange(path.join(changesDir, e.name), artifacts, { includeContent: true, templateDir }));
|
|
485
|
+
|
|
486
|
+
const archived = listDir(archiveDir)
|
|
487
|
+
.filter(e => e.isDirectory())
|
|
488
|
+
.map(e => scanChange(path.join(archiveDir, e.name), artifacts, { includeContent: true, templateDir, archived: true }));
|
|
489
|
+
|
|
490
|
+
// 主规格统计
|
|
491
|
+
const specsDir = path.join(openspecDir, 'specs');
|
|
492
|
+
let specCount = 0, reqCount = 0;
|
|
493
|
+
(function walk(dir) {
|
|
494
|
+
for (const e of listDir(dir)) {
|
|
495
|
+
const full = path.join(dir, e.name);
|
|
496
|
+
if (e.isDirectory()) walk(full);
|
|
497
|
+
else if (e.name === 'spec.md') {
|
|
498
|
+
specCount++;
|
|
499
|
+
const t = readText(full) || '';
|
|
500
|
+
reqCount += (t.match(/^### Requirement:/gm) || []).length;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
})(specsDir);
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
root: rootDir,
|
|
507
|
+
baseRoot: baseRoot || rootDir, // 所属扫描根(projects.json 里配置的根)
|
|
508
|
+
openspecDir,
|
|
509
|
+
schemaName,
|
|
510
|
+
active,
|
|
511
|
+
archived,
|
|
512
|
+
specs: { capabilities: specCount, requirements: reqCount },
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function findWorkspaces(roots) {
|
|
517
|
+
const workspaces = [];
|
|
518
|
+
for (const root of roots) {
|
|
519
|
+
if (!exists(root)) { console.error(`[warn] 根目录不存在: ${root}`); continue; }
|
|
520
|
+
// root 自身的 openspec/
|
|
521
|
+
const own = path.join(root, 'openspec');
|
|
522
|
+
if (exists(own)) workspaces.push(scanWorkspace(root, own, root));
|
|
523
|
+
// 一级子目录的 openspec/
|
|
524
|
+
for (const e of listDir(root)) {
|
|
525
|
+
if (!e.isDirectory() || e.name === 'openspec') continue;
|
|
526
|
+
const sub = path.join(root, e.name, 'openspec');
|
|
527
|
+
if (exists(sub)) workspaces.push(scanWorkspace(path.join(root, e.name), sub, root));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return workspaces;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ---------- 主流程 ----------
|
|
534
|
+
|
|
535
|
+
function loadRoots() {
|
|
536
|
+
let roots = [];
|
|
537
|
+
try {
|
|
538
|
+
const cfg = JSON.parse(readText(PROJECTS_FILE) || '{}');
|
|
539
|
+
roots = cfg.roots || [];
|
|
540
|
+
} catch (e) {
|
|
541
|
+
console.error('[warn] projects.json 读取失败:', e.message);
|
|
542
|
+
}
|
|
543
|
+
if (roots.length === 0) {
|
|
544
|
+
console.error('未配置扫描根目录。请在以下任一位置创建 projects.json (参考 projects.example.json):');
|
|
545
|
+
console.error(' ' + path.join(TOOL_DIR, 'projects.json') + ' (源码 clone 模式)');
|
|
546
|
+
console.error(' ' + path.join(USER_CONFIG_DIR, 'projects.json') + ' (npm 全局安装模式)');
|
|
547
|
+
process.exit(1);
|
|
548
|
+
}
|
|
549
|
+
return roots;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function filterRoots(roots, filterWord) {
|
|
553
|
+
if (!filterWord) return roots;
|
|
554
|
+
const w = filterWord.toLowerCase();
|
|
555
|
+
const hit = roots.filter(r => r.toLowerCase().includes(w) || path.basename(r).toLowerCase().includes(w));
|
|
556
|
+
if (hit.length === 0) {
|
|
557
|
+
console.error(`没有匹配「${filterWord}」的根路径。已配置: ${roots.join(', ')}`);
|
|
558
|
+
process.exit(1);
|
|
559
|
+
}
|
|
560
|
+
return hit;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// serve 模式安全版: 读取失败/为空返回空数组, 不退出进程(由 API 决定如何响应)
|
|
564
|
+
function safeLoadRoots() {
|
|
565
|
+
try {
|
|
566
|
+
const cfg = JSON.parse(readText(PROJECTS_FILE) || '{}');
|
|
567
|
+
return Array.isArray(cfg.roots) ? cfg.roots.filter(r => typeof r === 'string' && r.trim()) : [];
|
|
568
|
+
} catch { return []; }
|
|
569
|
+
}
|
|
570
|
+
// 页面「工作区」面板增/删根后持久化到 projects.json
|
|
571
|
+
function saveRoots(roots) {
|
|
572
|
+
fs.mkdirSync(path.dirname(PROJECTS_FILE), { recursive: true });
|
|
573
|
+
fs.writeFileSync(PROJECTS_FILE, JSON.stringify({ roots }, null, 2) + '\n', 'utf-8');
|
|
574
|
+
}
|
|
575
|
+
// 读 POST body(JSON, 上限 64KB)
|
|
576
|
+
function readBody(req, limit = 64 * 1024) {
|
|
577
|
+
return new Promise((resolve, reject) => {
|
|
578
|
+
const chunks = []; let size = 0;
|
|
579
|
+
req.on('data', c => {
|
|
580
|
+
size += c.length;
|
|
581
|
+
if (size > limit) { reject(new Error('请求体过大')); req.destroy(); return; }
|
|
582
|
+
chunks.push(c);
|
|
583
|
+
});
|
|
584
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
585
|
+
req.on('error', reject);
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
function isDirectory(p) {
|
|
589
|
+
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
590
|
+
}
|
|
591
|
+
function samePath(a, b) {
|
|
592
|
+
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function scanAll(filterWord, rootsOverride) {
|
|
596
|
+
const roots = rootsOverride || filterRoots(loadRoots(), filterWord);
|
|
597
|
+
console.log(`扫描 ${roots.length} 个根目录...`);
|
|
598
|
+
const workspaces = findWorkspaces(roots);
|
|
599
|
+
for (const ws of workspaces) {
|
|
600
|
+
console.log(` ✓ ${ws.root} (schema=${ws.schemaName}, active=${ws.active.length}, archived=${ws.archived.length})`);
|
|
601
|
+
}
|
|
602
|
+
return { generatedAt: new Date().toISOString(), roots, workspaces };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// 注入 HTML 模板(模板与扫描器分离, 便于独立调整页面)
|
|
606
|
+
const TEMPLATE_FILE = path.join(TOOL_DIR, 'template.html');
|
|
607
|
+
const TEMPLATE = readText(TEMPLATE_FILE);
|
|
608
|
+
if (!TEMPLATE) {
|
|
609
|
+
console.error(`模板缺失: ${TEMPLATE_FILE}`);
|
|
610
|
+
process.exit(1);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// 模板热加载 —— serve 是常驻进程: 若只在启动时读一次模板, 改完 template.html 后
|
|
614
|
+
// ① 页面永远吐旧 UI; ② /api/rescan 会把旧 UI 写回 dashboard.html, 覆盖掉磁盘上的新构建。
|
|
615
|
+
// 所以每次构建前比对 mtime, 变了就重新读盘。
|
|
616
|
+
let _tplText = TEMPLATE, _tplMtime = 0;
|
|
617
|
+
try { _tplMtime = fs.statSync(TEMPLATE_FILE).mtimeMs; } catch {}
|
|
618
|
+
function templateMtime() { try { return fs.statSync(TEMPLATE_FILE).mtimeMs; } catch { return 0; } }
|
|
619
|
+
function currentTemplate() {
|
|
620
|
+
const mt = templateMtime();
|
|
621
|
+
if (mt && mt !== _tplMtime) {
|
|
622
|
+
const t = readText(TEMPLATE_FILE);
|
|
623
|
+
if (t) {
|
|
624
|
+
_tplText = t; _tplMtime = mt;
|
|
625
|
+
console.log(`[template] 检测到模板更新, 已重新加载 (${(t.length / 1024).toFixed(1)} KB)`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return _tplText;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// 必须用函数替换: json 内容含 $` $' $& 等序列时, 字符串替换会做特殊展开破坏数据
|
|
632
|
+
function buildHtml(data) {
|
|
633
|
+
const json = JSON.stringify(data).replace(/</g, '\\u003c');
|
|
634
|
+
const tpl = currentTemplate();
|
|
635
|
+
const html = tpl.replace('__DATA__', () => json);
|
|
636
|
+
// 构建指纹: 排查「页面还是旧的」时, 一眼看出这页是哪份模板、什么时候生成的
|
|
637
|
+
const stamp = `<!-- imsflow build: template=${_tplMtime} ${new Date().toISOString()} tpl=${tpl.length}B -->`;
|
|
638
|
+
return /<!DOCTYPE html[^>]*>/i.test(html) ? html.replace(/<!DOCTYPE html[^>]*>/i, (m) => m + '\n' + stamp) : html;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const args = process.argv.slice(2);
|
|
642
|
+
const wantOpen = args.includes('--open');
|
|
643
|
+
const positional = args.filter(a => !a.startsWith('--'));
|
|
644
|
+
const serveMode = positional.includes('serve');
|
|
645
|
+
// 非 flag 位置参数(除 serve 外)= 根路径过滤词(按名称或路径模糊匹配)
|
|
646
|
+
const filterWord = positional.filter(a => a !== 'serve').pop() || null;
|
|
647
|
+
|
|
648
|
+
if (serveMode) {
|
|
649
|
+
// ---- 服务模式: imsflow serve [--port=N] ----
|
|
650
|
+
// 页面内「重新扫描」按钮调 /api/rescan 实时重扫, 解决快照模式的刷新问题
|
|
651
|
+
const portArg = args.find(a => a.startsWith('--port='));
|
|
652
|
+
const PORT = portArg ? parseInt(portArg.slice(7), 10) : 17771;
|
|
653
|
+
let cachedHtml = null;
|
|
654
|
+
let cachedTpl = 0; // 当前 cachedHtml 所用的模板 mtime
|
|
655
|
+
let lastData = null; // 最近一次扫描数据, 模板热更新时用它重建页面(不必重扫)
|
|
656
|
+
// 统一「重建 + 落盘」入口, 保证内存缓存与磁盘快照一致
|
|
657
|
+
const refresh = (data) => {
|
|
658
|
+
lastData = data;
|
|
659
|
+
cachedHtml = buildHtml(data);
|
|
660
|
+
cachedTpl = templateMtime();
|
|
661
|
+
try { fs.writeFileSync(OUT_HTML, cachedHtml, 'utf-8'); } catch {}
|
|
662
|
+
return cachedHtml;
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const server = http.createServer(async (req, res) => {
|
|
666
|
+
const url = (req.url || '/').split('?')[0];
|
|
667
|
+
const sendJson = (code, obj) => {
|
|
668
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
669
|
+
res.end(JSON.stringify(obj));
|
|
670
|
+
};
|
|
671
|
+
try {
|
|
672
|
+
if (url === '/api/roots' && req.method === 'GET') {
|
|
673
|
+
// 页面「工作区」面板拉取已配置的扫描根
|
|
674
|
+
sendJson(200, { ok: true, roots: safeLoadRoots(), configPath: PROJECTS_FILE });
|
|
675
|
+
} else if (url === '/api/roots' && req.method === 'POST') {
|
|
676
|
+
// 增/删扫描根: 校验后持久化到 projects.json, 再全量重扫(loadRoots 重新读盘)
|
|
677
|
+
const body = JSON.parse(await readBody(req));
|
|
678
|
+
const action = body.action;
|
|
679
|
+
const raw = String(body.root || '').trim();
|
|
680
|
+
if (!raw) { sendJson(400, { ok: false, error: '路径为空' }); return; }
|
|
681
|
+
const roots = safeLoadRoots();
|
|
682
|
+
if (action === 'add') {
|
|
683
|
+
const abs = path.resolve(raw);
|
|
684
|
+
if (!isDirectory(abs)) { sendJson(400, { ok: false, error: `目录不存在: ${abs}` }); return; }
|
|
685
|
+
if (roots.some(r => samePath(r, abs))) { sendJson(400, { ok: false, error: '该路径已在扫描列表中' }); return; }
|
|
686
|
+
roots.push(abs);
|
|
687
|
+
saveRoots(roots);
|
|
688
|
+
} else if (action === 'remove') {
|
|
689
|
+
if (!roots.some(r => samePath(r, raw))) { sendJson(400, { ok: false, error: '该路径不在扫描列表中' }); return; }
|
|
690
|
+
if (roots.length <= 1) { sendJson(400, { ok: false, error: '至少保留一个扫描根,移除后列表为空会导致服务无内容可扫' }); return; }
|
|
691
|
+
saveRoots(roots.filter(r => !samePath(r, raw)));
|
|
692
|
+
} else {
|
|
693
|
+
sendJson(400, { ok: false, error: 'action 必须是 add 或 remove' });
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const data = scanAll(null);
|
|
697
|
+
refresh(data);
|
|
698
|
+
console.log(`[roots:${action}] ${raw} · 现在 ${data.workspaces.length} 个工作区`);
|
|
699
|
+
sendJson(200, { ok: true, roots: data.roots });
|
|
700
|
+
} else if (url === '/api/rescan') {
|
|
701
|
+
// GET = 全量重扫(页面路径筛选器负责聚焦); POST {roots:[...]} = 只扫勾选的根, 不落盘
|
|
702
|
+
let subset = null;
|
|
703
|
+
if (req.method === 'POST') {
|
|
704
|
+
const body = JSON.parse(await readBody(req));
|
|
705
|
+
const configured = safeLoadRoots();
|
|
706
|
+
subset = [...new Set((Array.isArray(body.roots) ? body.roots : []).filter(r => configured.some(c => samePath(c, String(r)))))];
|
|
707
|
+
if (subset.length === 0) { sendJson(400, { ok: false, error: '未选择任何已配置的扫描根' }); return; }
|
|
708
|
+
} else if (safeLoadRoots().length === 0) {
|
|
709
|
+
sendJson(500, { ok: false, error: 'projects.json 未配置任何扫描根' });
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const t0 = Date.now();
|
|
713
|
+
const data = scanAll(null, subset);
|
|
714
|
+
refresh(data); // 顺手刷新磁盘快照
|
|
715
|
+
console.log(`[rescan] ${subset ? `选中 ${subset.length} 根` : '全量'} · ${data.workspaces.length} 个工作区 · ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
|
716
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
717
|
+
res.end(JSON.stringify(data));
|
|
718
|
+
} else if (url === '/' || url === '/index.html') {
|
|
719
|
+
if (!cachedHtml) {
|
|
720
|
+
refresh(scanAll(null));
|
|
721
|
+
console.log(`初始扫描完成, 页面 ${(cachedHtml.length / 1024).toFixed(0)} KB`);
|
|
722
|
+
} else if (templateMtime() !== cachedTpl && lastData) {
|
|
723
|
+
// 模板文件改过了 → 用上次扫描数据重建(不必重扫),刷新页面即可看到新 UI
|
|
724
|
+
refresh(lastData);
|
|
725
|
+
console.log('[template] 热更新生效, 页面已重建');
|
|
726
|
+
}
|
|
727
|
+
// no-store: 不设的话浏览器会启发式缓存, 模板改完刷新页面仍是旧 UI,
|
|
728
|
+
// 会被误判成「没生效」而重复排查构建链路。
|
|
729
|
+
res.writeHead(200, {
|
|
730
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
731
|
+
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
|
732
|
+
'Pragma': 'no-cache',
|
|
733
|
+
});
|
|
734
|
+
res.end(cachedHtml);
|
|
735
|
+
} else {
|
|
736
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
737
|
+
res.end('not found');
|
|
738
|
+
}
|
|
739
|
+
} catch (e) {
|
|
740
|
+
// 扫描中途出错不能让进程崩掉 —— 崩了用户页面就直接"不见了"
|
|
741
|
+
console.error('[serve] 请求处理出错:', e.message);
|
|
742
|
+
if (!res.headersSent) {
|
|
743
|
+
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
744
|
+
res.end('扫描失败: ' + e.message);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
server.on('error', (e) => {
|
|
750
|
+
if (e.code === 'EADDRINUSE') {
|
|
751
|
+
console.error(`端口 ${PORT} 已被占用(可能已有一个 imsflow serve 在跑,浏览器直接开 http://localhost:${PORT} 即可)。`);
|
|
752
|
+
console.error(`换端口: imsflow serve --port=17772`);
|
|
753
|
+
} else {
|
|
754
|
+
console.error(e.message);
|
|
755
|
+
}
|
|
756
|
+
process.exit(1);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
server.listen(PORT, () => {
|
|
760
|
+
const url = `http://localhost:${PORT}`;
|
|
761
|
+
console.log(`imsflow serve 已启动: ${url}`);
|
|
762
|
+
console.log(`模板: ${TEMPLATE_FILE}`);
|
|
763
|
+
console.log(` ${(currentTemplate().length / 1024).toFixed(1)} KB · mtime ${_tplMtime}(改动后刷新页面即生效, 无需重启)`);
|
|
764
|
+
console.log('页面内「↻ 重新扫描」按钮已激活(快照模式无此能力)');
|
|
765
|
+
console.log('Ctrl+C 退出');
|
|
766
|
+
exec(`start "" "${url}"`);
|
|
767
|
+
});
|
|
768
|
+
} else {
|
|
769
|
+
// ---- 快照模式: imsflow [过滤词] [--open] ----
|
|
770
|
+
const data = scanAll(filterWord);
|
|
771
|
+
const html = buildHtml(data);
|
|
772
|
+
fs.writeFileSync(OUT_HTML, html, 'utf-8');
|
|
773
|
+
const sizeKB = (fs.statSync(OUT_HTML).size / 1024).toFixed(0);
|
|
774
|
+
console.log(`\n已生成: ${OUT_HTML} (${sizeKB} KB)`);
|
|
775
|
+
if (wantOpen) {
|
|
776
|
+
exec(`start "" "${OUT_HTML}"`);
|
|
777
|
+
console.log('已在浏览器打开(快照模式;页面内重扫需 imsflow serve)');
|
|
778
|
+
}
|
|
779
|
+
}
|