@openspecui/core 0.9.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/dist/index.d.mts +1712 -0
- package/dist/index.mjs +2706 -0
- package/package.json +36 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2706 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
|
+
import { readFile as readFile$1, readdir, stat } from "node:fs/promises";
|
|
5
|
+
import { dirname, join as join$1, resolve } from "node:path";
|
|
6
|
+
import { existsSync, realpathSync, utimesSync } from "node:fs";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { watch } from "fs";
|
|
9
|
+
import { EventEmitter } from "events";
|
|
10
|
+
import { exec, spawn } from "child_process";
|
|
11
|
+
import { promisify } from "util";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
|
|
14
|
+
//#region src/parser.ts
|
|
15
|
+
/**
|
|
16
|
+
* Markdown parser for OpenSpec documents
|
|
17
|
+
*/
|
|
18
|
+
var MarkdownParser = class {
|
|
19
|
+
/**
|
|
20
|
+
* Parse a spec markdown content into a Spec object
|
|
21
|
+
*/
|
|
22
|
+
parseSpec(specId, content) {
|
|
23
|
+
const lines = content.split("\n");
|
|
24
|
+
let name = specId;
|
|
25
|
+
let overview = "";
|
|
26
|
+
const requirements = [];
|
|
27
|
+
let currentSection = "";
|
|
28
|
+
let currentRequirement = null;
|
|
29
|
+
let currentScenarioText = "";
|
|
30
|
+
let reqIndex = 0;
|
|
31
|
+
for (let i = 0; i < lines.length; i++) {
|
|
32
|
+
const line = lines[i];
|
|
33
|
+
if (line.startsWith("# ") && name === specId) {
|
|
34
|
+
name = line.slice(2).trim();
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (line.startsWith("## ")) {
|
|
38
|
+
const sectionTitle = line.slice(3).trim().toLowerCase();
|
|
39
|
+
if (sectionTitle.includes("purpose") || sectionTitle.includes("overview")) currentSection = "overview";
|
|
40
|
+
else if (sectionTitle.includes("requirement")) currentSection = "requirements";
|
|
41
|
+
else currentSection = sectionTitle;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (line.startsWith("### Requirement:") || line.startsWith("### ") && currentSection === "requirements") {
|
|
45
|
+
if (currentRequirement) {
|
|
46
|
+
if (currentScenarioText.trim()) {
|
|
47
|
+
currentRequirement.scenarios = currentRequirement.scenarios || [];
|
|
48
|
+
currentRequirement.scenarios.push({ rawText: currentScenarioText.trim() });
|
|
49
|
+
}
|
|
50
|
+
requirements.push({
|
|
51
|
+
id: currentRequirement.id || `req-${reqIndex}`,
|
|
52
|
+
text: currentRequirement.text || "",
|
|
53
|
+
scenarios: currentRequirement.scenarios || []
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
reqIndex++;
|
|
57
|
+
const reqTitle = line.replace(/^###\s*(Requirement:\s*)?/, "").trim();
|
|
58
|
+
currentRequirement = {
|
|
59
|
+
id: `req-${reqIndex}`,
|
|
60
|
+
text: reqTitle,
|
|
61
|
+
scenarios: []
|
|
62
|
+
};
|
|
63
|
+
currentScenarioText = "";
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (line.startsWith("#### Scenario:") || line.startsWith("#### ")) {
|
|
67
|
+
if (currentScenarioText.trim() && currentRequirement) {
|
|
68
|
+
currentRequirement.scenarios = currentRequirement.scenarios || [];
|
|
69
|
+
currentRequirement.scenarios.push({ rawText: currentScenarioText.trim() });
|
|
70
|
+
}
|
|
71
|
+
currentScenarioText = line.replace(/^####\s*(Scenario:\s*)?/, "").trim() + "\n";
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (currentSection === "overview" && !currentRequirement) overview += line + "\n";
|
|
75
|
+
else if (currentRequirement && line.trim()) {
|
|
76
|
+
if (line.startsWith("- ") || line.startsWith("* ")) currentScenarioText += line + "\n";
|
|
77
|
+
else if (!line.startsWith("#")) if (currentRequirement.text && !currentScenarioText) currentRequirement.text += " " + line.trim();
|
|
78
|
+
else currentScenarioText += line + "\n";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (currentRequirement) {
|
|
82
|
+
if (currentScenarioText.trim()) {
|
|
83
|
+
currentRequirement.scenarios = currentRequirement.scenarios || [];
|
|
84
|
+
currentRequirement.scenarios.push({ rawText: currentScenarioText.trim() });
|
|
85
|
+
}
|
|
86
|
+
requirements.push({
|
|
87
|
+
id: currentRequirement.id || `req-${reqIndex}`,
|
|
88
|
+
text: currentRequirement.text || "",
|
|
89
|
+
scenarios: currentRequirement.scenarios || []
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
id: specId,
|
|
94
|
+
name: name || specId,
|
|
95
|
+
overview: overview.trim(),
|
|
96
|
+
requirements,
|
|
97
|
+
metadata: {
|
|
98
|
+
version: "1.0.0",
|
|
99
|
+
format: "openspec"
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Parse a change proposal markdown content into a Change object
|
|
105
|
+
*/
|
|
106
|
+
parseChange(changeId, proposalContent, tasksContent = "", options) {
|
|
107
|
+
const lines = proposalContent.split("\n");
|
|
108
|
+
let name = changeId;
|
|
109
|
+
let why = "";
|
|
110
|
+
let whatChanges = "";
|
|
111
|
+
const deltas = [];
|
|
112
|
+
let currentSection = "";
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
if (line.startsWith("# ")) {
|
|
115
|
+
name = line.slice(2).trim();
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (line.startsWith("## ")) {
|
|
119
|
+
const sectionTitle = line.slice(3).trim().toLowerCase();
|
|
120
|
+
if (sectionTitle.includes("why")) currentSection = "why";
|
|
121
|
+
else if (sectionTitle.includes("what") || sectionTitle.includes("change")) currentSection = "whatChanges";
|
|
122
|
+
else if (sectionTitle.includes("impact") || sectionTitle.includes("delta")) currentSection = "impact";
|
|
123
|
+
else currentSection = sectionTitle;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (currentSection === "why") why += line + "\n";
|
|
127
|
+
else if (currentSection === "whatChanges") whatChanges += line + "\n";
|
|
128
|
+
else if (currentSection === "impact") {
|
|
129
|
+
const specMatch = line.match(/specs\/([a-zA-Z0-9-_]+)/);
|
|
130
|
+
if (specMatch) deltas.push({
|
|
131
|
+
spec: specMatch[1],
|
|
132
|
+
operation: "MODIFIED",
|
|
133
|
+
description: line.trim()
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const tasks = this.parseTasks(tasksContent);
|
|
138
|
+
const deltasFromDeltaSpecs = this.parseDeltasFromDeltaSpecs(options?.deltaSpecs);
|
|
139
|
+
const deltasFromWhatChanges = this.parseDeltasFromWhatChanges(whatChanges);
|
|
140
|
+
const combinedDeltas = deltasFromDeltaSpecs.length > 0 ? deltasFromDeltaSpecs : deltas;
|
|
141
|
+
const finalDeltas = combinedDeltas.length > 0 ? combinedDeltas : deltasFromWhatChanges;
|
|
142
|
+
return {
|
|
143
|
+
id: changeId,
|
|
144
|
+
name: name || changeId,
|
|
145
|
+
why: why.trim(),
|
|
146
|
+
whatChanges: whatChanges.trim(),
|
|
147
|
+
deltas: finalDeltas,
|
|
148
|
+
tasks,
|
|
149
|
+
progress: {
|
|
150
|
+
total: tasks.length,
|
|
151
|
+
completed: tasks.filter((t) => t.completed).length
|
|
152
|
+
},
|
|
153
|
+
design: options?.design,
|
|
154
|
+
deltaSpecs: options?.deltaSpecs
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
parseDeltasFromWhatChanges(whatChanges) {
|
|
158
|
+
if (!whatChanges.trim()) return [];
|
|
159
|
+
const deltas = [];
|
|
160
|
+
const lines = whatChanges.split("\n");
|
|
161
|
+
for (const line of lines) {
|
|
162
|
+
const match = line.match(/^\s*-\s*\*\*([^*:]+)(?::\*\*|\*\*:):?\s*(.+)$/);
|
|
163
|
+
if (!match) continue;
|
|
164
|
+
const spec = match[1].trim();
|
|
165
|
+
const description = match[2].trim();
|
|
166
|
+
const lower = description.toLowerCase();
|
|
167
|
+
let operation = "MODIFIED";
|
|
168
|
+
if (/\brename(s|d|ing)?\b/.test(lower) || /\brenamed\b/.test(lower)) operation = "RENAMED";
|
|
169
|
+
else if (/\bremove(s|d|ing)?\b/.test(lower) || /\bdelete(s|d|ing)?\b/.test(lower)) operation = "REMOVED";
|
|
170
|
+
else if (/\badd(s|ed|ing)?\b/.test(lower) || /\bcreate(s|d|ing)?\b/.test(lower) || /\bnew\b/.test(lower)) operation = "ADDED";
|
|
171
|
+
deltas.push({
|
|
172
|
+
spec,
|
|
173
|
+
operation,
|
|
174
|
+
description
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return deltas;
|
|
178
|
+
}
|
|
179
|
+
parseDeltasFromDeltaSpecs(deltaSpecs) {
|
|
180
|
+
if (!deltaSpecs || deltaSpecs.length === 0) return [];
|
|
181
|
+
return deltaSpecs.flatMap((deltaSpec) => this.parseDeltaSpecContent(deltaSpec));
|
|
182
|
+
}
|
|
183
|
+
parseDeltaSpecContent(deltaSpec) {
|
|
184
|
+
const deltas = [];
|
|
185
|
+
const lines = deltaSpec.content.split("\n");
|
|
186
|
+
let currentOperation = null;
|
|
187
|
+
let currentRequirement = null;
|
|
188
|
+
let renameBuffer = null;
|
|
189
|
+
let reqIndex = 0;
|
|
190
|
+
const finalizeRequirement = () => {
|
|
191
|
+
if (!currentOperation || !currentRequirement) return;
|
|
192
|
+
const scenarios = currentRequirement.scenarios.map((scenario) => {
|
|
193
|
+
const rawText = [scenario.title, ...scenario.lines].join("\n").trim();
|
|
194
|
+
return rawText ? { rawText } : null;
|
|
195
|
+
}).filter((s) => Boolean(s));
|
|
196
|
+
const descriptionText = currentRequirement.descriptionLines.map((l) => l.trim()).filter(Boolean).join(" ");
|
|
197
|
+
const requirement = {
|
|
198
|
+
id: `${deltaSpec.specId}-${currentOperation.toLowerCase()}-${++reqIndex}`,
|
|
199
|
+
text: descriptionText || currentRequirement.title,
|
|
200
|
+
scenarios
|
|
201
|
+
};
|
|
202
|
+
deltas.push({
|
|
203
|
+
spec: deltaSpec.specId,
|
|
204
|
+
operation: currentOperation,
|
|
205
|
+
description: `${currentOperation} requirement: ${requirement.text}`,
|
|
206
|
+
requirement,
|
|
207
|
+
requirements: [requirement]
|
|
208
|
+
});
|
|
209
|
+
};
|
|
210
|
+
for (const rawLine of lines) {
|
|
211
|
+
const line = rawLine.trimEnd();
|
|
212
|
+
const opMatch = line.match(/^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements/i);
|
|
213
|
+
if (opMatch) {
|
|
214
|
+
finalizeRequirement();
|
|
215
|
+
currentRequirement = null;
|
|
216
|
+
currentOperation = opMatch[1].toUpperCase();
|
|
217
|
+
renameBuffer = null;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (currentOperation === "RENAMED") {
|
|
221
|
+
const fromMatch = line.match(/FROM:\s*`?###\s*Requirement:\s*(.+?)`?$/i);
|
|
222
|
+
const toMatch = line.match(/TO:\s*`?###\s*Requirement:\s*(.+?)`?$/i);
|
|
223
|
+
if (fromMatch) renameBuffer = {
|
|
224
|
+
...renameBuffer ?? {},
|
|
225
|
+
from: fromMatch[1].trim()
|
|
226
|
+
};
|
|
227
|
+
if (toMatch) renameBuffer = {
|
|
228
|
+
...renameBuffer ?? {},
|
|
229
|
+
to: toMatch[1].trim()
|
|
230
|
+
};
|
|
231
|
+
if (renameBuffer?.from && renameBuffer?.to) {
|
|
232
|
+
deltas.push({
|
|
233
|
+
spec: deltaSpec.specId,
|
|
234
|
+
operation: "RENAMED",
|
|
235
|
+
description: `Rename requirement from "${renameBuffer.from}" to "${renameBuffer.to}"`,
|
|
236
|
+
rename: {
|
|
237
|
+
from: renameBuffer.from,
|
|
238
|
+
to: renameBuffer.to
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
renameBuffer = null;
|
|
242
|
+
}
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const requirementMatch = line.match(/^###\s+Requirement:\s*(.+)$/);
|
|
246
|
+
if (requirementMatch) {
|
|
247
|
+
finalizeRequirement();
|
|
248
|
+
currentRequirement = {
|
|
249
|
+
title: requirementMatch[1].trim(),
|
|
250
|
+
descriptionLines: [],
|
|
251
|
+
scenarios: []
|
|
252
|
+
};
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const scenarioMatch = line.match(/^####\s*Scenario:?\s*(.*)$/);
|
|
256
|
+
if (scenarioMatch && currentRequirement) {
|
|
257
|
+
const title = scenarioMatch[1].trim() || "Scenario";
|
|
258
|
+
currentRequirement.scenarios.push({
|
|
259
|
+
title,
|
|
260
|
+
lines: []
|
|
261
|
+
});
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (currentRequirement) {
|
|
265
|
+
const activeScenario = currentRequirement.scenarios[currentRequirement.scenarios.length - 1];
|
|
266
|
+
if (activeScenario) activeScenario.lines.push(line);
|
|
267
|
+
else currentRequirement.descriptionLines.push(line);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
finalizeRequirement();
|
|
271
|
+
return deltas;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Parse tasks from a tasks.md content
|
|
275
|
+
*/
|
|
276
|
+
parseTasks(content) {
|
|
277
|
+
if (!content) return [];
|
|
278
|
+
const tasks = [];
|
|
279
|
+
const lines = content.split("\n");
|
|
280
|
+
let currentSection = "";
|
|
281
|
+
let taskIndex = 0;
|
|
282
|
+
for (const line of lines) {
|
|
283
|
+
if (line.startsWith("## ")) {
|
|
284
|
+
currentSection = line.slice(3).trim();
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const taskMatch = line.match(/^[-*]\s+\[([ xX])\]\s+(.+)$/);
|
|
288
|
+
if (taskMatch) {
|
|
289
|
+
taskIndex++;
|
|
290
|
+
tasks.push({
|
|
291
|
+
id: `task-${taskIndex}`,
|
|
292
|
+
text: taskMatch[2].trim(),
|
|
293
|
+
completed: taskMatch[1].toLowerCase() === "x",
|
|
294
|
+
section: currentSection || void 0
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return tasks;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Serialize a spec back to markdown
|
|
302
|
+
*/
|
|
303
|
+
serializeSpec(spec) {
|
|
304
|
+
let content = `# ${spec.name}\n\n`;
|
|
305
|
+
content += `## Purpose\n${spec.overview}\n\n`;
|
|
306
|
+
content += `## Requirements\n`;
|
|
307
|
+
for (const req of spec.requirements) {
|
|
308
|
+
content += `\n### Requirement: ${req.text}\n`;
|
|
309
|
+
for (const scenario of req.scenarios) content += `\n#### Scenario\n${scenario.rawText}\n`;
|
|
310
|
+
}
|
|
311
|
+
return content;
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
//#endregion
|
|
316
|
+
//#region src/validator.ts
|
|
317
|
+
/**
|
|
318
|
+
* Validator for OpenSpec documents
|
|
319
|
+
*/
|
|
320
|
+
var Validator = class {
|
|
321
|
+
/**
|
|
322
|
+
* Validate a spec document
|
|
323
|
+
*/
|
|
324
|
+
validateSpec(spec) {
|
|
325
|
+
const issues = [];
|
|
326
|
+
if (!spec.overview || spec.overview.trim().length === 0) issues.push({
|
|
327
|
+
severity: "ERROR",
|
|
328
|
+
message: "Spec must have a Purpose/Overview section",
|
|
329
|
+
path: "overview"
|
|
330
|
+
});
|
|
331
|
+
if (spec.requirements.length === 0) issues.push({
|
|
332
|
+
severity: "ERROR",
|
|
333
|
+
message: "Spec must have at least one requirement",
|
|
334
|
+
path: "requirements"
|
|
335
|
+
});
|
|
336
|
+
for (const req of spec.requirements) {
|
|
337
|
+
if (!req.text.includes("SHALL") && !req.text.includes("MUST")) issues.push({
|
|
338
|
+
severity: "WARNING",
|
|
339
|
+
message: `Requirement should contain "SHALL" or "MUST": ${req.id}`,
|
|
340
|
+
path: `requirements.${req.id}`
|
|
341
|
+
});
|
|
342
|
+
if (req.scenarios.length === 0) issues.push({
|
|
343
|
+
severity: "WARNING",
|
|
344
|
+
message: `Requirement should have at least one scenario: ${req.id}`,
|
|
345
|
+
path: `requirements.${req.id}.scenarios`
|
|
346
|
+
});
|
|
347
|
+
if (req.text.length > 1e3) issues.push({
|
|
348
|
+
severity: "WARNING",
|
|
349
|
+
message: `Requirement text is too long (max 1000 chars): ${req.id}`,
|
|
350
|
+
path: `requirements.${req.id}.text`
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
valid: issues.filter((i) => i.severity === "ERROR").length === 0,
|
|
355
|
+
issues
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Validate a change proposal
|
|
360
|
+
*/
|
|
361
|
+
validateChange(change) {
|
|
362
|
+
const issues = [];
|
|
363
|
+
if (!change.why || change.why.length < 50) issues.push({
|
|
364
|
+
severity: "ERROR",
|
|
365
|
+
message: "Change \"Why\" section must be at least 50 characters",
|
|
366
|
+
path: "why"
|
|
367
|
+
});
|
|
368
|
+
if (change.why && change.why.length > 500) issues.push({
|
|
369
|
+
severity: "WARNING",
|
|
370
|
+
message: "Change \"Why\" section should be under 500 characters",
|
|
371
|
+
path: "why"
|
|
372
|
+
});
|
|
373
|
+
if (!change.whatChanges || change.whatChanges.trim().length === 0) issues.push({
|
|
374
|
+
severity: "ERROR",
|
|
375
|
+
message: "Change must have a \"What Changes\" section",
|
|
376
|
+
path: "whatChanges"
|
|
377
|
+
});
|
|
378
|
+
if (change.deltas.length === 0) issues.push({
|
|
379
|
+
severity: "WARNING",
|
|
380
|
+
message: "Change should have at least one delta",
|
|
381
|
+
path: "deltas"
|
|
382
|
+
});
|
|
383
|
+
if (change.deltas.length > 50) issues.push({
|
|
384
|
+
severity: "WARNING",
|
|
385
|
+
message: "Change has too many deltas (max 50)",
|
|
386
|
+
path: "deltas"
|
|
387
|
+
});
|
|
388
|
+
return {
|
|
389
|
+
valid: issues.filter((i) => i.severity === "ERROR").length === 0,
|
|
390
|
+
issues
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
//#endregion
|
|
396
|
+
//#region src/reactive-fs/reactive-state.ts
|
|
397
|
+
/**
|
|
398
|
+
* 全局的 AsyncLocalStorage,用于在异步调用链中传递 ReactiveContext
|
|
399
|
+
* 这是实现依赖收集的核心机制
|
|
400
|
+
*/
|
|
401
|
+
const contextStorage = new AsyncLocalStorage();
|
|
402
|
+
/**
|
|
403
|
+
* 响应式状态类,类似 Signal.State
|
|
404
|
+
*
|
|
405
|
+
* 核心机制:
|
|
406
|
+
* - get() 时自动注册到当前 ReactiveContext 的依赖列表
|
|
407
|
+
* - set() 时如果值变化,通知所有依赖的 Context
|
|
408
|
+
*/
|
|
409
|
+
var ReactiveState = class {
|
|
410
|
+
currentValue;
|
|
411
|
+
equals;
|
|
412
|
+
/** 所有依赖此状态的 Context */
|
|
413
|
+
subscribers = /* @__PURE__ */ new Set();
|
|
414
|
+
constructor(initialValue, options) {
|
|
415
|
+
this.currentValue = initialValue;
|
|
416
|
+
this.equals = options?.equals ?? ((a, b) => a === b);
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* 获取当前值
|
|
420
|
+
* 如果在 ReactiveContext 中调用,会自动注册依赖
|
|
421
|
+
*/
|
|
422
|
+
get() {
|
|
423
|
+
const context = contextStorage.getStore();
|
|
424
|
+
if (context) {
|
|
425
|
+
context.track(this);
|
|
426
|
+
this.subscribers.add(context);
|
|
427
|
+
}
|
|
428
|
+
return this.currentValue;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* 设置新值
|
|
432
|
+
* 如果值变化,通知所有订阅者
|
|
433
|
+
* @returns 是否发生了变化
|
|
434
|
+
*/
|
|
435
|
+
set(newValue) {
|
|
436
|
+
if (this.equals(this.currentValue, newValue)) return false;
|
|
437
|
+
this.currentValue = newValue;
|
|
438
|
+
for (const context of this.subscribers) context.notifyChange();
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* 取消订阅
|
|
443
|
+
* 当 Context 销毁时调用
|
|
444
|
+
*/
|
|
445
|
+
unsubscribe(context) {
|
|
446
|
+
this.subscribers.delete(context);
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* 获取当前订阅者数量(用于调试)
|
|
450
|
+
*/
|
|
451
|
+
get subscriberCount() {
|
|
452
|
+
return this.subscribers.size;
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
//#endregion
|
|
457
|
+
//#region src/reactive-fs/reactive-context.ts
|
|
458
|
+
function createPromiseWithResolvers() {
|
|
459
|
+
let resolve$1;
|
|
460
|
+
let reject;
|
|
461
|
+
return {
|
|
462
|
+
promise: new Promise((res, rej) => {
|
|
463
|
+
resolve$1 = res;
|
|
464
|
+
reject = rej;
|
|
465
|
+
}),
|
|
466
|
+
resolve: resolve$1,
|
|
467
|
+
reject
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* 响应式上下文,管理依赖收集和变更通知
|
|
472
|
+
*
|
|
473
|
+
* 核心机制:
|
|
474
|
+
* - 在 stream() 中执行任务时,通过 AsyncLocalStorage 传递 this
|
|
475
|
+
* - 任务中的所有 ReactiveState.get() 调用都会自动注册依赖
|
|
476
|
+
* - 当任何依赖变更时,重新执行任务并 yield 新结果
|
|
477
|
+
*/
|
|
478
|
+
var ReactiveContext = class {
|
|
479
|
+
/** 当前追踪的依赖 */
|
|
480
|
+
dependencies = /* @__PURE__ */ new Set();
|
|
481
|
+
/** 等待变更的 Promise */
|
|
482
|
+
changePromise;
|
|
483
|
+
/** 是否已销毁 */
|
|
484
|
+
destroyed = false;
|
|
485
|
+
/**
|
|
486
|
+
* 追踪依赖
|
|
487
|
+
* 由 ReactiveState.get() 调用
|
|
488
|
+
*/
|
|
489
|
+
track(state) {
|
|
490
|
+
if (!this.destroyed) this.dependencies.add(state);
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* 通知变更
|
|
494
|
+
* 由 ReactiveState.set() 调用
|
|
495
|
+
*/
|
|
496
|
+
notifyChange() {
|
|
497
|
+
if (!this.destroyed && this.changePromise) this.changePromise.resolve();
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* 运行响应式任务流
|
|
501
|
+
* 每次依赖变更时重新执行任务并 yield 结果
|
|
502
|
+
*
|
|
503
|
+
* @param task 要执行的异步任务
|
|
504
|
+
* @param signal 用于取消的 AbortSignal
|
|
505
|
+
*/
|
|
506
|
+
async *stream(task, signal) {
|
|
507
|
+
try {
|
|
508
|
+
while (!signal?.aborted && !this.destroyed) {
|
|
509
|
+
this.clearDependencies();
|
|
510
|
+
this.changePromise = createPromiseWithResolvers();
|
|
511
|
+
yield await contextStorage.run(this, task);
|
|
512
|
+
if (this.dependencies.size === 0) break;
|
|
513
|
+
await Promise.race([this.changePromise.promise, signal ? this.waitForAbort(signal) : new Promise(() => {})]);
|
|
514
|
+
if (signal?.aborted) break;
|
|
515
|
+
}
|
|
516
|
+
} finally {
|
|
517
|
+
this.destroy();
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* 执行一次任务(非响应式)
|
|
522
|
+
* 用于初始数据获取
|
|
523
|
+
*/
|
|
524
|
+
async runOnce(task) {
|
|
525
|
+
return contextStorage.run(this, task);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* 清理依赖
|
|
529
|
+
*/
|
|
530
|
+
clearDependencies() {
|
|
531
|
+
for (const state of this.dependencies) state.unsubscribe(this);
|
|
532
|
+
this.dependencies.clear();
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* 销毁上下文
|
|
536
|
+
* @param reason 可选的销毁原因,如果提供则 reject changePromise
|
|
537
|
+
*/
|
|
538
|
+
destroy(reason) {
|
|
539
|
+
this.destroyed = true;
|
|
540
|
+
this.clearDependencies();
|
|
541
|
+
if (reason && this.changePromise) this.changePromise.reject(reason);
|
|
542
|
+
this.changePromise = void 0;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* 等待 AbortSignal
|
|
546
|
+
*/
|
|
547
|
+
waitForAbort(signal) {
|
|
548
|
+
return new Promise((_, reject) => {
|
|
549
|
+
if (signal.aborted) {
|
|
550
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
signal.addEventListener("abort", () => {
|
|
554
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
//#endregion
|
|
561
|
+
//#region src/reactive-fs/project-watcher.ts
|
|
562
|
+
/**
|
|
563
|
+
* 获取路径的真实路径(解析符号链接)
|
|
564
|
+
* 在 macOS 上,/var 是 /private/var 的符号链接
|
|
565
|
+
*/
|
|
566
|
+
function getRealPath$1(path) {
|
|
567
|
+
try {
|
|
568
|
+
return realpathSync(resolve(path));
|
|
569
|
+
} catch {
|
|
570
|
+
return resolve(path);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/** 默认防抖时间 (ms) */
|
|
574
|
+
const DEBOUNCE_MS$1 = 50;
|
|
575
|
+
/** 默认忽略模式 */
|
|
576
|
+
const DEFAULT_IGNORE = [
|
|
577
|
+
"node_modules",
|
|
578
|
+
".git",
|
|
579
|
+
"**/.DS_Store"
|
|
580
|
+
];
|
|
581
|
+
/** 健康检查间隔 (ms) - 3秒 */
|
|
582
|
+
const HEALTH_CHECK_INTERVAL_MS = 3e3;
|
|
583
|
+
/**
|
|
584
|
+
* 项目监听器
|
|
585
|
+
*
|
|
586
|
+
* 使用 @parcel/watcher 监听项目根目录,
|
|
587
|
+
* 然后通过路径前缀匹配分发事件给订阅者。
|
|
588
|
+
*
|
|
589
|
+
* 特性:
|
|
590
|
+
* - 单个 watcher 监听整个项目
|
|
591
|
+
* - 自动处理新创建的目录
|
|
592
|
+
* - 内置防抖机制
|
|
593
|
+
* - 高性能原生实现
|
|
594
|
+
*/
|
|
595
|
+
var ProjectWatcher = class {
|
|
596
|
+
projectDir;
|
|
597
|
+
subscription = null;
|
|
598
|
+
pathSubscriptions = /* @__PURE__ */ new Map();
|
|
599
|
+
pendingEvents = [];
|
|
600
|
+
debounceTimer = null;
|
|
601
|
+
debounceMs;
|
|
602
|
+
ignore;
|
|
603
|
+
initialized = false;
|
|
604
|
+
initPromise = null;
|
|
605
|
+
healthCheckTimer = null;
|
|
606
|
+
lastEventTime = 0;
|
|
607
|
+
healthCheckPending = false;
|
|
608
|
+
enableHealthCheck;
|
|
609
|
+
reinitializeTimer = null;
|
|
610
|
+
reinitializePending = false;
|
|
611
|
+
constructor(projectDir, options = {}) {
|
|
612
|
+
this.projectDir = getRealPath$1(projectDir);
|
|
613
|
+
this.debounceMs = options.debounceMs ?? DEBOUNCE_MS$1;
|
|
614
|
+
this.ignore = options.ignore ?? DEFAULT_IGNORE;
|
|
615
|
+
this.enableHealthCheck = options.enableHealthCheck ?? true;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* 初始化 watcher
|
|
619
|
+
* 懒加载,首次订阅时自动调用
|
|
620
|
+
*/
|
|
621
|
+
async init() {
|
|
622
|
+
if (this.initialized) return;
|
|
623
|
+
if (this.initPromise) return this.initPromise;
|
|
624
|
+
this.initPromise = this.doInit();
|
|
625
|
+
await this.initPromise;
|
|
626
|
+
}
|
|
627
|
+
async doInit() {
|
|
628
|
+
this.subscription = await (await import("@parcel/watcher")).subscribe(this.projectDir, (err, events) => {
|
|
629
|
+
if (err) {
|
|
630
|
+
this.handleWatcherError(err);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
this.handleEvents(events);
|
|
634
|
+
}, { ignore: this.ignore });
|
|
635
|
+
this.initialized = true;
|
|
636
|
+
this.lastEventTime = Date.now();
|
|
637
|
+
if (this.enableHealthCheck) this.startHealthCheck();
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* 处理 watcher 错误
|
|
641
|
+
* 对于 FSEvents dropped 错误,触发延迟重建
|
|
642
|
+
*/
|
|
643
|
+
handleWatcherError(err) {
|
|
644
|
+
if ((err.message || String(err)).includes("Events were dropped")) {
|
|
645
|
+
if (!this.reinitializePending) {
|
|
646
|
+
console.warn("[ProjectWatcher] FSEvents dropped events, scheduling reinitialize...");
|
|
647
|
+
this.scheduleReinitialize();
|
|
648
|
+
}
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
console.error("[ProjectWatcher] Error:", err);
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* 延迟重建 watcher(防抖,避免频繁重建)
|
|
655
|
+
*/
|
|
656
|
+
scheduleReinitialize() {
|
|
657
|
+
if (this.reinitializePending) return;
|
|
658
|
+
this.reinitializePending = true;
|
|
659
|
+
if (this.reinitializeTimer) clearTimeout(this.reinitializeTimer);
|
|
660
|
+
this.reinitializeTimer = setTimeout(() => {
|
|
661
|
+
this.reinitializeTimer = null;
|
|
662
|
+
this.reinitializePending = false;
|
|
663
|
+
console.log("[ProjectWatcher] Reinitializing due to FSEvents error...");
|
|
664
|
+
this.reinitialize();
|
|
665
|
+
}, 1e3);
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* 处理原始事件
|
|
669
|
+
*/
|
|
670
|
+
handleEvents(events) {
|
|
671
|
+
this.lastEventTime = Date.now();
|
|
672
|
+
this.healthCheckPending = false;
|
|
673
|
+
const watchEvents = events.map((e) => ({
|
|
674
|
+
type: e.type,
|
|
675
|
+
path: e.path
|
|
676
|
+
}));
|
|
677
|
+
this.pendingEvents.push(...watchEvents);
|
|
678
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
679
|
+
this.debounceTimer = setTimeout(() => {
|
|
680
|
+
this.flushEvents();
|
|
681
|
+
}, this.debounceMs);
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* 分发事件给订阅者
|
|
685
|
+
*/
|
|
686
|
+
flushEvents() {
|
|
687
|
+
const events = this.pendingEvents;
|
|
688
|
+
this.pendingEvents = [];
|
|
689
|
+
this.debounceTimer = null;
|
|
690
|
+
if (events.length === 0) return;
|
|
691
|
+
for (const sub of this.pathSubscriptions.values()) {
|
|
692
|
+
const matchedEvents = events.filter((e) => this.matchPath(e, sub));
|
|
693
|
+
if (matchedEvents.length > 0) try {
|
|
694
|
+
sub.callback(matchedEvents);
|
|
695
|
+
} catch (err) {
|
|
696
|
+
console.error(`[ProjectWatcher] Callback error for ${sub.path}:`, err);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* 检查事件是否匹配订阅
|
|
702
|
+
*/
|
|
703
|
+
matchPath(event, sub) {
|
|
704
|
+
const eventPath = event.path;
|
|
705
|
+
if (sub.watchChildren) return eventPath.startsWith(sub.path + "/") || eventPath === sub.path;
|
|
706
|
+
else {
|
|
707
|
+
const eventDir = dirname(eventPath);
|
|
708
|
+
return eventPath === sub.path || eventDir === sub.path;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* 同步订阅路径变更(watcher 必须已初始化)
|
|
713
|
+
*
|
|
714
|
+
* 这是同步版本,用于在 watcher 已初始化后快速注册订阅。
|
|
715
|
+
* 如果 watcher 未初始化,抛出错误。
|
|
716
|
+
*
|
|
717
|
+
* @param path 要监听的路径
|
|
718
|
+
* @param callback 变更回调
|
|
719
|
+
* @param options 订阅选项
|
|
720
|
+
* @returns 取消订阅函数
|
|
721
|
+
*/
|
|
722
|
+
subscribeSync(path, callback, options = {}) {
|
|
723
|
+
if (!this.initialized) throw new Error("ProjectWatcher not initialized. Call init() first.");
|
|
724
|
+
const normalizedPath = getRealPath$1(path);
|
|
725
|
+
const id = Symbol();
|
|
726
|
+
this.pathSubscriptions.set(id, {
|
|
727
|
+
path: normalizedPath,
|
|
728
|
+
watchChildren: options.watchChildren ?? false,
|
|
729
|
+
callback
|
|
730
|
+
});
|
|
731
|
+
return () => {
|
|
732
|
+
this.pathSubscriptions.delete(id);
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* 订阅路径变更(异步版本,自动初始化)
|
|
737
|
+
*
|
|
738
|
+
* @param path 要监听的路径
|
|
739
|
+
* @param callback 变更回调
|
|
740
|
+
* @param options 订阅选项
|
|
741
|
+
* @returns 取消订阅函数
|
|
742
|
+
*/
|
|
743
|
+
async subscribe(path, callback, options = {}) {
|
|
744
|
+
await this.init();
|
|
745
|
+
return this.subscribeSync(path, callback, options);
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* 获取当前订阅数量(用于调试)
|
|
749
|
+
*/
|
|
750
|
+
get subscriptionCount() {
|
|
751
|
+
return this.pathSubscriptions.size;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* 检查是否已初始化
|
|
755
|
+
*/
|
|
756
|
+
get isInitialized() {
|
|
757
|
+
return this.initialized;
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* 启动健康检查定时器
|
|
761
|
+
*/
|
|
762
|
+
startHealthCheck() {
|
|
763
|
+
this.stopHealthCheck();
|
|
764
|
+
this.healthCheckTimer = setInterval(() => {
|
|
765
|
+
this.performHealthCheck();
|
|
766
|
+
}, HEALTH_CHECK_INTERVAL_MS);
|
|
767
|
+
this.healthCheckTimer.unref();
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* 停止健康检查定时器
|
|
771
|
+
*/
|
|
772
|
+
stopHealthCheck() {
|
|
773
|
+
if (this.healthCheckTimer) {
|
|
774
|
+
clearInterval(this.healthCheckTimer);
|
|
775
|
+
this.healthCheckTimer = null;
|
|
776
|
+
}
|
|
777
|
+
this.healthCheckPending = false;
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* 执行健康检查
|
|
781
|
+
*
|
|
782
|
+
* 工作流程:
|
|
783
|
+
* 1. 如果最近有事件,无需检查
|
|
784
|
+
* 2. 如果上次探测还在等待中,说明 watcher 可能失效,尝试重建
|
|
785
|
+
* 3. 否则,创建临时文件触发事件,等待下次检查验证
|
|
786
|
+
*/
|
|
787
|
+
async performHealthCheck() {
|
|
788
|
+
if (Date.now() - this.lastEventTime < HEALTH_CHECK_INTERVAL_MS) {
|
|
789
|
+
this.healthCheckPending = false;
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
if (this.healthCheckPending) {
|
|
793
|
+
console.warn("[ProjectWatcher] Health check failed, watcher appears stale. Reinitializing...");
|
|
794
|
+
await this.reinitialize();
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
this.healthCheckPending = true;
|
|
798
|
+
this.sendProbe();
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* 发送探测:通过 utimesSync 修改项目目录的时间戳来触发 watcher 事件
|
|
802
|
+
*/
|
|
803
|
+
sendProbe() {
|
|
804
|
+
try {
|
|
805
|
+
const now = /* @__PURE__ */ new Date();
|
|
806
|
+
utimesSync(this.projectDir, now, now);
|
|
807
|
+
} catch {}
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* 重新初始化 watcher
|
|
811
|
+
*/
|
|
812
|
+
async reinitialize() {
|
|
813
|
+
this.stopHealthCheck();
|
|
814
|
+
if (this.subscription) {
|
|
815
|
+
try {
|
|
816
|
+
await this.subscription.unsubscribe();
|
|
817
|
+
} catch {}
|
|
818
|
+
this.subscription = null;
|
|
819
|
+
}
|
|
820
|
+
this.initialized = false;
|
|
821
|
+
this.initPromise = null;
|
|
822
|
+
this.healthCheckPending = false;
|
|
823
|
+
if (!existsSync(this.projectDir)) {
|
|
824
|
+
console.warn("[ProjectWatcher] Project directory does not exist, waiting for it to be created...");
|
|
825
|
+
this.waitForProjectDir();
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
try {
|
|
829
|
+
await this.init();
|
|
830
|
+
console.log("[ProjectWatcher] Reinitialized successfully");
|
|
831
|
+
} catch (err) {
|
|
832
|
+
console.error("[ProjectWatcher] Failed to reinitialize:", err);
|
|
833
|
+
setTimeout(() => this.reinitialize(), HEALTH_CHECK_INTERVAL_MS);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* 等待项目目录被创建
|
|
838
|
+
*/
|
|
839
|
+
waitForProjectDir() {
|
|
840
|
+
const checkInterval = setInterval(() => {
|
|
841
|
+
if (existsSync(this.projectDir)) {
|
|
842
|
+
clearInterval(checkInterval);
|
|
843
|
+
console.log("[ProjectWatcher] Project directory created, reinitializing...");
|
|
844
|
+
this.reinitialize();
|
|
845
|
+
}
|
|
846
|
+
}, HEALTH_CHECK_INTERVAL_MS);
|
|
847
|
+
checkInterval.unref();
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* 关闭 watcher
|
|
851
|
+
*/
|
|
852
|
+
async close() {
|
|
853
|
+
this.stopHealthCheck();
|
|
854
|
+
if (this.debounceTimer) {
|
|
855
|
+
clearTimeout(this.debounceTimer);
|
|
856
|
+
this.debounceTimer = null;
|
|
857
|
+
}
|
|
858
|
+
if (this.reinitializeTimer) {
|
|
859
|
+
clearTimeout(this.reinitializeTimer);
|
|
860
|
+
this.reinitializeTimer = null;
|
|
861
|
+
}
|
|
862
|
+
this.reinitializePending = false;
|
|
863
|
+
if (this.subscription) {
|
|
864
|
+
await this.subscription.unsubscribe();
|
|
865
|
+
this.subscription = null;
|
|
866
|
+
}
|
|
867
|
+
this.pathSubscriptions.clear();
|
|
868
|
+
this.pendingEvents = [];
|
|
869
|
+
this.initialized = false;
|
|
870
|
+
this.initPromise = null;
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
/**
|
|
874
|
+
* 全局 ProjectWatcher 实例缓存
|
|
875
|
+
* key: 项目目录路径
|
|
876
|
+
*/
|
|
877
|
+
const watcherCache = /* @__PURE__ */ new Map();
|
|
878
|
+
/**
|
|
879
|
+
* 获取或创建项目监听器
|
|
880
|
+
*/
|
|
881
|
+
function getProjectWatcher(projectDir, options) {
|
|
882
|
+
const normalizedDir = getRealPath$1(projectDir);
|
|
883
|
+
let watcher = watcherCache.get(normalizedDir);
|
|
884
|
+
if (!watcher) {
|
|
885
|
+
watcher = new ProjectWatcher(normalizedDir, options);
|
|
886
|
+
watcherCache.set(normalizedDir, watcher);
|
|
887
|
+
}
|
|
888
|
+
return watcher;
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* 关闭所有 ProjectWatcher(用于测试清理)
|
|
892
|
+
*/
|
|
893
|
+
async function closeAllProjectWatchers() {
|
|
894
|
+
const closePromises = Array.from(watcherCache.values()).map((w) => w.close());
|
|
895
|
+
await Promise.all(closePromises);
|
|
896
|
+
watcherCache.clear();
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
//#endregion
|
|
900
|
+
//#region src/reactive-fs/watcher-pool.ts
|
|
901
|
+
/**
|
|
902
|
+
* 获取路径的真实路径(解析符号链接)
|
|
903
|
+
*/
|
|
904
|
+
function getRealPath(path) {
|
|
905
|
+
try {
|
|
906
|
+
return realpathSync(resolve(path));
|
|
907
|
+
} catch {
|
|
908
|
+
return resolve(path);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* 全局 ProjectWatcher 实例
|
|
913
|
+
* 通过 initWatcherPool 初始化
|
|
914
|
+
*/
|
|
915
|
+
let globalProjectWatcher = null;
|
|
916
|
+
let globalProjectDir = null;
|
|
917
|
+
/** 默认防抖时间 (ms) */
|
|
918
|
+
const DEBOUNCE_MS = 100;
|
|
919
|
+
/** 路径订阅缓存 */
|
|
920
|
+
const subscriptionCache = /* @__PURE__ */ new Map();
|
|
921
|
+
/** 防抖定时器 */
|
|
922
|
+
const debounceTimers = /* @__PURE__ */ new Map();
|
|
923
|
+
/**
|
|
924
|
+
* 初始化 watcher pool
|
|
925
|
+
*
|
|
926
|
+
* 必须在使用 acquireWatcher 之前调用。
|
|
927
|
+
* 通常由 server 在启动时调用。
|
|
928
|
+
*
|
|
929
|
+
* @param projectDir 项目根目录
|
|
930
|
+
*/
|
|
931
|
+
async function initWatcherPool(projectDir) {
|
|
932
|
+
const normalizedDir = getRealPath(projectDir);
|
|
933
|
+
if (globalProjectWatcher && globalProjectDir === normalizedDir) return;
|
|
934
|
+
if (globalProjectWatcher) await globalProjectWatcher.close();
|
|
935
|
+
globalProjectDir = normalizedDir;
|
|
936
|
+
globalProjectWatcher = getProjectWatcher(normalizedDir);
|
|
937
|
+
await globalProjectWatcher.init();
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* 获取或创建文件/目录监听器
|
|
941
|
+
*
|
|
942
|
+
* 特性:
|
|
943
|
+
* - 使用 @parcel/watcher 监听项目根目录
|
|
944
|
+
* - 自动处理新创建的目录(解决 init 后无法监听的问题)
|
|
945
|
+
* - 同一路径共享订阅
|
|
946
|
+
* - 引用计数管理生命周期
|
|
947
|
+
* - 内置防抖机制
|
|
948
|
+
*
|
|
949
|
+
* @param path 要监听的路径
|
|
950
|
+
* @param onChange 变更回调
|
|
951
|
+
* @param options 监听选项
|
|
952
|
+
* @returns 释放函数,调用后取消订阅
|
|
953
|
+
*/
|
|
954
|
+
function acquireWatcher(path, onChange, options = {}) {
|
|
955
|
+
if (!globalProjectWatcher || !globalProjectWatcher.isInitialized) return () => {};
|
|
956
|
+
const normalizedPath = getRealPath(path);
|
|
957
|
+
const debounceMs = options.debounceMs ?? DEBOUNCE_MS;
|
|
958
|
+
const isRecursive = options.recursive ?? false;
|
|
959
|
+
const cacheKey = `${normalizedPath}:${isRecursive}`;
|
|
960
|
+
let subscription = subscriptionCache.get(cacheKey);
|
|
961
|
+
if (!subscription) {
|
|
962
|
+
const unsubscribe = globalProjectWatcher.subscribeSync(normalizedPath, () => {
|
|
963
|
+
const existingTimer = debounceTimers.get(cacheKey);
|
|
964
|
+
if (existingTimer) clearTimeout(existingTimer);
|
|
965
|
+
const timer = setTimeout(() => {
|
|
966
|
+
debounceTimers.delete(cacheKey);
|
|
967
|
+
const currentSub = subscriptionCache.get(cacheKey);
|
|
968
|
+
if (currentSub) for (const cb of currentSub.callbacks) try {
|
|
969
|
+
cb();
|
|
970
|
+
} catch (err) {
|
|
971
|
+
console.error(`[watcher-pool] Callback error for ${normalizedPath}:`, err);
|
|
972
|
+
}
|
|
973
|
+
}, debounceMs);
|
|
974
|
+
debounceTimers.set(cacheKey, timer);
|
|
975
|
+
}, { watchChildren: isRecursive });
|
|
976
|
+
subscription = {
|
|
977
|
+
path: normalizedPath,
|
|
978
|
+
callbacks: /* @__PURE__ */ new Set(),
|
|
979
|
+
unsubscribe,
|
|
980
|
+
onError: options.onError
|
|
981
|
+
};
|
|
982
|
+
subscriptionCache.set(cacheKey, subscription);
|
|
983
|
+
}
|
|
984
|
+
subscription.callbacks.add(onChange);
|
|
985
|
+
return () => {
|
|
986
|
+
const currentSub = subscriptionCache.get(cacheKey);
|
|
987
|
+
if (!currentSub) return;
|
|
988
|
+
currentSub.callbacks.delete(onChange);
|
|
989
|
+
if (currentSub.callbacks.size === 0) {
|
|
990
|
+
currentSub.unsubscribe();
|
|
991
|
+
subscriptionCache.delete(cacheKey);
|
|
992
|
+
const timer = debounceTimers.get(cacheKey);
|
|
993
|
+
if (timer) {
|
|
994
|
+
clearTimeout(timer);
|
|
995
|
+
debounceTimers.delete(cacheKey);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* 获取当前活跃的监听器数量(用于调试)
|
|
1002
|
+
*/
|
|
1003
|
+
function getActiveWatcherCount() {
|
|
1004
|
+
return subscriptionCache.size;
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* 关闭所有监听器(用于测试清理)
|
|
1008
|
+
*/
|
|
1009
|
+
async function closeAllWatchers() {
|
|
1010
|
+
for (const [key, sub] of subscriptionCache) {
|
|
1011
|
+
sub.unsubscribe();
|
|
1012
|
+
const timer = debounceTimers.get(key);
|
|
1013
|
+
if (timer) clearTimeout(timer);
|
|
1014
|
+
}
|
|
1015
|
+
subscriptionCache.clear();
|
|
1016
|
+
debounceTimers.clear();
|
|
1017
|
+
if (globalProjectWatcher) {
|
|
1018
|
+
await globalProjectWatcher.close();
|
|
1019
|
+
globalProjectWatcher = null;
|
|
1020
|
+
globalProjectDir = null;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* 检查 watcher pool 是否已初始化
|
|
1025
|
+
*/
|
|
1026
|
+
function isWatcherPoolInitialized() {
|
|
1027
|
+
return globalProjectWatcher !== null && globalProjectWatcher.isInitialized;
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* 获取当前监听的项目目录
|
|
1031
|
+
*/
|
|
1032
|
+
function getWatchedProjectDir() {
|
|
1033
|
+
return globalProjectDir;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
//#endregion
|
|
1037
|
+
//#region src/reactive-fs/reactive-fs.ts
|
|
1038
|
+
/** 状态缓存:路径 -> ReactiveState */
|
|
1039
|
+
const stateCache$1 = /* @__PURE__ */ new Map();
|
|
1040
|
+
/** 监听器释放函数缓存 */
|
|
1041
|
+
const releaseCache$1 = /* @__PURE__ */ new Map();
|
|
1042
|
+
/**
|
|
1043
|
+
* 响应式读取文件内容
|
|
1044
|
+
*
|
|
1045
|
+
* 特性:
|
|
1046
|
+
* - 自动注册文件监听
|
|
1047
|
+
* - 文件变更时自动更新状态
|
|
1048
|
+
* - 在 ReactiveContext 中调用时自动追踪依赖
|
|
1049
|
+
* - 支持监听尚未创建的文件(通过 @parcel/watcher)
|
|
1050
|
+
*
|
|
1051
|
+
* @param filepath 文件路径
|
|
1052
|
+
* @returns 文件内容,文件不存在时返回 null
|
|
1053
|
+
*/
|
|
1054
|
+
async function reactiveReadFile(filepath) {
|
|
1055
|
+
const normalizedPath = resolve(filepath);
|
|
1056
|
+
const key = `file:${normalizedPath}`;
|
|
1057
|
+
const getValue = async () => {
|
|
1058
|
+
try {
|
|
1059
|
+
return await readFile$1(normalizedPath, "utf-8");
|
|
1060
|
+
} catch {
|
|
1061
|
+
return null;
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
let state = stateCache$1.get(key);
|
|
1065
|
+
if (!state) {
|
|
1066
|
+
state = new ReactiveState(await getValue());
|
|
1067
|
+
stateCache$1.set(key, state);
|
|
1068
|
+
const release = acquireWatcher(dirname(normalizedPath), async () => {
|
|
1069
|
+
const newValue = await getValue();
|
|
1070
|
+
state.set(newValue);
|
|
1071
|
+
}, { onError: () => {
|
|
1072
|
+
stateCache$1.delete(key);
|
|
1073
|
+
releaseCache$1.delete(key);
|
|
1074
|
+
} });
|
|
1075
|
+
releaseCache$1.set(key, release);
|
|
1076
|
+
}
|
|
1077
|
+
return state.get();
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* 响应式读取目录内容
|
|
1081
|
+
*
|
|
1082
|
+
* 特性:
|
|
1083
|
+
* - 自动注册目录监听
|
|
1084
|
+
* - 目录变更时自动更新状态
|
|
1085
|
+
* - 在 ReactiveContext 中调用时自动追踪依赖
|
|
1086
|
+
* - 支持监听尚未创建的目录(通过 @parcel/watcher)
|
|
1087
|
+
*
|
|
1088
|
+
* @param dirpath 目录路径
|
|
1089
|
+
* @param options 选项
|
|
1090
|
+
* @returns 目录项名称数组
|
|
1091
|
+
*/
|
|
1092
|
+
async function reactiveReadDir(dirpath, options = {}) {
|
|
1093
|
+
const normalizedPath = resolve(dirpath);
|
|
1094
|
+
const key = `dir:${normalizedPath}:${JSON.stringify(options)}`;
|
|
1095
|
+
const getValue = async () => {
|
|
1096
|
+
try {
|
|
1097
|
+
return (await readdir(normalizedPath, { withFileTypes: true })).filter((entry) => {
|
|
1098
|
+
if (!options.includeHidden && entry.name.startsWith(".")) return false;
|
|
1099
|
+
if (options.exclude?.includes(entry.name)) return false;
|
|
1100
|
+
if (options.directoriesOnly && !entry.isDirectory()) return false;
|
|
1101
|
+
if (options.filesOnly && !entry.isFile()) return false;
|
|
1102
|
+
return true;
|
|
1103
|
+
}).map((entry) => entry.name);
|
|
1104
|
+
} catch {
|
|
1105
|
+
return [];
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
1108
|
+
let state = stateCache$1.get(key);
|
|
1109
|
+
if (!state) {
|
|
1110
|
+
state = new ReactiveState(await getValue(), { equals: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]) });
|
|
1111
|
+
stateCache$1.set(key, state);
|
|
1112
|
+
const release = acquireWatcher(normalizedPath, async () => {
|
|
1113
|
+
const newValue = await getValue();
|
|
1114
|
+
state.set(newValue);
|
|
1115
|
+
}, {
|
|
1116
|
+
recursive: true,
|
|
1117
|
+
onError: () => {
|
|
1118
|
+
stateCache$1.delete(key);
|
|
1119
|
+
releaseCache$1.delete(key);
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
releaseCache$1.set(key, release);
|
|
1123
|
+
}
|
|
1124
|
+
return state.get();
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* 响应式检查路径是否存在
|
|
1128
|
+
*
|
|
1129
|
+
* @param path 路径
|
|
1130
|
+
* @returns 是否存在
|
|
1131
|
+
*/
|
|
1132
|
+
async function reactiveExists(path) {
|
|
1133
|
+
const normalizedPath = resolve(path);
|
|
1134
|
+
const key = `exists:${normalizedPath}`;
|
|
1135
|
+
const getValue = async () => {
|
|
1136
|
+
try {
|
|
1137
|
+
await stat(normalizedPath);
|
|
1138
|
+
return true;
|
|
1139
|
+
} catch {
|
|
1140
|
+
return false;
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
let state = stateCache$1.get(key);
|
|
1144
|
+
if (!state) {
|
|
1145
|
+
state = new ReactiveState(await getValue());
|
|
1146
|
+
stateCache$1.set(key, state);
|
|
1147
|
+
const release = acquireWatcher(dirname(normalizedPath), async () => {
|
|
1148
|
+
const newValue = await getValue();
|
|
1149
|
+
state.set(newValue);
|
|
1150
|
+
}, { onError: () => {
|
|
1151
|
+
stateCache$1.delete(key);
|
|
1152
|
+
releaseCache$1.delete(key);
|
|
1153
|
+
} });
|
|
1154
|
+
releaseCache$1.set(key, release);
|
|
1155
|
+
}
|
|
1156
|
+
return state.get();
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* 响应式获取文件/目录的 stat 信息
|
|
1160
|
+
*
|
|
1161
|
+
* @param path 路径
|
|
1162
|
+
* @returns stat 信息,不存在时返回 null
|
|
1163
|
+
*/
|
|
1164
|
+
async function reactiveStat(path) {
|
|
1165
|
+
const normalizedPath = resolve(path);
|
|
1166
|
+
const key = `stat:${normalizedPath}`;
|
|
1167
|
+
const getValue = async () => {
|
|
1168
|
+
try {
|
|
1169
|
+
const s = await stat(normalizedPath);
|
|
1170
|
+
return {
|
|
1171
|
+
isDirectory: s.isDirectory(),
|
|
1172
|
+
isFile: s.isFile(),
|
|
1173
|
+
mtime: s.mtime.getTime(),
|
|
1174
|
+
birthtime: s.birthtime.getTime()
|
|
1175
|
+
};
|
|
1176
|
+
} catch {
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1179
|
+
};
|
|
1180
|
+
let state = stateCache$1.get(key);
|
|
1181
|
+
if (!state) {
|
|
1182
|
+
state = new ReactiveState(await getValue(), { equals: (a, b) => {
|
|
1183
|
+
if (a === null && b === null) return true;
|
|
1184
|
+
if (a === null || b === null) return false;
|
|
1185
|
+
return a.isDirectory === b.isDirectory && a.isFile === b.isFile && a.mtime === b.mtime && a.birthtime === b.birthtime;
|
|
1186
|
+
} });
|
|
1187
|
+
stateCache$1.set(key, state);
|
|
1188
|
+
const release = acquireWatcher(dirname(normalizedPath), async () => {
|
|
1189
|
+
const newValue = await getValue();
|
|
1190
|
+
state.set(newValue);
|
|
1191
|
+
}, { onError: () => {
|
|
1192
|
+
stateCache$1.delete(key);
|
|
1193
|
+
releaseCache$1.delete(key);
|
|
1194
|
+
} });
|
|
1195
|
+
releaseCache$1.set(key, release);
|
|
1196
|
+
}
|
|
1197
|
+
return state.get();
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* 清除指定路径的缓存(用于测试)
|
|
1201
|
+
*/
|
|
1202
|
+
function clearCache(path) {
|
|
1203
|
+
if (path) {
|
|
1204
|
+
const normalizedPath = resolve(path);
|
|
1205
|
+
for (const [key, release] of releaseCache$1) if (key.includes(normalizedPath)) {
|
|
1206
|
+
release();
|
|
1207
|
+
releaseCache$1.delete(key);
|
|
1208
|
+
stateCache$1.delete(key);
|
|
1209
|
+
}
|
|
1210
|
+
} else {
|
|
1211
|
+
for (const release of releaseCache$1.values()) release();
|
|
1212
|
+
releaseCache$1.clear();
|
|
1213
|
+
stateCache$1.clear();
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* 获取缓存大小(用于调试)
|
|
1218
|
+
*/
|
|
1219
|
+
function getCacheSize() {
|
|
1220
|
+
return stateCache$1.size;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
//#endregion
|
|
1224
|
+
//#region src/adapter.ts
|
|
1225
|
+
/**
|
|
1226
|
+
* OpenSpec filesystem adapter
|
|
1227
|
+
* Handles reading, writing, and managing OpenSpec files
|
|
1228
|
+
*/
|
|
1229
|
+
var OpenSpecAdapter = class {
|
|
1230
|
+
parser = new MarkdownParser();
|
|
1231
|
+
validator = new Validator();
|
|
1232
|
+
constructor(projectDir) {
|
|
1233
|
+
this.projectDir = projectDir;
|
|
1234
|
+
}
|
|
1235
|
+
get openspecDir() {
|
|
1236
|
+
return join(this.projectDir, "openspec");
|
|
1237
|
+
}
|
|
1238
|
+
get specsDir() {
|
|
1239
|
+
return join(this.openspecDir, "specs");
|
|
1240
|
+
}
|
|
1241
|
+
get changesDir() {
|
|
1242
|
+
return join(this.openspecDir, "changes");
|
|
1243
|
+
}
|
|
1244
|
+
get archiveDir() {
|
|
1245
|
+
return join(this.changesDir, "archive");
|
|
1246
|
+
}
|
|
1247
|
+
async isInitialized() {
|
|
1248
|
+
return (await reactiveStat(this.openspecDir))?.isDirectory ?? false;
|
|
1249
|
+
}
|
|
1250
|
+
/** File time info derived from filesystem (reactive) */
|
|
1251
|
+
async getFileTimeInfo(filePath) {
|
|
1252
|
+
const statInfo = await reactiveStat(filePath);
|
|
1253
|
+
if (!statInfo) return null;
|
|
1254
|
+
return {
|
|
1255
|
+
createdAt: statInfo.birthtime,
|
|
1256
|
+
updatedAt: statInfo.mtime
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
async listSpecs() {
|
|
1260
|
+
return reactiveReadDir(this.specsDir, { directoriesOnly: true });
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* List specs with metadata (id, name, and time info)
|
|
1264
|
+
* Only returns specs that have valid spec.md
|
|
1265
|
+
* Sorted by updatedAt descending (most recent first)
|
|
1266
|
+
*/
|
|
1267
|
+
async listSpecsWithMeta() {
|
|
1268
|
+
const ids = await this.listSpecs();
|
|
1269
|
+
return (await Promise.all(ids.map(async (id) => {
|
|
1270
|
+
const spec = await this.readSpec(id);
|
|
1271
|
+
if (!spec) return null;
|
|
1272
|
+
const specPath = join(this.specsDir, id, "spec.md");
|
|
1273
|
+
const timeInfo = await this.getFileTimeInfo(specPath);
|
|
1274
|
+
return {
|
|
1275
|
+
id,
|
|
1276
|
+
name: spec.name,
|
|
1277
|
+
createdAt: timeInfo?.createdAt ?? 0,
|
|
1278
|
+
updatedAt: timeInfo?.updatedAt ?? 0
|
|
1279
|
+
};
|
|
1280
|
+
}))).filter((r) => r !== null).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
1281
|
+
}
|
|
1282
|
+
async listChanges() {
|
|
1283
|
+
return reactiveReadDir(this.changesDir, {
|
|
1284
|
+
directoriesOnly: true,
|
|
1285
|
+
exclude: ["archive"]
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* List changes with metadata (id, name, progress, and time info)
|
|
1290
|
+
* Only returns changes that have valid proposal.md
|
|
1291
|
+
* Sorted by updatedAt descending (most recent first)
|
|
1292
|
+
*/
|
|
1293
|
+
async listChangesWithMeta() {
|
|
1294
|
+
const ids = await this.listChanges();
|
|
1295
|
+
return (await Promise.all(ids.map(async (id) => {
|
|
1296
|
+
const change = await this.readChange(id);
|
|
1297
|
+
if (!change) return null;
|
|
1298
|
+
const proposalPath = join(this.changesDir, id, "proposal.md");
|
|
1299
|
+
const timeInfo = await this.getFileTimeInfo(proposalPath);
|
|
1300
|
+
return {
|
|
1301
|
+
id,
|
|
1302
|
+
name: change.name,
|
|
1303
|
+
progress: change.progress,
|
|
1304
|
+
createdAt: timeInfo?.createdAt ?? 0,
|
|
1305
|
+
updatedAt: timeInfo?.updatedAt ?? 0
|
|
1306
|
+
};
|
|
1307
|
+
}))).filter((r) => r !== null).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
1308
|
+
}
|
|
1309
|
+
async listArchivedChanges() {
|
|
1310
|
+
return reactiveReadDir(this.archiveDir, { directoriesOnly: true });
|
|
1311
|
+
}
|
|
1312
|
+
/**
|
|
1313
|
+
* List archived changes with metadata and time info
|
|
1314
|
+
* Only returns archives that have valid proposal.md
|
|
1315
|
+
* Sorted by updatedAt descending (most recent first)
|
|
1316
|
+
*/
|
|
1317
|
+
async listArchivedChangesWithMeta() {
|
|
1318
|
+
const ids = await this.listArchivedChanges();
|
|
1319
|
+
return (await Promise.all(ids.map(async (id) => {
|
|
1320
|
+
const change = await this.readArchivedChange(id);
|
|
1321
|
+
if (!change) return null;
|
|
1322
|
+
const proposalPath = join(this.archiveDir, id, "proposal.md");
|
|
1323
|
+
const timeInfo = await this.getFileTimeInfo(proposalPath);
|
|
1324
|
+
return {
|
|
1325
|
+
id,
|
|
1326
|
+
name: change.name,
|
|
1327
|
+
createdAt: timeInfo?.createdAt ?? 0,
|
|
1328
|
+
updatedAt: timeInfo?.updatedAt ?? 0
|
|
1329
|
+
};
|
|
1330
|
+
}))).filter((r) => r !== null).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Read project.md content (reactive)
|
|
1334
|
+
*/
|
|
1335
|
+
async readProjectMd() {
|
|
1336
|
+
return reactiveReadFile(join(this.openspecDir, "project.md"));
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Read AGENTS.md content (reactive)
|
|
1340
|
+
*/
|
|
1341
|
+
async readAgentsMd() {
|
|
1342
|
+
return reactiveReadFile(join(this.openspecDir, "AGENTS.md"));
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Write project.md content
|
|
1346
|
+
*/
|
|
1347
|
+
async writeProjectMd(content) {
|
|
1348
|
+
await writeFile(join(this.openspecDir, "project.md"), content, "utf-8");
|
|
1349
|
+
}
|
|
1350
|
+
/**
|
|
1351
|
+
* Write AGENTS.md content
|
|
1352
|
+
*/
|
|
1353
|
+
async writeAgentsMd(content) {
|
|
1354
|
+
await writeFile(join(this.openspecDir, "AGENTS.md"), content, "utf-8");
|
|
1355
|
+
}
|
|
1356
|
+
async readSpec(specId) {
|
|
1357
|
+
try {
|
|
1358
|
+
const content = await this.readSpecRaw(specId);
|
|
1359
|
+
if (!content) return null;
|
|
1360
|
+
return this.parser.parseSpec(specId, content);
|
|
1361
|
+
} catch {
|
|
1362
|
+
return null;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
async readSpecRaw(specId) {
|
|
1366
|
+
return reactiveReadFile(join(this.specsDir, specId, "spec.md"));
|
|
1367
|
+
}
|
|
1368
|
+
async readChange(changeId) {
|
|
1369
|
+
try {
|
|
1370
|
+
const raw = await this.readChangeRaw(changeId);
|
|
1371
|
+
if (!raw) return null;
|
|
1372
|
+
return this.parser.parseChange(changeId, raw.proposal, raw.tasks, {
|
|
1373
|
+
design: raw.design,
|
|
1374
|
+
deltaSpecs: raw.deltaSpecs
|
|
1375
|
+
});
|
|
1376
|
+
} catch {
|
|
1377
|
+
return null;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
async readChangeFiles(changeId) {
|
|
1381
|
+
const changeRoot = join(this.changesDir, changeId);
|
|
1382
|
+
return this.readFilesUnderRoot(changeRoot);
|
|
1383
|
+
}
|
|
1384
|
+
async readArchivedChangeFiles(changeId) {
|
|
1385
|
+
const archiveRoot = join(this.archiveDir, changeId);
|
|
1386
|
+
return this.readFilesUnderRoot(archiveRoot);
|
|
1387
|
+
}
|
|
1388
|
+
async readFilesUnderRoot(root) {
|
|
1389
|
+
if (!(await reactiveStat(root))?.isDirectory) return [];
|
|
1390
|
+
return (await this.collectChangeFiles(root, root)).sort((a, b) => {
|
|
1391
|
+
if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
|
|
1392
|
+
return a.path.localeCompare(b.path);
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
async collectChangeFiles(root, dir) {
|
|
1396
|
+
const names = await reactiveReadDir(dir, { includeHidden: false });
|
|
1397
|
+
const files = [];
|
|
1398
|
+
for (const name of names) {
|
|
1399
|
+
const fullPath = join(dir, name);
|
|
1400
|
+
const statInfo = await reactiveStat(fullPath);
|
|
1401
|
+
if (!statInfo) continue;
|
|
1402
|
+
const relativePath = fullPath.slice(root.length + 1);
|
|
1403
|
+
if (statInfo.isDirectory) {
|
|
1404
|
+
files.push({
|
|
1405
|
+
path: relativePath,
|
|
1406
|
+
type: "directory"
|
|
1407
|
+
});
|
|
1408
|
+
files.push(...await this.collectChangeFiles(root, fullPath));
|
|
1409
|
+
} else {
|
|
1410
|
+
const content = await reactiveReadFile(fullPath);
|
|
1411
|
+
files.push({
|
|
1412
|
+
path: relativePath,
|
|
1413
|
+
type: "file",
|
|
1414
|
+
content: content ?? void 0
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
return files;
|
|
1419
|
+
}
|
|
1420
|
+
async readChangeRaw(changeId) {
|
|
1421
|
+
const changeDir = join(this.changesDir, changeId);
|
|
1422
|
+
const proposalPath = join(changeDir, "proposal.md");
|
|
1423
|
+
const tasksPath = join(changeDir, "tasks.md");
|
|
1424
|
+
const designPath = join(changeDir, "design.md");
|
|
1425
|
+
const specsDir = join(changeDir, "specs");
|
|
1426
|
+
const [proposal, tasks, design] = await Promise.all([
|
|
1427
|
+
reactiveReadFile(proposalPath),
|
|
1428
|
+
reactiveReadFile(tasksPath),
|
|
1429
|
+
reactiveReadFile(designPath)
|
|
1430
|
+
]);
|
|
1431
|
+
if (!proposal) return null;
|
|
1432
|
+
const deltaSpecs = await this.readDeltaSpecs(specsDir);
|
|
1433
|
+
return {
|
|
1434
|
+
proposal,
|
|
1435
|
+
tasks: tasks ?? "",
|
|
1436
|
+
design: design ?? void 0,
|
|
1437
|
+
deltaSpecs
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
/** Read delta specs from a specs directory */
|
|
1441
|
+
async readDeltaSpecs(specsDir) {
|
|
1442
|
+
const specIds = await reactiveReadDir(specsDir, { directoriesOnly: true });
|
|
1443
|
+
const deltaSpecs = [];
|
|
1444
|
+
for (const specId of specIds) {
|
|
1445
|
+
const content = await reactiveReadFile(join(specsDir, specId, "spec.md"));
|
|
1446
|
+
if (content) deltaSpecs.push({
|
|
1447
|
+
specId,
|
|
1448
|
+
content
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
return deltaSpecs;
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Read an archived change
|
|
1455
|
+
*/
|
|
1456
|
+
async readArchivedChange(changeId) {
|
|
1457
|
+
try {
|
|
1458
|
+
const raw = await this.readArchivedChangeRaw(changeId);
|
|
1459
|
+
if (!raw) return null;
|
|
1460
|
+
return this.parser.parseChange(changeId, raw.proposal, raw.tasks, {
|
|
1461
|
+
design: raw.design,
|
|
1462
|
+
deltaSpecs: raw.deltaSpecs
|
|
1463
|
+
});
|
|
1464
|
+
} catch {
|
|
1465
|
+
return null;
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* Read raw archived change files (reactive)
|
|
1470
|
+
*/
|
|
1471
|
+
async readArchivedChangeRaw(changeId) {
|
|
1472
|
+
const archiveChangeDir = join(this.archiveDir, changeId);
|
|
1473
|
+
const proposalPath = join(archiveChangeDir, "proposal.md");
|
|
1474
|
+
const tasksPath = join(archiveChangeDir, "tasks.md");
|
|
1475
|
+
const designPath = join(archiveChangeDir, "design.md");
|
|
1476
|
+
const specsDir = join(archiveChangeDir, "specs");
|
|
1477
|
+
const [proposal, tasks, design] = await Promise.all([
|
|
1478
|
+
reactiveReadFile(proposalPath),
|
|
1479
|
+
reactiveReadFile(tasksPath),
|
|
1480
|
+
reactiveReadFile(designPath)
|
|
1481
|
+
]);
|
|
1482
|
+
if (!proposal) return null;
|
|
1483
|
+
const deltaSpecs = await this.readDeltaSpecs(specsDir);
|
|
1484
|
+
return {
|
|
1485
|
+
proposal,
|
|
1486
|
+
tasks: tasks ?? "",
|
|
1487
|
+
design: design ?? void 0,
|
|
1488
|
+
deltaSpecs
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
async writeSpec(specId, content) {
|
|
1492
|
+
const specDir = join(this.specsDir, specId);
|
|
1493
|
+
await mkdir(specDir, { recursive: true });
|
|
1494
|
+
await writeFile(join(specDir, "spec.md"), content, "utf-8");
|
|
1495
|
+
}
|
|
1496
|
+
async writeChange(changeId, proposal, tasks) {
|
|
1497
|
+
const changeDir = join(this.changesDir, changeId);
|
|
1498
|
+
await mkdir(changeDir, { recursive: true });
|
|
1499
|
+
await writeFile(join(changeDir, "proposal.md"), proposal, "utf-8");
|
|
1500
|
+
if (tasks !== void 0) await writeFile(join(changeDir, "tasks.md"), tasks, "utf-8");
|
|
1501
|
+
}
|
|
1502
|
+
async archiveChange(changeId) {
|
|
1503
|
+
try {
|
|
1504
|
+
const changeDir = join(this.changesDir, changeId);
|
|
1505
|
+
const archivePath = join(this.archiveDir, changeId);
|
|
1506
|
+
await mkdir(this.archiveDir, { recursive: true });
|
|
1507
|
+
await rename(changeDir, archivePath);
|
|
1508
|
+
return true;
|
|
1509
|
+
} catch {
|
|
1510
|
+
return false;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
async init() {
|
|
1514
|
+
await mkdir(this.specsDir, { recursive: true });
|
|
1515
|
+
await mkdir(this.changesDir, { recursive: true });
|
|
1516
|
+
await mkdir(this.archiveDir, { recursive: true });
|
|
1517
|
+
await writeFile(join(this.openspecDir, "project.md"), `# Project Specification
|
|
1518
|
+
|
|
1519
|
+
## Overview
|
|
1520
|
+
This project uses OpenSpec for spec-driven development.
|
|
1521
|
+
|
|
1522
|
+
## Structure
|
|
1523
|
+
- \`specs/\` - Source of truth specifications
|
|
1524
|
+
- \`changes/\` - Active change proposals
|
|
1525
|
+
- \`changes/archive/\` - Completed changes
|
|
1526
|
+
`, "utf-8");
|
|
1527
|
+
await writeFile(join(this.openspecDir, "AGENTS.md"), `# AI Agent Instructions
|
|
1528
|
+
|
|
1529
|
+
This project uses OpenSpec for spec-driven development.
|
|
1530
|
+
|
|
1531
|
+
## Available Commands
|
|
1532
|
+
- \`openspec list\` - List changes or specs
|
|
1533
|
+
- \`openspec view\` - Dashboard view
|
|
1534
|
+
- \`openspec show <name>\` - Show change or spec details
|
|
1535
|
+
- \`openspec validate <name>\` - Validate change or spec
|
|
1536
|
+
- \`openspec archive <change>\` - Archive completed change
|
|
1537
|
+
|
|
1538
|
+
## Workflow
|
|
1539
|
+
1. Create a change proposal in \`changes/<change-id>/proposal.md\`
|
|
1540
|
+
2. Define delta specs in \`changes/<change-id>/specs/\`
|
|
1541
|
+
3. Track tasks in \`changes/<change-id>/tasks.md\`
|
|
1542
|
+
4. Implement and mark tasks complete
|
|
1543
|
+
5. Archive when done: \`openspec archive <change-id>\`
|
|
1544
|
+
`, "utf-8");
|
|
1545
|
+
}
|
|
1546
|
+
/**
|
|
1547
|
+
* Toggle a task's completion status in tasks.md
|
|
1548
|
+
* @param changeId - The change ID
|
|
1549
|
+
* @param taskIndex - 1-based task index
|
|
1550
|
+
* @param completed - New completion status
|
|
1551
|
+
*/
|
|
1552
|
+
async toggleTask(changeId, taskIndex, completed) {
|
|
1553
|
+
try {
|
|
1554
|
+
const tasksPath = join(this.changesDir, changeId, "tasks.md");
|
|
1555
|
+
const lines = (await readFile(tasksPath, "utf-8")).split("\n");
|
|
1556
|
+
let currentTaskIndex = 0;
|
|
1557
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1558
|
+
const taskMatch = lines[i].match(/^([-*]\s+)\[([ xX])\](\s+.*)$/);
|
|
1559
|
+
if (taskMatch) {
|
|
1560
|
+
currentTaskIndex++;
|
|
1561
|
+
if (currentTaskIndex === taskIndex) {
|
|
1562
|
+
const prefix = taskMatch[1];
|
|
1563
|
+
const suffix = taskMatch[3];
|
|
1564
|
+
lines[i] = `${prefix}${completed ? "[x]" : "[ ]"}${suffix}`;
|
|
1565
|
+
break;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
if (currentTaskIndex < taskIndex) return false;
|
|
1570
|
+
await writeFile(tasksPath, lines.join("\n"), "utf-8");
|
|
1571
|
+
return true;
|
|
1572
|
+
} catch {
|
|
1573
|
+
return false;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
async validateSpec(specId) {
|
|
1577
|
+
const spec = await this.readSpec(specId);
|
|
1578
|
+
if (!spec) return {
|
|
1579
|
+
valid: false,
|
|
1580
|
+
issues: [{
|
|
1581
|
+
severity: "ERROR",
|
|
1582
|
+
message: `Spec '${specId}' not found`
|
|
1583
|
+
}]
|
|
1584
|
+
};
|
|
1585
|
+
return this.validator.validateSpec(spec);
|
|
1586
|
+
}
|
|
1587
|
+
async validateChange(changeId) {
|
|
1588
|
+
const change = await this.readChange(changeId);
|
|
1589
|
+
if (!change) return {
|
|
1590
|
+
valid: false,
|
|
1591
|
+
issues: [{
|
|
1592
|
+
severity: "ERROR",
|
|
1593
|
+
message: `Change '${changeId}' not found`
|
|
1594
|
+
}]
|
|
1595
|
+
};
|
|
1596
|
+
return this.validator.validateChange(change);
|
|
1597
|
+
}
|
|
1598
|
+
async getDashboardData() {
|
|
1599
|
+
const [specIds, changeIds, archivedIds] = await Promise.all([
|
|
1600
|
+
this.listSpecs(),
|
|
1601
|
+
this.listChanges(),
|
|
1602
|
+
this.listArchivedChanges()
|
|
1603
|
+
]);
|
|
1604
|
+
const specs = await Promise.all(specIds.map((id) => this.readSpec(id)));
|
|
1605
|
+
const changes = await Promise.all(changeIds.map((id) => this.readChange(id)));
|
|
1606
|
+
const validSpecs = specs.filter((s) => s !== null);
|
|
1607
|
+
const validChanges = changes.filter((c) => c !== null);
|
|
1608
|
+
const totalRequirements = validSpecs.reduce((sum, s) => sum + s.requirements.length, 0);
|
|
1609
|
+
const totalTasks = validChanges.reduce((sum, c) => sum + c.progress.total, 0);
|
|
1610
|
+
const completedTasks = validChanges.reduce((sum, c) => sum + c.progress.completed, 0);
|
|
1611
|
+
return {
|
|
1612
|
+
specs: validSpecs,
|
|
1613
|
+
changes: validChanges,
|
|
1614
|
+
archivedCount: archivedIds.length,
|
|
1615
|
+
summary: {
|
|
1616
|
+
specCount: validSpecs.length,
|
|
1617
|
+
requirementCount: totalRequirements,
|
|
1618
|
+
activeChangeCount: validChanges.length,
|
|
1619
|
+
archivedChangeCount: archivedIds.length,
|
|
1620
|
+
totalTasks,
|
|
1621
|
+
completedTasks,
|
|
1622
|
+
progressPercent: totalTasks > 0 ? Math.round(completedTasks / totalTasks * 100) : 0
|
|
1623
|
+
}
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
|
|
1628
|
+
//#endregion
|
|
1629
|
+
//#region src/schemas.ts
|
|
1630
|
+
/**
|
|
1631
|
+
* Zod schemas and TypeScript types for OpenSpec documents.
|
|
1632
|
+
*
|
|
1633
|
+
* OpenSpec uses a structured format for specifications and change proposals:
|
|
1634
|
+
* - Spec: A specification document with requirements and scenarios
|
|
1635
|
+
* - Change: A change proposal with deltas and tasks
|
|
1636
|
+
* - Task: A trackable work item within a change
|
|
1637
|
+
*
|
|
1638
|
+
* @module schemas
|
|
1639
|
+
*/
|
|
1640
|
+
/**
|
|
1641
|
+
* File metadata for a change directory entry.
|
|
1642
|
+
*/
|
|
1643
|
+
const ChangeFileSchema = z.object({
|
|
1644
|
+
path: z.string(),
|
|
1645
|
+
type: z.enum(["file", "directory"]),
|
|
1646
|
+
content: z.string().optional(),
|
|
1647
|
+
size: z.number().optional()
|
|
1648
|
+
});
|
|
1649
|
+
/**
|
|
1650
|
+
* A requirement within a specification.
|
|
1651
|
+
* Requirements should use RFC 2119 keywords (SHALL, MUST, etc.)
|
|
1652
|
+
*/
|
|
1653
|
+
const RequirementSchema = z.object({
|
|
1654
|
+
id: z.string(),
|
|
1655
|
+
text: z.string(),
|
|
1656
|
+
scenarios: z.array(z.object({ rawText: z.string() }))
|
|
1657
|
+
});
|
|
1658
|
+
/**
|
|
1659
|
+
* A specification document.
|
|
1660
|
+
* Located at: openspec/specs/{id}/spec.md
|
|
1661
|
+
*/
|
|
1662
|
+
const SpecSchema = z.object({
|
|
1663
|
+
id: z.string(),
|
|
1664
|
+
name: z.string(),
|
|
1665
|
+
overview: z.string(),
|
|
1666
|
+
requirements: z.array(RequirementSchema),
|
|
1667
|
+
metadata: z.object({
|
|
1668
|
+
version: z.string().default("1.0.0"),
|
|
1669
|
+
format: z.literal("openspec").default("openspec"),
|
|
1670
|
+
sourcePath: z.string().optional()
|
|
1671
|
+
}).optional()
|
|
1672
|
+
});
|
|
1673
|
+
/**
|
|
1674
|
+
* A delta describes changes to a spec within a change proposal.
|
|
1675
|
+
* Deltas track which specs are affected and how.
|
|
1676
|
+
*/
|
|
1677
|
+
const DeltaOperationType = z.enum([
|
|
1678
|
+
"ADDED",
|
|
1679
|
+
"MODIFIED",
|
|
1680
|
+
"REMOVED",
|
|
1681
|
+
"RENAMED"
|
|
1682
|
+
]);
|
|
1683
|
+
const DeltaSchema = z.object({
|
|
1684
|
+
spec: z.string(),
|
|
1685
|
+
operation: DeltaOperationType,
|
|
1686
|
+
description: z.string(),
|
|
1687
|
+
requirement: RequirementSchema.optional(),
|
|
1688
|
+
requirements: z.array(RequirementSchema).optional(),
|
|
1689
|
+
rename: z.object({
|
|
1690
|
+
from: z.string(),
|
|
1691
|
+
to: z.string()
|
|
1692
|
+
}).optional()
|
|
1693
|
+
});
|
|
1694
|
+
/**
|
|
1695
|
+
* A task within a change proposal.
|
|
1696
|
+
* Tasks are parsed from tasks.md using checkbox syntax: - [ ] or - [x]
|
|
1697
|
+
*/
|
|
1698
|
+
const TaskSchema = z.object({
|
|
1699
|
+
id: z.string(),
|
|
1700
|
+
text: z.string(),
|
|
1701
|
+
completed: z.boolean(),
|
|
1702
|
+
section: z.string().optional()
|
|
1703
|
+
});
|
|
1704
|
+
/**
|
|
1705
|
+
* A delta spec file from changes/{id}/specs/{specId}/spec.md
|
|
1706
|
+
* Contains the proposed changes to a spec
|
|
1707
|
+
*/
|
|
1708
|
+
const DeltaSpecSchema = z.object({
|
|
1709
|
+
specId: z.string(),
|
|
1710
|
+
content: z.string()
|
|
1711
|
+
});
|
|
1712
|
+
/**
|
|
1713
|
+
* A change proposal document.
|
|
1714
|
+
* Located at: openspec/changes/{id}/proposal.md + tasks.md
|
|
1715
|
+
*
|
|
1716
|
+
* Change proposals describe why a change is needed, what will change,
|
|
1717
|
+
* which specs are affected (deltas), and trackable tasks.
|
|
1718
|
+
*/
|
|
1719
|
+
const ChangeSchema = z.object({
|
|
1720
|
+
id: z.string(),
|
|
1721
|
+
name: z.string(),
|
|
1722
|
+
why: z.string(),
|
|
1723
|
+
whatChanges: z.string(),
|
|
1724
|
+
deltas: z.array(DeltaSchema),
|
|
1725
|
+
tasks: z.array(TaskSchema),
|
|
1726
|
+
progress: z.object({
|
|
1727
|
+
total: z.number(),
|
|
1728
|
+
completed: z.number()
|
|
1729
|
+
}),
|
|
1730
|
+
design: z.string().optional(),
|
|
1731
|
+
deltaSpecs: z.array(DeltaSpecSchema).optional(),
|
|
1732
|
+
metadata: z.object({
|
|
1733
|
+
version: z.string().default("1.0.0"),
|
|
1734
|
+
format: z.literal("openspec-change").default("openspec-change")
|
|
1735
|
+
}).optional()
|
|
1736
|
+
});
|
|
1737
|
+
|
|
1738
|
+
//#endregion
|
|
1739
|
+
//#region src/watcher.ts
|
|
1740
|
+
/**
|
|
1741
|
+
* OpenSpec file watcher
|
|
1742
|
+
* Watches the openspec/ directory for changes and emits events
|
|
1743
|
+
*/
|
|
1744
|
+
var OpenSpecWatcher = class extends EventEmitter {
|
|
1745
|
+
watchers = [];
|
|
1746
|
+
debounceTimers = /* @__PURE__ */ new Map();
|
|
1747
|
+
debounceMs;
|
|
1748
|
+
constructor(projectDir, options = {}) {
|
|
1749
|
+
super();
|
|
1750
|
+
this.projectDir = projectDir;
|
|
1751
|
+
this.debounceMs = options.debounceMs ?? 100;
|
|
1752
|
+
}
|
|
1753
|
+
get openspecDir() {
|
|
1754
|
+
return join(this.projectDir, "openspec");
|
|
1755
|
+
}
|
|
1756
|
+
get specsDir() {
|
|
1757
|
+
return join(this.openspecDir, "specs");
|
|
1758
|
+
}
|
|
1759
|
+
get changesDir() {
|
|
1760
|
+
return join(this.openspecDir, "changes");
|
|
1761
|
+
}
|
|
1762
|
+
get archiveDir() {
|
|
1763
|
+
return join(this.changesDir, "archive");
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* Start watching for file changes
|
|
1767
|
+
*/
|
|
1768
|
+
start() {
|
|
1769
|
+
this.stop();
|
|
1770
|
+
this.watchDir(this.specsDir, (filename, eventType) => {
|
|
1771
|
+
const match = filename.match(/^([^/]+)\//);
|
|
1772
|
+
if (match) this.emitDebounced(`spec:${match[1]}`, {
|
|
1773
|
+
type: "spec",
|
|
1774
|
+
action: eventType === "rename" ? "create" : "update",
|
|
1775
|
+
id: match[1],
|
|
1776
|
+
path: join(this.specsDir, filename),
|
|
1777
|
+
timestamp: Date.now()
|
|
1778
|
+
});
|
|
1779
|
+
});
|
|
1780
|
+
this.watchDir(this.changesDir, (filename, eventType) => {
|
|
1781
|
+
if (filename.startsWith("archive/")) return;
|
|
1782
|
+
const match = filename.match(/^([^/]+)\//);
|
|
1783
|
+
if (match) this.emitDebounced(`change:${match[1]}`, {
|
|
1784
|
+
type: "change",
|
|
1785
|
+
action: eventType === "rename" ? "create" : "update",
|
|
1786
|
+
id: match[1],
|
|
1787
|
+
path: join(this.changesDir, filename),
|
|
1788
|
+
timestamp: Date.now()
|
|
1789
|
+
});
|
|
1790
|
+
});
|
|
1791
|
+
this.watchDir(this.archiveDir, (filename, eventType) => {
|
|
1792
|
+
const match = filename.match(/^([^/]+)\//);
|
|
1793
|
+
if (match) this.emitDebounced(`archive:${match[1]}`, {
|
|
1794
|
+
type: "archive",
|
|
1795
|
+
action: eventType === "rename" ? "create" : "update",
|
|
1796
|
+
id: match[1],
|
|
1797
|
+
path: join(this.archiveDir, filename),
|
|
1798
|
+
timestamp: Date.now()
|
|
1799
|
+
});
|
|
1800
|
+
});
|
|
1801
|
+
this.watchDir(this.openspecDir, (filename, eventType) => {
|
|
1802
|
+
if (filename === "project.md" || filename === "AGENTS.md") this.emitDebounced(`project:${filename}`, {
|
|
1803
|
+
type: "project",
|
|
1804
|
+
action: eventType === "rename" ? "create" : "update",
|
|
1805
|
+
path: join(this.openspecDir, filename),
|
|
1806
|
+
timestamp: Date.now()
|
|
1807
|
+
});
|
|
1808
|
+
});
|
|
1809
|
+
this.emit("started");
|
|
1810
|
+
}
|
|
1811
|
+
/**
|
|
1812
|
+
* Stop watching for file changes
|
|
1813
|
+
*/
|
|
1814
|
+
stop() {
|
|
1815
|
+
for (const watcher of this.watchers) watcher.close();
|
|
1816
|
+
this.watchers = [];
|
|
1817
|
+
for (const timer of this.debounceTimers.values()) clearTimeout(timer);
|
|
1818
|
+
this.debounceTimers.clear();
|
|
1819
|
+
this.emit("stopped");
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* Watch a directory recursively
|
|
1823
|
+
*/
|
|
1824
|
+
watchDir(dir, callback) {
|
|
1825
|
+
try {
|
|
1826
|
+
const watcher = watch(dir, { recursive: true }, (eventType, filename) => {
|
|
1827
|
+
if (filename) callback(filename, eventType);
|
|
1828
|
+
});
|
|
1829
|
+
watcher.on("error", (error) => {
|
|
1830
|
+
this.emit("error", error);
|
|
1831
|
+
});
|
|
1832
|
+
this.watchers.push(watcher);
|
|
1833
|
+
} catch (error) {
|
|
1834
|
+
this.emit("warning", `Could not watch ${dir}: ${error}`);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
/**
|
|
1838
|
+
* Emit event with debouncing to avoid duplicate events
|
|
1839
|
+
*/
|
|
1840
|
+
emitDebounced(key, event) {
|
|
1841
|
+
const existing = this.debounceTimers.get(key);
|
|
1842
|
+
if (existing) clearTimeout(existing);
|
|
1843
|
+
const timer = setTimeout(() => {
|
|
1844
|
+
this.debounceTimers.delete(key);
|
|
1845
|
+
this.emit("change", event);
|
|
1846
|
+
}, this.debounceMs);
|
|
1847
|
+
this.debounceTimers.set(key, timer);
|
|
1848
|
+
}
|
|
1849
|
+
};
|
|
1850
|
+
/**
|
|
1851
|
+
* Create a file change observable for use with tRPC subscriptions
|
|
1852
|
+
*/
|
|
1853
|
+
function createFileChangeObservable(watcher) {
|
|
1854
|
+
return { subscribe: (observer) => {
|
|
1855
|
+
const changeHandler = (event) => {
|
|
1856
|
+
observer.next(event);
|
|
1857
|
+
};
|
|
1858
|
+
const errorHandler = (error) => {
|
|
1859
|
+
observer.error?.(error);
|
|
1860
|
+
};
|
|
1861
|
+
watcher.on("change", changeHandler);
|
|
1862
|
+
watcher.on("error", errorHandler);
|
|
1863
|
+
return { unsubscribe: () => {
|
|
1864
|
+
watcher.off("change", changeHandler);
|
|
1865
|
+
watcher.off("error", errorHandler);
|
|
1866
|
+
} };
|
|
1867
|
+
} };
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
//#endregion
|
|
1871
|
+
//#region src/config.ts
|
|
1872
|
+
const execAsync = promisify(exec);
|
|
1873
|
+
/** 默认的 fallback CLI 命令(数组形式) */
|
|
1874
|
+
const FALLBACK_CLI_COMMAND = ["npx", "@fission-ai/openspec"];
|
|
1875
|
+
/** 全局 openspec 命令(数组形式) */
|
|
1876
|
+
const GLOBAL_CLI_COMMAND = ["openspec"];
|
|
1877
|
+
/** 缓存检测到的 CLI 命令 */
|
|
1878
|
+
let detectedCliCommand = null;
|
|
1879
|
+
/**
|
|
1880
|
+
* 解析 CLI 命令字符串为数组
|
|
1881
|
+
*
|
|
1882
|
+
* 支持两种格式:
|
|
1883
|
+
* 1. JSON 数组:以 `[` 开头,如 `["npx", "@fission-ai/openspec"]`
|
|
1884
|
+
* 2. 简单字符串:用空格分割,如 `npx @fission-ai/openspec`
|
|
1885
|
+
*
|
|
1886
|
+
* 注意:简单字符串解析不支持带引号的参数,如需复杂命令请使用 JSON 数组格式
|
|
1887
|
+
*/
|
|
1888
|
+
function parseCliCommand(command) {
|
|
1889
|
+
const trimmed = command.trim();
|
|
1890
|
+
if (trimmed.startsWith("[")) try {
|
|
1891
|
+
const parsed = JSON.parse(trimmed);
|
|
1892
|
+
if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) return parsed;
|
|
1893
|
+
throw new Error("Invalid JSON array: expected array of strings");
|
|
1894
|
+
} catch (err) {
|
|
1895
|
+
throw new Error(`Failed to parse CLI command as JSON array: ${err instanceof Error ? err.message : err}`);
|
|
1896
|
+
}
|
|
1897
|
+
return trimmed.split(/\s+/).filter(Boolean);
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* 比较两个语义化版本号
|
|
1901
|
+
* @returns 正数表示 a > b,负数表示 a < b,0 表示相等
|
|
1902
|
+
*/
|
|
1903
|
+
function compareVersions(a, b) {
|
|
1904
|
+
const parseVersion = (v) => {
|
|
1905
|
+
return v.replace(/^v/, "").split("-")[0].split(".").map((n) => parseInt(n, 10) || 0);
|
|
1906
|
+
};
|
|
1907
|
+
const aParts = parseVersion(a);
|
|
1908
|
+
const bParts = parseVersion(b);
|
|
1909
|
+
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
|
1910
|
+
const aVal = aParts[i] ?? 0;
|
|
1911
|
+
const bVal = bParts[i] ?? 0;
|
|
1912
|
+
if (aVal !== bVal) return aVal - bVal;
|
|
1913
|
+
}
|
|
1914
|
+
return 0;
|
|
1915
|
+
}
|
|
1916
|
+
/**
|
|
1917
|
+
* 获取 npx 可用的最新版本
|
|
1918
|
+
*
|
|
1919
|
+
* 使用 `npx @fission-ai/openspec --version` 获取最新版本
|
|
1920
|
+
* 这会下载并执行最新版本,所以超时时间较长
|
|
1921
|
+
*/
|
|
1922
|
+
async function fetchLatestVersion() {
|
|
1923
|
+
try {
|
|
1924
|
+
const { stdout } = await execAsync("npx @fission-ai/openspec --version", { timeout: 6e4 });
|
|
1925
|
+
return stdout.trim();
|
|
1926
|
+
} catch {
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
/**
|
|
1931
|
+
* 嗅探全局 openspec 命令(无缓存)
|
|
1932
|
+
*
|
|
1933
|
+
* 使用 `openspec --version` 检测是否有全局命令可用。
|
|
1934
|
+
* 同时检查 npm registry 上的最新版本。
|
|
1935
|
+
* 每次调用都会重新检测,不使用缓存。
|
|
1936
|
+
*/
|
|
1937
|
+
async function sniffGlobalCli() {
|
|
1938
|
+
const [localResult, latestVersion] = await Promise.all([execAsync("openspec --version", { timeout: 1e4 }).catch((err) => ({ error: err })), fetchLatestVersion()]);
|
|
1939
|
+
if ("error" in localResult) {
|
|
1940
|
+
const error = localResult.error instanceof Error ? localResult.error.message : String(localResult.error);
|
|
1941
|
+
if (error.includes("not found") || error.includes("ENOENT") || error.includes("not recognized")) return {
|
|
1942
|
+
hasGlobal: false,
|
|
1943
|
+
latestVersion,
|
|
1944
|
+
hasUpdate: !!latestVersion
|
|
1945
|
+
};
|
|
1946
|
+
return {
|
|
1947
|
+
hasGlobal: false,
|
|
1948
|
+
latestVersion,
|
|
1949
|
+
hasUpdate: !!latestVersion,
|
|
1950
|
+
error
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
const version = localResult.stdout.trim();
|
|
1954
|
+
detectedCliCommand = GLOBAL_CLI_COMMAND;
|
|
1955
|
+
return {
|
|
1956
|
+
hasGlobal: true,
|
|
1957
|
+
version,
|
|
1958
|
+
latestVersion,
|
|
1959
|
+
hasUpdate: latestVersion ? compareVersions(latestVersion, version) > 0 : false
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
/**
|
|
1963
|
+
* 检测全局安装的 openspec 命令
|
|
1964
|
+
* 优先使用全局命令,fallback 到 npx
|
|
1965
|
+
*
|
|
1966
|
+
* @returns CLI 命令数组
|
|
1967
|
+
*/
|
|
1968
|
+
async function detectCliCommand() {
|
|
1969
|
+
if (detectedCliCommand !== null) return detectedCliCommand;
|
|
1970
|
+
try {
|
|
1971
|
+
await execAsync(`${process.platform === "win32" ? "where" : "which"} openspec`);
|
|
1972
|
+
detectedCliCommand = GLOBAL_CLI_COMMAND;
|
|
1973
|
+
return detectedCliCommand;
|
|
1974
|
+
} catch {
|
|
1975
|
+
detectedCliCommand = FALLBACK_CLI_COMMAND;
|
|
1976
|
+
return detectedCliCommand;
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
/**
|
|
1980
|
+
* 获取默认 CLI 命令(异步,带检测)
|
|
1981
|
+
*
|
|
1982
|
+
* @returns CLI 命令数组,如 `['openspec']` 或 `['npx', '@fission-ai/openspec']`
|
|
1983
|
+
*/
|
|
1984
|
+
async function getDefaultCliCommand() {
|
|
1985
|
+
return detectCliCommand();
|
|
1986
|
+
}
|
|
1987
|
+
/**
|
|
1988
|
+
* 获取默认 CLI 命令的字符串形式(用于 UI 显示)
|
|
1989
|
+
*/
|
|
1990
|
+
async function getDefaultCliCommandString() {
|
|
1991
|
+
return (await detectCliCommand()).join(" ");
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* OpenSpecUI 配置 Schema
|
|
1995
|
+
*
|
|
1996
|
+
* 存储在 openspec/.openspecui.json 中,利用文件监听实现响应式更新
|
|
1997
|
+
*/
|
|
1998
|
+
const OpenSpecUIConfigSchema = z.object({
|
|
1999
|
+
cli: z.object({ command: z.string().optional() }).default({}),
|
|
2000
|
+
ui: z.object({ theme: z.enum([
|
|
2001
|
+
"light",
|
|
2002
|
+
"dark",
|
|
2003
|
+
"system"
|
|
2004
|
+
]).default("system") }).default({})
|
|
2005
|
+
});
|
|
2006
|
+
/** 默认配置(静态,用于测试和类型) */
|
|
2007
|
+
const DEFAULT_CONFIG = {
|
|
2008
|
+
cli: {},
|
|
2009
|
+
ui: { theme: "system" }
|
|
2010
|
+
};
|
|
2011
|
+
/**
|
|
2012
|
+
* 配置管理器
|
|
2013
|
+
*
|
|
2014
|
+
* 负责读写 openspec/.openspecui.json 配置文件。
|
|
2015
|
+
* 读取操作使用 reactiveReadFile,支持响应式更新。
|
|
2016
|
+
*/
|
|
2017
|
+
var ConfigManager = class {
|
|
2018
|
+
configPath;
|
|
2019
|
+
constructor(projectDir) {
|
|
2020
|
+
this.configPath = join(projectDir, "openspec", ".openspecui.json");
|
|
2021
|
+
}
|
|
2022
|
+
/**
|
|
2023
|
+
* 读取配置(响应式)
|
|
2024
|
+
*
|
|
2025
|
+
* 如果配置文件不存在,返回默认配置。
|
|
2026
|
+
* 如果配置文件格式错误,返回默认配置并打印警告。
|
|
2027
|
+
*/
|
|
2028
|
+
async readConfig() {
|
|
2029
|
+
const content = await reactiveReadFile(this.configPath);
|
|
2030
|
+
if (!content) return DEFAULT_CONFIG;
|
|
2031
|
+
try {
|
|
2032
|
+
const parsed = JSON.parse(content);
|
|
2033
|
+
const result = OpenSpecUIConfigSchema.safeParse(parsed);
|
|
2034
|
+
if (result.success) return result.data;
|
|
2035
|
+
console.warn("Invalid config format, using defaults:", result.error.message);
|
|
2036
|
+
return DEFAULT_CONFIG;
|
|
2037
|
+
} catch (err) {
|
|
2038
|
+
console.warn("Failed to parse config, using defaults:", err);
|
|
2039
|
+
return DEFAULT_CONFIG;
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
/**
|
|
2043
|
+
* 写入配置
|
|
2044
|
+
*
|
|
2045
|
+
* 会触发文件监听,自动更新订阅者。
|
|
2046
|
+
*/
|
|
2047
|
+
async writeConfig(config) {
|
|
2048
|
+
const current = await this.readConfig();
|
|
2049
|
+
const merged = {
|
|
2050
|
+
...current,
|
|
2051
|
+
...config,
|
|
2052
|
+
cli: {
|
|
2053
|
+
...current.cli,
|
|
2054
|
+
...config.cli
|
|
2055
|
+
},
|
|
2056
|
+
ui: {
|
|
2057
|
+
...current.ui,
|
|
2058
|
+
...config.ui
|
|
2059
|
+
}
|
|
2060
|
+
};
|
|
2061
|
+
await writeFile(this.configPath, JSON.stringify(merged, null, 2), "utf-8");
|
|
2062
|
+
}
|
|
2063
|
+
/**
|
|
2064
|
+
* 获取 CLI 命令(数组形式)
|
|
2065
|
+
*
|
|
2066
|
+
* 优先级:配置文件 > 全局 openspec 命令 > npx fallback
|
|
2067
|
+
*
|
|
2068
|
+
* @returns CLI 命令数组,如 `['openspec']` 或 `['npx', '@fission-ai/openspec']`
|
|
2069
|
+
*/
|
|
2070
|
+
async getCliCommand() {
|
|
2071
|
+
const config = await this.readConfig();
|
|
2072
|
+
if (config.cli.command) return parseCliCommand(config.cli.command);
|
|
2073
|
+
return getDefaultCliCommand();
|
|
2074
|
+
}
|
|
2075
|
+
/**
|
|
2076
|
+
* 获取 CLI 命令的字符串形式(用于 UI 显示)
|
|
2077
|
+
*/
|
|
2078
|
+
async getCliCommandString() {
|
|
2079
|
+
return (await this.getCliCommand()).join(" ");
|
|
2080
|
+
}
|
|
2081
|
+
/**
|
|
2082
|
+
* 设置 CLI 命令
|
|
2083
|
+
*/
|
|
2084
|
+
async setCliCommand(command) {
|
|
2085
|
+
await this.writeConfig({ cli: { command } });
|
|
2086
|
+
}
|
|
2087
|
+
};
|
|
2088
|
+
|
|
2089
|
+
//#endregion
|
|
2090
|
+
//#region src/cli-executor.ts
|
|
2091
|
+
/**
|
|
2092
|
+
* CLI 执行器
|
|
2093
|
+
*
|
|
2094
|
+
* 负责调用外部 openspec CLI 命令。
|
|
2095
|
+
* 命令前缀从 ConfigManager 获取,支持:
|
|
2096
|
+
* - ['npx', '@fission-ai/openspec'] (默认)
|
|
2097
|
+
* - ['openspec'] (全局安装)
|
|
2098
|
+
* - 自定义数组或字符串
|
|
2099
|
+
*
|
|
2100
|
+
* 注意:所有命令都使用 shell: false 执行,避免 shell 注入风险
|
|
2101
|
+
*/
|
|
2102
|
+
var CliExecutor = class {
|
|
2103
|
+
constructor(configManager, projectDir) {
|
|
2104
|
+
this.configManager = configManager;
|
|
2105
|
+
this.projectDir = projectDir;
|
|
2106
|
+
}
|
|
2107
|
+
/**
|
|
2108
|
+
* 创建干净的环境变量,移除 pnpm 特有的配置
|
|
2109
|
+
* 避免 pnpm 环境变量污染 npx/npm 执行
|
|
2110
|
+
*/
|
|
2111
|
+
getCleanEnv() {
|
|
2112
|
+
const env = { ...process.env };
|
|
2113
|
+
for (const key of Object.keys(env)) if (key.startsWith("npm_config_") || key.startsWith("npm_package_") || key === "npm_execpath" || key === "npm_lifecycle_event" || key === "npm_lifecycle_script") delete env[key];
|
|
2114
|
+
return env;
|
|
2115
|
+
}
|
|
2116
|
+
/**
|
|
2117
|
+
* 构建完整命令数组
|
|
2118
|
+
*
|
|
2119
|
+
* @param args CLI 参数,如 ['init'] 或 ['archive', 'change-id']
|
|
2120
|
+
* @returns [command, ...commandArgs, ...args]
|
|
2121
|
+
*/
|
|
2122
|
+
async buildCommandArray(args) {
|
|
2123
|
+
return [...await this.configManager.getCliCommand(), ...args];
|
|
2124
|
+
}
|
|
2125
|
+
/**
|
|
2126
|
+
* 执行 CLI 命令
|
|
2127
|
+
*
|
|
2128
|
+
* @param args CLI 参数,如 ['init'] 或 ['archive', 'change-id']
|
|
2129
|
+
* @returns 执行结果
|
|
2130
|
+
*/
|
|
2131
|
+
async execute(args) {
|
|
2132
|
+
const [cmd, ...cmdArgs] = await this.buildCommandArray(args);
|
|
2133
|
+
return new Promise((resolve$1) => {
|
|
2134
|
+
const child = spawn(cmd, cmdArgs, {
|
|
2135
|
+
cwd: this.projectDir,
|
|
2136
|
+
shell: false,
|
|
2137
|
+
env: this.getCleanEnv()
|
|
2138
|
+
});
|
|
2139
|
+
let stdout = "";
|
|
2140
|
+
let stderr = "";
|
|
2141
|
+
child.stdout?.on("data", (data) => {
|
|
2142
|
+
stdout += data.toString();
|
|
2143
|
+
});
|
|
2144
|
+
child.stderr?.on("data", (data) => {
|
|
2145
|
+
stderr += data.toString();
|
|
2146
|
+
});
|
|
2147
|
+
child.on("close", (exitCode) => {
|
|
2148
|
+
resolve$1({
|
|
2149
|
+
success: exitCode === 0,
|
|
2150
|
+
stdout,
|
|
2151
|
+
stderr,
|
|
2152
|
+
exitCode
|
|
2153
|
+
});
|
|
2154
|
+
});
|
|
2155
|
+
child.on("error", (err) => {
|
|
2156
|
+
resolve$1({
|
|
2157
|
+
success: false,
|
|
2158
|
+
stdout,
|
|
2159
|
+
stderr: stderr + "\n" + err.message,
|
|
2160
|
+
exitCode: null
|
|
2161
|
+
});
|
|
2162
|
+
});
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
/**
|
|
2166
|
+
* 执行 openspec init(非交互式)
|
|
2167
|
+
*
|
|
2168
|
+
* @param tools 工具列表,如 ['claude', 'cursor'] 或 'all' 或 'none'
|
|
2169
|
+
*/
|
|
2170
|
+
async init(tools = "all") {
|
|
2171
|
+
const toolsArg = Array.isArray(tools) ? tools.join(",") : tools;
|
|
2172
|
+
return this.execute([
|
|
2173
|
+
"init",
|
|
2174
|
+
"--tools",
|
|
2175
|
+
toolsArg
|
|
2176
|
+
]);
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* 执行 openspec archive <changeId>(非交互式)
|
|
2180
|
+
*
|
|
2181
|
+
* @param changeId 要归档的 change ID
|
|
2182
|
+
* @param options 选项
|
|
2183
|
+
*/
|
|
2184
|
+
async archive(changeId, options = {}) {
|
|
2185
|
+
const args = [
|
|
2186
|
+
"archive",
|
|
2187
|
+
"-y",
|
|
2188
|
+
changeId
|
|
2189
|
+
];
|
|
2190
|
+
if (options.skipSpecs) args.push("--skip-specs");
|
|
2191
|
+
if (options.noValidate) args.push("--no-validate");
|
|
2192
|
+
return this.execute(args);
|
|
2193
|
+
}
|
|
2194
|
+
/**
|
|
2195
|
+
* 执行 openspec validate [type] [id]
|
|
2196
|
+
*/
|
|
2197
|
+
async validate(type, id) {
|
|
2198
|
+
const args = ["validate"];
|
|
2199
|
+
if (type) args.push(type);
|
|
2200
|
+
if (id) args.push(id);
|
|
2201
|
+
return this.execute(args);
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* 流式执行 openspec validate
|
|
2205
|
+
*/
|
|
2206
|
+
validateStream(type, id, onEvent) {
|
|
2207
|
+
const args = ["validate"];
|
|
2208
|
+
if (type) args.push(type);
|
|
2209
|
+
if (id) args.push(id);
|
|
2210
|
+
return this.executeStream(args, onEvent);
|
|
2211
|
+
}
|
|
2212
|
+
/**
|
|
2213
|
+
* 检查 CLI 是否可用
|
|
2214
|
+
* @param timeout 超时时间(毫秒),默认 10 秒
|
|
2215
|
+
*/
|
|
2216
|
+
async checkAvailability(timeout = 1e4) {
|
|
2217
|
+
try {
|
|
2218
|
+
const result = await Promise.race([this.execute(["--version"]), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("CLI check timed out")), timeout))]);
|
|
2219
|
+
if (result.success) return {
|
|
2220
|
+
available: true,
|
|
2221
|
+
version: result.stdout.trim()
|
|
2222
|
+
};
|
|
2223
|
+
return {
|
|
2224
|
+
available: false,
|
|
2225
|
+
error: result.stderr || "Unknown error"
|
|
2226
|
+
};
|
|
2227
|
+
} catch (err) {
|
|
2228
|
+
return {
|
|
2229
|
+
available: false,
|
|
2230
|
+
error: err instanceof Error ? err.message : "Unknown error"
|
|
2231
|
+
};
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
/**
|
|
2235
|
+
* 流式执行 CLI 命令
|
|
2236
|
+
*
|
|
2237
|
+
* @param args CLI 参数
|
|
2238
|
+
* @param onEvent 事件回调
|
|
2239
|
+
* @returns 取消函数
|
|
2240
|
+
*/
|
|
2241
|
+
async executeStream(args, onEvent) {
|
|
2242
|
+
const fullCommand = await this.buildCommandArray(args);
|
|
2243
|
+
const [cmd, ...cmdArgs] = fullCommand;
|
|
2244
|
+
onEvent({
|
|
2245
|
+
type: "command",
|
|
2246
|
+
data: fullCommand.join(" ")
|
|
2247
|
+
});
|
|
2248
|
+
const child = spawn(cmd, cmdArgs, {
|
|
2249
|
+
cwd: this.projectDir,
|
|
2250
|
+
shell: false,
|
|
2251
|
+
env: this.getCleanEnv()
|
|
2252
|
+
});
|
|
2253
|
+
child.stdout?.on("data", (data) => {
|
|
2254
|
+
onEvent({
|
|
2255
|
+
type: "stdout",
|
|
2256
|
+
data: data.toString()
|
|
2257
|
+
});
|
|
2258
|
+
});
|
|
2259
|
+
child.stderr?.on("data", (data) => {
|
|
2260
|
+
onEvent({
|
|
2261
|
+
type: "stderr",
|
|
2262
|
+
data: data.toString()
|
|
2263
|
+
});
|
|
2264
|
+
});
|
|
2265
|
+
child.on("close", (exitCode) => {
|
|
2266
|
+
onEvent({
|
|
2267
|
+
type: "exit",
|
|
2268
|
+
exitCode
|
|
2269
|
+
});
|
|
2270
|
+
});
|
|
2271
|
+
child.on("error", (err) => {
|
|
2272
|
+
onEvent({
|
|
2273
|
+
type: "stderr",
|
|
2274
|
+
data: err.message
|
|
2275
|
+
});
|
|
2276
|
+
onEvent({
|
|
2277
|
+
type: "exit",
|
|
2278
|
+
exitCode: null
|
|
2279
|
+
});
|
|
2280
|
+
});
|
|
2281
|
+
return () => {
|
|
2282
|
+
child.kill();
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2285
|
+
/**
|
|
2286
|
+
* 流式执行 openspec init
|
|
2287
|
+
*/
|
|
2288
|
+
initStream(tools, onEvent) {
|
|
2289
|
+
const toolsArg = Array.isArray(tools) ? tools.join(",") : tools;
|
|
2290
|
+
return this.executeStream([
|
|
2291
|
+
"init",
|
|
2292
|
+
"--tools",
|
|
2293
|
+
toolsArg
|
|
2294
|
+
], onEvent);
|
|
2295
|
+
}
|
|
2296
|
+
/**
|
|
2297
|
+
* 流式执行 openspec archive
|
|
2298
|
+
*/
|
|
2299
|
+
archiveStream(changeId, options, onEvent) {
|
|
2300
|
+
const args = [
|
|
2301
|
+
"archive",
|
|
2302
|
+
"-y",
|
|
2303
|
+
changeId
|
|
2304
|
+
];
|
|
2305
|
+
if (options.skipSpecs) args.push("--skip-specs");
|
|
2306
|
+
if (options.noValidate) args.push("--no-validate");
|
|
2307
|
+
return this.executeStream(args, onEvent);
|
|
2308
|
+
}
|
|
2309
|
+
/**
|
|
2310
|
+
* 流式执行任意命令(数组形式)
|
|
2311
|
+
*
|
|
2312
|
+
* 用于执行不需要 openspec CLI 前缀的命令,如 npm install。
|
|
2313
|
+
* 使用 shell: false 避免 shell 注入风险。
|
|
2314
|
+
*
|
|
2315
|
+
* @param command 命令数组,如 ['npm', 'install', '-g', '@fission-ai/openspec']
|
|
2316
|
+
* @param onEvent 事件回调
|
|
2317
|
+
* @returns 取消函数
|
|
2318
|
+
*/
|
|
2319
|
+
executeCommandStream(command, onEvent) {
|
|
2320
|
+
const [cmd, ...cmdArgs] = command;
|
|
2321
|
+
onEvent({
|
|
2322
|
+
type: "command",
|
|
2323
|
+
data: command.join(" ")
|
|
2324
|
+
});
|
|
2325
|
+
const child = spawn(cmd, cmdArgs, {
|
|
2326
|
+
cwd: this.projectDir,
|
|
2327
|
+
shell: false,
|
|
2328
|
+
env: this.getCleanEnv()
|
|
2329
|
+
});
|
|
2330
|
+
child.stdout?.on("data", (data) => {
|
|
2331
|
+
onEvent({
|
|
2332
|
+
type: "stdout",
|
|
2333
|
+
data: data.toString()
|
|
2334
|
+
});
|
|
2335
|
+
});
|
|
2336
|
+
child.stderr?.on("data", (data) => {
|
|
2337
|
+
onEvent({
|
|
2338
|
+
type: "stderr",
|
|
2339
|
+
data: data.toString()
|
|
2340
|
+
});
|
|
2341
|
+
});
|
|
2342
|
+
child.on("close", (exitCode) => {
|
|
2343
|
+
onEvent({
|
|
2344
|
+
type: "exit",
|
|
2345
|
+
exitCode
|
|
2346
|
+
});
|
|
2347
|
+
});
|
|
2348
|
+
child.on("error", (err) => {
|
|
2349
|
+
onEvent({
|
|
2350
|
+
type: "stderr",
|
|
2351
|
+
data: err.message
|
|
2352
|
+
});
|
|
2353
|
+
onEvent({
|
|
2354
|
+
type: "exit",
|
|
2355
|
+
exitCode: null
|
|
2356
|
+
});
|
|
2357
|
+
});
|
|
2358
|
+
return () => {
|
|
2359
|
+
child.kill();
|
|
2360
|
+
};
|
|
2361
|
+
}
|
|
2362
|
+
};
|
|
2363
|
+
|
|
2364
|
+
//#endregion
|
|
2365
|
+
//#region src/tool-config.ts
|
|
2366
|
+
/**
|
|
2367
|
+
* 工具配置检测模块
|
|
2368
|
+
*
|
|
2369
|
+
* 完全对齐 @fission-ai/openspec 的官方实现
|
|
2370
|
+
* 用于检测项目中已配置的 AI 工具
|
|
2371
|
+
*
|
|
2372
|
+
* 重要:使用响应式文件系统实现,监听配置目录,
|
|
2373
|
+
* 当配置文件变化时会自动触发更新。
|
|
2374
|
+
*
|
|
2375
|
+
* @see references/openspec/src/core/config.ts (AI_TOOLS)
|
|
2376
|
+
* @see references/openspec/src/core/configurators/slash/
|
|
2377
|
+
* @see references/openspec/src/core/init.ts (isToolConfigured)
|
|
2378
|
+
*/
|
|
2379
|
+
/**
|
|
2380
|
+
* 获取 Codex 全局 prompts 目录
|
|
2381
|
+
* 优先使用 $CODEX_HOME 环境变量,否则使用 ~/.codex
|
|
2382
|
+
* @see references/openspec/src/core/configurators/slash/codex.ts
|
|
2383
|
+
*/
|
|
2384
|
+
function getCodexGlobalPromptsDir() {
|
|
2385
|
+
return join$1(process.env.CODEX_HOME?.trim() || join$1(homedir(), ".codex"), "prompts");
|
|
2386
|
+
}
|
|
2387
|
+
/**
|
|
2388
|
+
* 所有支持的 AI 工具配置
|
|
2389
|
+
*
|
|
2390
|
+
* 完全对齐官方 OpenSpec CLI 的 AI_TOOLS
|
|
2391
|
+
* 按字母顺序排序(与官方一致)
|
|
2392
|
+
*
|
|
2393
|
+
* @see references/openspec/src/core/config.ts
|
|
2394
|
+
* @see references/openspec/src/core/configurators/slash/registry.ts
|
|
2395
|
+
*/
|
|
2396
|
+
const AI_TOOLS = [
|
|
2397
|
+
{
|
|
2398
|
+
name: "Amazon Q Developer",
|
|
2399
|
+
value: "amazon-q",
|
|
2400
|
+
available: true,
|
|
2401
|
+
successLabel: "Amazon Q Developer",
|
|
2402
|
+
scope: "project",
|
|
2403
|
+
detectionPath: ".amazonq/prompts/openspec-proposal.md"
|
|
2404
|
+
},
|
|
2405
|
+
{
|
|
2406
|
+
name: "Antigravity",
|
|
2407
|
+
value: "antigravity",
|
|
2408
|
+
available: true,
|
|
2409
|
+
successLabel: "Antigravity",
|
|
2410
|
+
scope: "project",
|
|
2411
|
+
detectionPath: ".agent/workflows/openspec-proposal.md"
|
|
2412
|
+
},
|
|
2413
|
+
{
|
|
2414
|
+
name: "Auggie (Augment CLI)",
|
|
2415
|
+
value: "auggie",
|
|
2416
|
+
available: true,
|
|
2417
|
+
successLabel: "Auggie",
|
|
2418
|
+
scope: "project",
|
|
2419
|
+
detectionPath: ".augment/commands/openspec-proposal.md"
|
|
2420
|
+
},
|
|
2421
|
+
{
|
|
2422
|
+
name: "Claude Code",
|
|
2423
|
+
value: "claude",
|
|
2424
|
+
available: true,
|
|
2425
|
+
successLabel: "Claude Code",
|
|
2426
|
+
scope: "project",
|
|
2427
|
+
detectionPath: ".claude/commands/openspec/proposal.md"
|
|
2428
|
+
},
|
|
2429
|
+
{
|
|
2430
|
+
name: "Cline",
|
|
2431
|
+
value: "cline",
|
|
2432
|
+
available: true,
|
|
2433
|
+
successLabel: "Cline",
|
|
2434
|
+
scope: "project",
|
|
2435
|
+
detectionPath: ".clinerules/workflows/openspec-proposal.md"
|
|
2436
|
+
},
|
|
2437
|
+
{
|
|
2438
|
+
name: "Codex",
|
|
2439
|
+
value: "codex",
|
|
2440
|
+
available: true,
|
|
2441
|
+
successLabel: "Codex",
|
|
2442
|
+
scope: "global",
|
|
2443
|
+
detectionPath: () => join$1(getCodexGlobalPromptsDir(), "openspec-proposal.md")
|
|
2444
|
+
},
|
|
2445
|
+
{
|
|
2446
|
+
name: "CodeBuddy Code (CLI)",
|
|
2447
|
+
value: "codebuddy",
|
|
2448
|
+
available: true,
|
|
2449
|
+
successLabel: "CodeBuddy Code",
|
|
2450
|
+
scope: "project",
|
|
2451
|
+
detectionPath: ".codebuddy/commands/openspec/proposal.md"
|
|
2452
|
+
},
|
|
2453
|
+
{
|
|
2454
|
+
name: "CoStrict",
|
|
2455
|
+
value: "costrict",
|
|
2456
|
+
available: true,
|
|
2457
|
+
successLabel: "CoStrict",
|
|
2458
|
+
scope: "project",
|
|
2459
|
+
detectionPath: ".cospec/openspec/commands/openspec-proposal.md"
|
|
2460
|
+
},
|
|
2461
|
+
{
|
|
2462
|
+
name: "Crush",
|
|
2463
|
+
value: "crush",
|
|
2464
|
+
available: true,
|
|
2465
|
+
successLabel: "Crush",
|
|
2466
|
+
scope: "project",
|
|
2467
|
+
detectionPath: ".crush/commands/openspec/proposal.md"
|
|
2468
|
+
},
|
|
2469
|
+
{
|
|
2470
|
+
name: "Cursor",
|
|
2471
|
+
value: "cursor",
|
|
2472
|
+
available: true,
|
|
2473
|
+
successLabel: "Cursor",
|
|
2474
|
+
scope: "project",
|
|
2475
|
+
detectionPath: ".cursor/commands/openspec-proposal.md"
|
|
2476
|
+
},
|
|
2477
|
+
{
|
|
2478
|
+
name: "Factory Droid",
|
|
2479
|
+
value: "factory",
|
|
2480
|
+
available: true,
|
|
2481
|
+
successLabel: "Factory Droid",
|
|
2482
|
+
scope: "project",
|
|
2483
|
+
detectionPath: ".factory/commands/openspec-proposal.md"
|
|
2484
|
+
},
|
|
2485
|
+
{
|
|
2486
|
+
name: "Gemini CLI",
|
|
2487
|
+
value: "gemini",
|
|
2488
|
+
available: true,
|
|
2489
|
+
successLabel: "Gemini CLI",
|
|
2490
|
+
scope: "project",
|
|
2491
|
+
detectionPath: ".gemini/commands/openspec/proposal.toml"
|
|
2492
|
+
},
|
|
2493
|
+
{
|
|
2494
|
+
name: "GitHub Copilot",
|
|
2495
|
+
value: "github-copilot",
|
|
2496
|
+
available: true,
|
|
2497
|
+
successLabel: "GitHub Copilot",
|
|
2498
|
+
scope: "project",
|
|
2499
|
+
detectionPath: ".github/prompts/openspec-proposal.prompt.md"
|
|
2500
|
+
},
|
|
2501
|
+
{
|
|
2502
|
+
name: "iFlow",
|
|
2503
|
+
value: "iflow",
|
|
2504
|
+
available: true,
|
|
2505
|
+
successLabel: "iFlow",
|
|
2506
|
+
scope: "project",
|
|
2507
|
+
detectionPath: ".iflow/commands/openspec-proposal.md"
|
|
2508
|
+
},
|
|
2509
|
+
{
|
|
2510
|
+
name: "Kilo Code",
|
|
2511
|
+
value: "kilocode",
|
|
2512
|
+
available: true,
|
|
2513
|
+
successLabel: "Kilo Code",
|
|
2514
|
+
scope: "project",
|
|
2515
|
+
detectionPath: ".kilocode/workflows/openspec-proposal.md"
|
|
2516
|
+
},
|
|
2517
|
+
{
|
|
2518
|
+
name: "OpenCode",
|
|
2519
|
+
value: "opencode",
|
|
2520
|
+
available: true,
|
|
2521
|
+
successLabel: "OpenCode",
|
|
2522
|
+
scope: "project",
|
|
2523
|
+
detectionPath: ".opencode/command/openspec-proposal.md"
|
|
2524
|
+
},
|
|
2525
|
+
{
|
|
2526
|
+
name: "Qoder (CLI)",
|
|
2527
|
+
value: "qoder",
|
|
2528
|
+
available: true,
|
|
2529
|
+
successLabel: "Qoder",
|
|
2530
|
+
scope: "project",
|
|
2531
|
+
detectionPath: ".qoder/commands/openspec/proposal.md"
|
|
2532
|
+
},
|
|
2533
|
+
{
|
|
2534
|
+
name: "Qwen Code",
|
|
2535
|
+
value: "qwen",
|
|
2536
|
+
available: true,
|
|
2537
|
+
successLabel: "Qwen Code",
|
|
2538
|
+
scope: "project",
|
|
2539
|
+
detectionPath: ".qwen/commands/openspec-proposal.toml"
|
|
2540
|
+
},
|
|
2541
|
+
{
|
|
2542
|
+
name: "RooCode",
|
|
2543
|
+
value: "roocode",
|
|
2544
|
+
available: true,
|
|
2545
|
+
successLabel: "RooCode",
|
|
2546
|
+
scope: "project",
|
|
2547
|
+
detectionPath: ".roo/commands/openspec-proposal.md"
|
|
2548
|
+
},
|
|
2549
|
+
{
|
|
2550
|
+
name: "Windsurf",
|
|
2551
|
+
value: "windsurf",
|
|
2552
|
+
available: true,
|
|
2553
|
+
successLabel: "Windsurf",
|
|
2554
|
+
scope: "project",
|
|
2555
|
+
detectionPath: ".windsurf/workflows/openspec-proposal.md"
|
|
2556
|
+
},
|
|
2557
|
+
{
|
|
2558
|
+
name: "AGENTS.md (works with Amp, VS Code, …)",
|
|
2559
|
+
value: "agents",
|
|
2560
|
+
available: false,
|
|
2561
|
+
successLabel: "your AGENTS.md-compatible assistant",
|
|
2562
|
+
scope: "project",
|
|
2563
|
+
detectionPath: "AGENTS.md"
|
|
2564
|
+
}
|
|
2565
|
+
];
|
|
2566
|
+
/**
|
|
2567
|
+
* 获取所有可用的工具(available: true)
|
|
2568
|
+
*/
|
|
2569
|
+
function getAvailableTools() {
|
|
2570
|
+
return AI_TOOLS.filter((tool) => tool.available);
|
|
2571
|
+
}
|
|
2572
|
+
/**
|
|
2573
|
+
* 获取所有可用的工具 ID 列表(available: true)
|
|
2574
|
+
*/
|
|
2575
|
+
function getAvailableToolIds() {
|
|
2576
|
+
return getAvailableTools().map((tool) => tool.value);
|
|
2577
|
+
}
|
|
2578
|
+
/**
|
|
2579
|
+
* 获取所有工具(包括 available: false 的)
|
|
2580
|
+
*/
|
|
2581
|
+
function getAllTools() {
|
|
2582
|
+
return AI_TOOLS;
|
|
2583
|
+
}
|
|
2584
|
+
/**
|
|
2585
|
+
* 获取所有工具 ID 列表(包括 available: false 的)
|
|
2586
|
+
*/
|
|
2587
|
+
function getAllToolIds() {
|
|
2588
|
+
return AI_TOOLS.map((tool) => tool.value);
|
|
2589
|
+
}
|
|
2590
|
+
/**
|
|
2591
|
+
* 根据工具 ID 获取工具配置
|
|
2592
|
+
*/
|
|
2593
|
+
function getToolById(toolId) {
|
|
2594
|
+
return AI_TOOLS.find((tool) => tool.value === toolId);
|
|
2595
|
+
}
|
|
2596
|
+
/** 状态缓存:projectDir -> ReactiveState */
|
|
2597
|
+
const stateCache = /* @__PURE__ */ new Map();
|
|
2598
|
+
/** 监听器释放函数缓存 */
|
|
2599
|
+
const releaseCache = /* @__PURE__ */ new Map();
|
|
2600
|
+
/**
|
|
2601
|
+
* 检查文件是否存在
|
|
2602
|
+
*/
|
|
2603
|
+
async function fileExists(filePath) {
|
|
2604
|
+
try {
|
|
2605
|
+
await stat(filePath);
|
|
2606
|
+
return true;
|
|
2607
|
+
} catch {
|
|
2608
|
+
return false;
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
/**
|
|
2612
|
+
* 解析工具的检测路径
|
|
2613
|
+
* @param config 工具配置
|
|
2614
|
+
* @param projectDir 项目根目录
|
|
2615
|
+
* @returns 绝对路径,如果无检测路径则返回 undefined
|
|
2616
|
+
*/
|
|
2617
|
+
function resolveDetectionPath(config, projectDir) {
|
|
2618
|
+
if (config.scope === "none" || !config.detectionPath) return;
|
|
2619
|
+
if (config.scope === "global") return config.detectionPath();
|
|
2620
|
+
return join$1(projectDir, config.detectionPath);
|
|
2621
|
+
}
|
|
2622
|
+
/**
|
|
2623
|
+
* 扫描已配置的工具(并行检查)
|
|
2624
|
+
*/
|
|
2625
|
+
async function scanConfiguredTools(projectDir) {
|
|
2626
|
+
return (await Promise.all(AI_TOOLS.map(async (config) => {
|
|
2627
|
+
const filePath = resolveDetectionPath(config, projectDir);
|
|
2628
|
+
if (!filePath) return null;
|
|
2629
|
+
return await fileExists(filePath) ? config.value : null;
|
|
2630
|
+
}))).filter((id) => id !== null);
|
|
2631
|
+
}
|
|
2632
|
+
/**
|
|
2633
|
+
* 获取需要监听的项目级目录列表
|
|
2634
|
+
* 只监听包含工具配置的一级隐藏目录
|
|
2635
|
+
*/
|
|
2636
|
+
function getProjectWatchDirs(projectDir) {
|
|
2637
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
2638
|
+
for (const config of AI_TOOLS) if (config.scope === "project" && config.detectionPath) {
|
|
2639
|
+
const firstDir = config.detectionPath.split("/")[0];
|
|
2640
|
+
if (firstDir) dirs.add(join$1(projectDir, firstDir));
|
|
2641
|
+
}
|
|
2642
|
+
return Array.from(dirs);
|
|
2643
|
+
}
|
|
2644
|
+
/**
|
|
2645
|
+
* 获取需要监听的全局目录列表
|
|
2646
|
+
* 如 Codex 的 ~/.codex/prompts/
|
|
2647
|
+
*/
|
|
2648
|
+
function getGlobalWatchDirs() {
|
|
2649
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
2650
|
+
for (const config of AI_TOOLS) if (config.scope === "global" && config.detectionPath) {
|
|
2651
|
+
const filePath = config.detectionPath();
|
|
2652
|
+
dirs.add(dirname(filePath));
|
|
2653
|
+
}
|
|
2654
|
+
return Array.from(dirs);
|
|
2655
|
+
}
|
|
2656
|
+
/**
|
|
2657
|
+
* 检测项目中已配置的工具(响应式)
|
|
2658
|
+
*
|
|
2659
|
+
* 监听两类目录:
|
|
2660
|
+
* 1. 项目级配置目录(如 .claude, .cursor 等)
|
|
2661
|
+
* 2. 全局配置目录(如 ~/.codex/prompts/)
|
|
2662
|
+
*
|
|
2663
|
+
* @param projectDir 项目根目录
|
|
2664
|
+
* @returns 已配置的工具 ID 列表
|
|
2665
|
+
*/
|
|
2666
|
+
async function getConfiguredTools(projectDir) {
|
|
2667
|
+
const normalizedPath = resolve(projectDir);
|
|
2668
|
+
const key = `tools:${normalizedPath}`;
|
|
2669
|
+
let state = stateCache.get(key);
|
|
2670
|
+
if (!state) {
|
|
2671
|
+
state = new ReactiveState(await scanConfiguredTools(normalizedPath), { equals: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]) });
|
|
2672
|
+
stateCache.set(key, state);
|
|
2673
|
+
const releases = [];
|
|
2674
|
+
const onUpdate = async () => {
|
|
2675
|
+
const newValue = await scanConfiguredTools(normalizedPath);
|
|
2676
|
+
state.set(newValue);
|
|
2677
|
+
};
|
|
2678
|
+
const projectWatchDirs = getProjectWatchDirs(normalizedPath);
|
|
2679
|
+
for (const dir of projectWatchDirs) {
|
|
2680
|
+
const release = acquireWatcher(dir, onUpdate, { recursive: true });
|
|
2681
|
+
releases.push(release);
|
|
2682
|
+
}
|
|
2683
|
+
const globalWatchDirs = getGlobalWatchDirs();
|
|
2684
|
+
for (const dir of globalWatchDirs) {
|
|
2685
|
+
const release = acquireWatcher(dir, onUpdate, { recursive: false });
|
|
2686
|
+
releases.push(release);
|
|
2687
|
+
}
|
|
2688
|
+
const rootRelease = acquireWatcher(normalizedPath, onUpdate, { recursive: false });
|
|
2689
|
+
releases.push(rootRelease);
|
|
2690
|
+
releaseCache.set(key, () => releases.forEach((r) => r()));
|
|
2691
|
+
}
|
|
2692
|
+
return state.get();
|
|
2693
|
+
}
|
|
2694
|
+
/**
|
|
2695
|
+
* 检查特定工具是否已配置
|
|
2696
|
+
*
|
|
2697
|
+
* @param projectDir 项目根目录
|
|
2698
|
+
* @param toolId 工具 ID
|
|
2699
|
+
* @returns 是否已配置
|
|
2700
|
+
*/
|
|
2701
|
+
async function isToolConfigured(projectDir, toolId) {
|
|
2702
|
+
return (await getConfiguredTools(projectDir)).includes(toolId);
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
//#endregion
|
|
2706
|
+
export { AI_TOOLS, ChangeFileSchema, ChangeSchema, CliExecutor, ConfigManager, DEFAULT_CONFIG, DeltaOperationType, DeltaSchema, DeltaSpecSchema, MarkdownParser, OpenSpecAdapter, OpenSpecUIConfigSchema, OpenSpecWatcher, ProjectWatcher, ReactiveContext, ReactiveState, RequirementSchema, SpecSchema, TaskSchema, Validator, acquireWatcher, clearCache, closeAllProjectWatchers, closeAllWatchers, contextStorage, createFileChangeObservable, getActiveWatcherCount, getAllToolIds, getAllTools, getAvailableToolIds, getAvailableTools, getCacheSize, getConfiguredTools, getDefaultCliCommand, getDefaultCliCommandString, getProjectWatcher, getToolById, getWatchedProjectDir, initWatcherPool, isToolConfigured, isWatcherPoolInitialized, parseCliCommand, reactiveExists, reactiveReadDir, reactiveReadFile, reactiveStat, sniffGlobalCli };
|