sillyspec 3.20.2 → 3.20.4

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.
Files changed (133) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +21 -21
  2. package/.claude/skills/sillyspec-auto/SKILL.md +83 -83
  3. package/.claude/skills/sillyspec-brainstorm/SKILL.md +44 -44
  4. package/.claude/skills/sillyspec-commit/SKILL.md +106 -106
  5. package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
  6. package/.claude/skills/sillyspec-doctor/SKILL.md +31 -31
  7. package/.claude/skills/sillyspec-execute/SKILL.md +30 -30
  8. package/.claude/skills/sillyspec-explore/SKILL.md +109 -109
  9. package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
  10. package/.claude/skills/sillyspec-plan/SKILL.md +21 -21
  11. package/.claude/skills/sillyspec-propose/SKILL.md +21 -21
  12. package/.claude/skills/sillyspec-quick/SKILL.md +21 -21
  13. package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
  14. package/.claude/skills/sillyspec-scan/SKILL.md +21 -21
  15. package/.claude/skills/sillyspec-state/SKILL.md +54 -54
  16. package/.claude/skills/sillyspec-status/SKILL.md +21 -21
  17. package/.claude/skills/sillyspec-verify/SKILL.md +21 -21
  18. package/.claude/skills/sillyspec-workspace/SKILL.md +157 -157
  19. package/.husky/pre-push +13 -13
  20. package/CLAUDE.md +18 -18
  21. package/README.md +198 -188
  22. package/SKILL.md +90 -91
  23. package/bin/sillyspec.js +2 -2
  24. package/docs/brainstorm-plan-contract.md +64 -64
  25. package/docs/plan-execute-contract.md +123 -123
  26. package/docs/platform-scan-protocol.md +298 -298
  27. package/docs/revision-mode.md +115 -115
  28. package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +99 -99
  29. package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +218 -218
  30. package/docs/sillyspec/file-lifecycle/stage-artifacts.md +167 -167
  31. package/docs/sillyspec/file-lifecycle/storage-and-state.md +148 -148
  32. package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +211 -193
  33. package/docs/sillyspec/file-lifecycle.md +125 -125
  34. package/docs/workflow-contract-regression.md +106 -106
  35. package/docs/worktree-isolation.md +252 -252
  36. package/package.json +40 -40
  37. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  38. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  39. package/packages/dashboard/dist/index.html +16 -16
  40. package/packages/dashboard/index.html +15 -15
  41. package/packages/dashboard/package-lock.json +2384 -2384
  42. package/packages/dashboard/package.json +25 -25
  43. package/packages/dashboard/server/executor.js +86 -86
  44. package/packages/dashboard/server/index.js +588 -588
  45. package/packages/dashboard/server/parser.js +526 -526
  46. package/packages/dashboard/server/watcher.js +344 -344
  47. package/packages/dashboard/src/App.vue +558 -558
  48. package/packages/dashboard/src/components/ActionBar.vue +93 -93
  49. package/packages/dashboard/src/components/CommandPalette.vue +96 -96
  50. package/packages/dashboard/src/components/DetailPanel.vue +137 -137
  51. package/packages/dashboard/src/components/LogStream.vue +65 -65
  52. package/packages/dashboard/src/components/PipelineStage.vue +95 -95
  53. package/packages/dashboard/src/components/PipelineView.vue +156 -156
  54. package/packages/dashboard/src/components/ProjectList.vue +210 -210
  55. package/packages/dashboard/src/components/StageBadge.vue +67 -67
  56. package/packages/dashboard/src/components/StepCard.vue +94 -94
  57. package/packages/dashboard/src/components/detail/DocsDetail.vue +48 -48
  58. package/packages/dashboard/src/components/detail/GitDetail.vue +61 -61
  59. package/packages/dashboard/src/components/detail/TechDetail.vue +43 -43
  60. package/packages/dashboard/src/composables/useDashboard.js +170 -170
  61. package/packages/dashboard/src/composables/useKeyboard.js +119 -119
  62. package/packages/dashboard/src/composables/useWebSocket.js +129 -129
  63. package/packages/dashboard/src/main.js +8 -8
  64. package/packages/dashboard/src/style.css +132 -132
  65. package/packages/dashboard/vite.config.js +18 -18
  66. package/src/brainstorm-postcheck.js +158 -158
  67. package/src/change-list.js +52 -52
  68. package/src/change-risk-profile.js +352 -352
  69. package/src/classify-change.js +73 -73
  70. package/src/constants.js +70 -70
  71. package/src/contract-matrix.js +278 -278
  72. package/src/db.js +201 -201
  73. package/src/endpoint-extractor.js +315 -315
  74. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  75. package/src/hooks/worktree-guard.js +653 -653
  76. package/src/index.js +922 -900
  77. package/src/init.js +431 -431
  78. package/src/knowledge-match.js +130 -130
  79. package/src/migrate.js +117 -117
  80. package/src/modules.js +482 -482
  81. package/src/progress.js +1734 -1734
  82. package/src/run.js +3465 -3358
  83. package/src/scan-postcheck.js +387 -383
  84. package/src/setup.js +398 -398
  85. package/src/stage-contract.js +700 -700
  86. package/src/stages/archive.js +160 -160
  87. package/src/stages/brainstorm-auto.js +229 -229
  88. package/src/stages/brainstorm.js +645 -645
  89. package/src/stages/doctor.js +365 -365
  90. package/src/stages/execute.js +625 -625
  91. package/src/stages/explore.js +34 -34
  92. package/src/stages/index.js +29 -29
  93. package/src/stages/knowledge.js +498 -498
  94. package/src/stages/plan-postcheck.js +511 -513
  95. package/src/stages/plan.js +582 -582
  96. package/src/stages/propose.js +174 -174
  97. package/src/stages/quick.js +82 -82
  98. package/src/stages/scan.js +558 -558
  99. package/src/stages/status.js +65 -65
  100. package/src/stages/verify.js +322 -322
  101. package/src/sync.js +497 -497
  102. package/src/task-review.js +346 -346
  103. package/src/workflow.js +785 -785
  104. package/src/worktree-apply.js +549 -549
  105. package/src/worktree-deps.js +185 -0
  106. package/src/worktree.js +982 -932
  107. package/templates/workflows/archive-impact.yaml +79 -79
  108. package/templates/workflows/scan-docs.yaml +132 -132
  109. package/test/brainstorm-plan-contract.test.mjs +273 -273
  110. package/test/check-syntax.mjs +26 -26
  111. package/test/contract-artifacts.test.mjs +323 -323
  112. package/test/decision-supersede.test.mjs +277 -277
  113. package/test/knowledge-match.test.mjs +231 -231
  114. package/test/plan-execute-contract.test.mjs +330 -330
  115. package/test/plan-optimization.test.mjs +572 -572
  116. package/test/platform-artifacts.test.mjs +166 -166
  117. package/test/platform-failure-samples.test.mjs +199 -199
  118. package/test/platform-recovery-chain.test.mjs +167 -167
  119. package/test/platform-recovery.test.mjs +136 -136
  120. package/test/platform-scan-p0.test.mjs +168 -168
  121. package/test/revision-v1.test.mjs +1145 -1145
  122. package/test/run-scan-project-parse.test.mjs +200 -200
  123. package/test/run-tests.mjs +48 -48
  124. package/test/scan-knowledge.test.mjs +175 -175
  125. package/test/scan-paths.test.mjs +68 -68
  126. package/test/scan-postcheck.test.mjs +197 -197
  127. package/test/spec-dir.test.mjs +206 -206
  128. package/test/stage-contract.test.mjs +299 -299
  129. package/test/stage-definitions.test.mjs +39 -39
  130. package/test/wait-gates.test.mjs +496 -496
  131. package/test/worktree-deps-provision.test.mjs +148 -0
  132. package/test/worktree-guard.test.mjs +71 -71
  133. package/test/worktree-native-overlay.test.mjs +188 -188
@@ -1,588 +1,588 @@
1
- import { createServer } from 'http'
2
- import { WebSocketServer } from 'ws'
3
- import { existsSync, readFileSync, readdirSync, realpathSync, watch } from 'fs'
4
- import { join, dirname, basename, sep, resolve, relative } from 'path'
5
- import { fileURLToPath } from 'url'
6
- import { homedir } from 'os'
7
- import open from 'open'
8
- import { parseProjectState, parseSillyspecDocsTree, parseProjectOverview, parseGitDetail, parseTechStackDetail } from './parser.js'
9
- import { startWatcher, stopWatcher, addCustomScanPath, removeCustomScanPath, getCustomScanPaths, customScanPaths } from './watcher.js'
10
- import { executeCommand } from './executor.js'
11
-
12
- const __dirname = dirname(fileURLToPath(import.meta.url))
13
-
14
- // WebSocket clients and active processes
15
- let wss = null
16
- const activeProcesses = new Map()
17
- const allowedStages = new Set(['brainstorm', 'plan', 'execute', 'verify', 'scan', 'quick', 'explore', 'archive', 'status', 'doctor', 'auto'])
18
-
19
- function isAllowedCliCommand(command) {
20
- const args = String(command || '').trim().split(/\s+/).filter(Boolean)
21
- if (args.length === 0) return false
22
- if (args[0] === 'run') {
23
- if (!allowedStages.has(args[1])) return false
24
- const allowedFlags = new Set(['--status', '--reset', '--done', '--skip', '--output', '--input', '--change'])
25
- for (let i = 2; i < args.length; i++) {
26
- if (args[i].startsWith('-') && !allowedFlags.has(args[i])) return false
27
- if (['--output', '--input', '--change'].includes(args[i])) i++
28
- }
29
- return true
30
- }
31
- if (args[0] === 'progress') {
32
- const sub = args[1]
33
- if (!['show', 'status', 'validate', 'reset'].includes(sub)) return false
34
- for (let i = 2; i < args.length; i++) {
35
- if (!['--stage', '--deep'].includes(args[i])) return false
36
- if (args[i] === '--stage') i++
37
- }
38
- return true
39
- }
40
- return false
41
- }
42
-
43
- // Progress file watchers: projectPath -> { watcher, timer, refCount }
44
- const progressWatchers = new Map()
45
-
46
- function startProgressWatch(projectPath) {
47
- if (progressWatchers.has(projectPath)) {
48
- progressWatchers.get(projectPath).refCount++
49
- return
50
- }
51
- // Watch the SQLite database file for changes (replaces old progress.json watch)
52
- const dbFile = join(projectPath, '.sillyspec', '.runtime', 'sillyspec.db')
53
- if (!existsSync(dbFile)) return
54
-
55
- let timer = null
56
- try {
57
- const watcher = watch(dbFile, (eventType) => {
58
- if (timer) clearTimeout(timer)
59
- timer = setTimeout(() => {
60
- timer = null
61
- try {
62
- const state = parseProjectState(projectPath)
63
- broadcast({ type: 'project:update', data: { path: projectPath, state } })
64
- } catch {}
65
- }, 200)
66
- })
67
- progressWatchers.set(projectPath, { watcher, timer, refCount: 1 })
68
- } catch {}
69
- }
70
-
71
- function stopProgressWatch(projectPath) {
72
- const entry = progressWatchers.get(projectPath)
73
- if (!entry) return
74
- entry.refCount--
75
- if (entry.refCount <= 0) {
76
- entry.watcher.close()
77
- if (entry.timer) clearTimeout(entry.timer)
78
- progressWatchers.delete(projectPath)
79
- }
80
- }
81
-
82
- /**
83
- * Broadcast message to all connected WebSocket clients
84
- * @param {object} data - Data to broadcast
85
- */
86
- function broadcast(data) {
87
- if (!wss) return
88
- const message = JSON.stringify(data)
89
- wss.clients.forEach(client => {
90
- if (client.readyState === 1) { // OPEN
91
- client.send(message)
92
- }
93
- })
94
- }
95
-
96
- function isInside(parent, child) {
97
- const rel = relative(resolve(parent), resolve(child))
98
- return rel === '' || (!!rel && !rel.startsWith('..') && !rel.includes(`..${sep}`))
99
- }
100
-
101
- function isSillyspecPath(filePath) {
102
- const parts = resolve(filePath).split(/[\\/]+/)
103
- return parts.includes('.sillyspec')
104
- }
105
-
106
- function isViewableDocPath(filePath) {
107
- return /\.(md|markdown|mdx|html?|txt|log|json|ya?ml|toml|xml|csv)$/i.test(filePath)
108
- }
109
-
110
- function isLocalOrigin(origin) {
111
- if (!origin) return true
112
- try {
113
- const { hostname } = new URL(origin)
114
- return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
115
- } catch {
116
- return false
117
- }
118
- }
119
-
120
- // --- Shared scan logic (aligned with watcher.js) ---
121
-
122
- const excludeDirs = new Set([
123
- '.Trash', '.cache', '.npm', '.local', '.vscode', 'Library',
124
- '.git', 'node_modules', '.Trash-*', '.DS_Store', '.config',
125
- '.cocoapods', '.gem', '.rvm', '.nvm', '.asdf', '.brew',
126
- 'AppData', 'Application Data', '.cargo', '.rustup',
127
- '.nuget', '.android', '.gradle', '.m2', '.vscode-server'
128
- ])
129
-
130
- function shouldExclude(name, cwd) {
131
- if (excludeDirs.has(name)) return true
132
- for (const pattern of excludeDirs) {
133
- if (pattern.includes('*')) {
134
- const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
135
- if (regex.test(name)) return true
136
- }
137
- }
138
- const cwdName = cwd.split(sep).pop() || cwd.split('/').pop() || ''
139
- if (name.startsWith('.') && name !== cwdName) return true
140
- return false
141
- }
142
-
143
- function scanDirectory(baseDir, seen, maxDepth = 2, currentDepth = 0) {
144
- const cwd = process.cwd()
145
- const projects = []
146
- try {
147
- const entries = readdirSync(baseDir, { withFileTypes: true })
148
- for (const entry of entries) {
149
- if (!entry.isDirectory()) continue
150
- if (shouldExclude(entry.name, cwd)) continue
151
- const dirPath = join(baseDir, entry.name)
152
- let realPath
153
- try { realPath = realpathSync(dirPath) } catch { realPath = dirPath }
154
- const normalizedPath = realPath.toLowerCase()
155
- if (seen.has(normalizedPath)) continue
156
- seen.add(normalizedPath)
157
- if (existsSync(join(dirPath, '.sillyspec'))) {
158
- projects.push({ name: entry.name, path: dirPath })
159
- }
160
- if (currentDepth < maxDepth) {
161
- projects.push(...scanDirectory(dirPath, seen, maxDepth, currentDepth + 1))
162
- }
163
- }
164
- } catch (err) {}
165
- return projects
166
- }
167
-
168
- /**
169
- * Discover all SillySpec projects
170
- * @returns {Promise<Array>} Array of project objects
171
- */
172
- async function discoverProjects() {
173
- const home = homedir()
174
- const cwd = process.cwd()
175
-
176
- const scanDirs = new Set()
177
- scanDirs.add(cwd)
178
- scanDirs.add(dirname(cwd))
179
- scanDirs.add(dirname(dirname(cwd)))
180
- scanDirs.add(home)
181
-
182
- const extraDirs = [
183
- 'Desktop', '桌面', 'Documents', '文档', 'Downloads', '下载',
184
- 'Projects', '项目', 'Work', '工作', 'Repos', 'Code', 'src', 'dev',
185
- 'workspace', '工作区'
186
- ]
187
-
188
- for (const extra of extraDirs) {
189
- const extraPath = join(home, extra)
190
- if (existsSync(extraPath)) scanDirs.add(extraPath)
191
- }
192
-
193
- for (const customPath of customScanPaths) {
194
- if (existsSync(customPath)) scanDirs.add(customPath)
195
- }
196
-
197
- const seen = new Set()
198
- const projects = []
199
-
200
- // Normalize cwd for dedup
201
- let cwdReal
202
- try { cwdReal = realpathSync(cwd) } catch { cwdReal = cwd }
203
- seen.add(cwdReal.toLowerCase())
204
- if (existsSync(join(cwd, '.sillyspec'))) {
205
- projects.push({ name: basename(cwd), path: cwd })
206
- }
207
-
208
- for (const baseDir of scanDirs) {
209
- projects.push(...scanDirectory(baseDir, seen, 2, 0))
210
- }
211
-
212
- return projects
213
- }
214
-
215
- /**
216
- * Handle WebSocket message for CLI execution
217
- * @param {object} ws - WebSocket client
218
- * @param {object} data - Message data
219
- */
220
- function handleCliExecute(ws, data) {
221
- const { projectName, command } = data
222
-
223
- if (!projectName || !command) {
224
- ws.send(JSON.stringify({
225
- type: 'cli:error',
226
- data: { message: 'Missing projectName or command' }
227
- }))
228
- return
229
- }
230
-
231
- if (!isAllowedCliCommand(command)) {
232
- ws.send(JSON.stringify({
233
- type: 'cli:error',
234
- data: { message: `Command not allowed: ${command}` }
235
- }))
236
- return
237
- }
238
-
239
- discoverProjects().then(projects => {
240
- const project = projects.find(p => p.name === projectName)
241
- if (!project) {
242
- ws.send(JSON.stringify({
243
- type: 'cli:error',
244
- data: { message: `Project not found: ${projectName}` }
245
- }))
246
- return
247
- }
248
-
249
- const existingKill = activeProcesses.get(projectName)
250
- if (existingKill) existingKill()
251
-
252
- const kill = executeCommand(
253
- project.path,
254
- command,
255
- (output) => {
256
- broadcast({
257
- type: 'cli:output',
258
- data: { projectName, output: output.data, outputType: output.type }
259
- })
260
- },
261
- (result) => {
262
- activeProcesses.delete(projectName)
263
- broadcast({
264
- type: 'cli:complete',
265
- data: { projectName, exitCode: result.code, signal: result.signal }
266
- })
267
- }
268
- )
269
-
270
- activeProcesses.set(projectName, kill)
271
- broadcast({ type: 'cli:started', data: { projectName, command } })
272
- })
273
- }
274
-
275
- /**
276
- * Start the dashboard server
277
- * @param {object} options - Server options
278
- * @returns {object} HTTP server instance
279
- */
280
- function startServer({ port = 3456, open: openBrowser = true } = {}) {
281
- const server = createServer((req, res) => {
282
- const origin = req.headers.origin
283
- if (!isLocalOrigin(origin)) {
284
- res.writeHead(403)
285
- res.end('Forbidden')
286
- return
287
- }
288
- res.setHeader('Access-Control-Allow-Origin', origin || `http://127.0.0.1:${port}`)
289
- res.setHeader('Vary', 'Origin')
290
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
291
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
292
-
293
- if (req.method === 'OPTIONS') {
294
- res.writeHead(200)
295
- res.end()
296
- return
297
- }
298
-
299
- if (req.url === '/api/projects') {
300
- discoverProjects().then(projects => {
301
- res.setHeader('Content-Type', 'application/json')
302
- res.writeHead(200)
303
- res.end(JSON.stringify(projects))
304
- }).catch(err => {
305
- res.writeHead(500)
306
- res.end(JSON.stringify({ error: err.message }))
307
- })
308
- return
309
- }
310
-
311
- // Detail API
312
- const detailMatch = req.url?.match(/^\/api\/projects\/(.+)\/detail(\?|$)/)
313
- if (detailMatch) {
314
- const projectPath = decodeURIComponent(detailMatch[1])
315
- const url = new URL(req.url, `http://${req.headers.host}`)
316
- const type = url.searchParams.get('type')
317
- let data
318
- try {
319
- if (type === 'git') data = parseGitDetail(projectPath)
320
- else if (type === 'tech') data = parseTechStackDetail(projectPath)
321
- else if (type === 'docs') data = parseSillyspecDocsTree(projectPath).groups
322
- else { res.writeHead(400); res.end(JSON.stringify({ error: 'Invalid type' })); return }
323
- res.setHeader('Content-Type', 'application/json')
324
- res.writeHead(200)
325
- res.end(JSON.stringify(data))
326
- } catch (err) {
327
- res.writeHead(500)
328
- res.end(JSON.stringify({ error: err.message }))
329
- }
330
- return
331
- }
332
-
333
- // Overview API
334
- if (req.url?.startsWith('/api/projects/') && req.url.endsWith('/overview')) {
335
- const parts = req.url.replace('/api/projects/', '').replace('/overview', '').split('/')
336
- const projectPath = decodeURIComponent(parts.join('/'))
337
- try {
338
- const overview = parseProjectOverview(projectPath)
339
- res.setHeader('Content-Type', 'application/json')
340
- res.writeHead(200)
341
- res.end(JSON.stringify(overview))
342
- } catch (err) {
343
- res.writeHead(500)
344
- res.end(JSON.stringify({ error: err.message }))
345
- }
346
- return
347
- }
348
-
349
- if (req.url?.startsWith('/api/project/')) {
350
- const projectName = decodeURIComponent(req.url.split('/').pop())
351
- discoverProjects().then(projects => {
352
- const project = projects.find(p => p.name === projectName)
353
- if (project) {
354
- const state = parseProjectState(project.path)
355
- res.setHeader('Content-Type', 'application/json')
356
- res.writeHead(200)
357
- res.end(JSON.stringify({ ...project, state }))
358
- } else {
359
- res.writeHead(404)
360
- res.end(JSON.stringify({ error: 'Project not found' }))
361
- }
362
- }).catch(err => {
363
- res.writeHead(500)
364
- res.end(JSON.stringify({ error: err.message }))
365
- })
366
- return
367
- }
368
-
369
- // Docs API
370
- if (req.url?.startsWith('/api/docs/content')) {
371
- const url = new URL(req.url, `http://${req.headers.host}`)
372
- const filePath = url.searchParams.get('path')
373
- if (!filePath) {
374
- res.writeHead(400)
375
- res.end(JSON.stringify({ error: 'Missing path parameter' }))
376
- return
377
- }
378
- // Security: only allow reading viewable text documents under a .sillyspec tree.
379
- const normalizedPath = resolve(filePath)
380
- if (!isSillyspecPath(normalizedPath) || !isViewableDocPath(normalizedPath)) {
381
- res.writeHead(403)
382
- res.end(JSON.stringify({ error: 'Access denied' }))
383
- return
384
- }
385
- if (existsSync(normalizedPath)) {
386
- try {
387
- const content = readFileSync(normalizedPath, 'utf-8')
388
- res.setHeader('Content-Type', 'text/plain; charset=utf-8')
389
- res.writeHead(200)
390
- res.end(content)
391
- } catch (err) {
392
- res.writeHead(500)
393
- res.end(JSON.stringify({ error: err.message }))
394
- }
395
- } else {
396
- res.writeHead(404)
397
- res.end(JSON.stringify({ error: 'File not found' }))
398
- }
399
- return
400
- }
401
-
402
- if (req.url?.startsWith('/api/docs')) {
403
- const url = new URL(req.url, `http://${req.headers.host}`)
404
- const projectPath = url.searchParams.get('project')
405
- if (!projectPath) {
406
- res.writeHead(400)
407
- res.end(JSON.stringify({ error: 'Missing project parameter' }))
408
- return
409
- }
410
- const docs = parseSillyspecDocsTree(projectPath)
411
- res.setHeader('Content-Type', 'application/json')
412
- res.writeHead(200)
413
- res.end(JSON.stringify(docs))
414
- return
415
- }
416
-
417
- // Serve static files (dist/)
418
- const distDir = join(__dirname, '../dist')
419
- const indexPath = join(distDir, 'index.html')
420
- if (existsSync(distDir)) {
421
- const url = new URL(req.url || '/', `http://${req.headers.host}`)
422
- const requestPath = decodeURIComponent(url.pathname)
423
- const filePath = resolve(distDir, requestPath === '/' ? 'index.html' : `.${requestPath}`)
424
- if (!isInside(distDir, filePath)) {
425
- res.writeHead(403)
426
- res.end('Access denied')
427
- return
428
- }
429
- if (existsSync(filePath)) {
430
- const ext = filePath.split('.').pop()
431
- const mimeTypes = { html: 'text/html', js: 'application/javascript', css: 'text/css', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg' }
432
- res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream')
433
- res.writeHead(200)
434
- res.end(readFileSync(filePath))
435
- return
436
- }
437
- if (existsSync(indexPath)) {
438
- res.setHeader('Content-Type', 'text/html')
439
- res.writeHead(200)
440
- res.end(readFileSync(indexPath, 'utf8'))
441
- return
442
- }
443
- }
444
-
445
- res.writeHead(404)
446
- res.end('Not found')
447
- })
448
-
449
- wss = new WebSocketServer({ server })
450
-
451
- wss.on('error', (err) => {
452
- console.error('WebSocket server error:', err)
453
- })
454
-
455
- wss.on('connection', (ws, req) => {
456
- if (!isLocalOrigin(req.headers.origin)) {
457
- ws.close(1008, 'Forbidden origin')
458
- return
459
- }
460
- console.log('WebSocket client connected')
461
-
462
- // Send initial projects list
463
- discoverProjects().then(projects => {
464
- const projectsWithState = projects.map(p => ({
465
- ...p,
466
- state: parseProjectState(p.path),
467
- overview: parseProjectOverview(p.path)
468
- }))
469
-
470
- ws.send(JSON.stringify({
471
- type: 'projects:init',
472
- data: projectsWithState
473
- }))
474
-
475
- // Start watching progress files for all discovered projects
476
- for (const p of projectsWithState) {
477
- startProgressWatch(p.path)
478
- }
479
-
480
- // Send current scan paths
481
- ws.send(JSON.stringify({
482
- type: 'scan:paths',
483
- data: getCustomScanPaths()
484
- }))
485
- }).catch(err => {
486
- console.error('Error sending initial projects:', err)
487
- })
488
-
489
- ws.on('message', (message) => {
490
- try {
491
- const data = JSON.parse(message)
492
-
493
- switch (data.type) {
494
- case 'cli:execute':
495
- handleCliExecute(ws, data.data)
496
- break
497
- case 'cli:kill':
498
- const kill = activeProcesses.get(data.data.projectName)
499
- if (kill) {
500
- kill()
501
- activeProcesses.delete(data.data.projectName)
502
- broadcast({ type: 'cli:killed', data: { projectName: data.data.projectName } })
503
- }
504
- break
505
- case 'scan:add-path':
506
- if (data.data?.path) {
507
- addCustomScanPath(data.data.path)
508
- broadcast({ type: 'scan:paths', data: getCustomScanPaths() })
509
- // Resend projects after scan
510
- discoverProjects().then(projects => {
511
- broadcast({
512
- type: 'projects:updated',
513
- data: projects.map(p => ({
514
- ...p,
515
- state: parseProjectState(p.path),
516
- overview: parseProjectOverview(p.path)
517
- }))
518
- })
519
- })
520
- }
521
- break
522
- case 'scan:remove-path':
523
- if (data.data?.path) {
524
- removeCustomScanPath(data.data.path)
525
- ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
526
- }
527
- break
528
- case 'scan:get-paths':
529
- ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
530
- break
531
- case 'docs:get':
532
- if (data.data?.projectPath) {
533
- const docs = parseSillyspecDocsTree(data.data.projectPath)
534
- ws.send(JSON.stringify({ type: 'docs:tree', data: docs }))
535
- }
536
- break
537
- default:
538
- console.log('Unknown message type:', data.type)
539
- }
540
- } catch (err) {
541
- console.error('Error handling message:', err)
542
- }
543
- })
544
-
545
- ws.on('close', () => {
546
- console.log('WebSocket client disconnected')
547
- // Decrement progress watchers — we track which projects this client was watching
548
- // via the initial projects:init. For simplicity, stop all that we started.
549
- // (Other clients will re-start via their connection if still active)
550
- for (const [path] of progressWatchers) {
551
- stopProgressWatch(path)
552
- }
553
- })
554
-
555
- ws.on('error', (err) => {
556
- console.error('WebSocket error:', err)
557
- })
558
- })
559
-
560
- try {
561
- startWatcher((projects) => {
562
- broadcast({ type: 'projects:updated', data: projects })
563
- })
564
- } catch (err) {
565
- console.error('Failed to start file watcher:', err)
566
- }
567
-
568
- server.listen(port, '127.0.0.1', () => {
569
- console.log(`Dashboard server running on http://127.0.0.1:${port}`)
570
- if (openBrowser) {
571
- open(`http://127.0.0.1:${port}`)
572
- }
573
- })
574
-
575
- const shutdown = () => {
576
- stopWatcher()
577
- activeProcesses.forEach(kill => kill())
578
- activeProcesses.clear()
579
- server.close()
580
- }
581
-
582
- process.on('SIGTERM', shutdown)
583
- process.on('SIGINT', shutdown)
584
-
585
- return server
586
- }
587
-
588
- export { startServer, broadcast, discoverProjects }
1
+ import { createServer } from 'http'
2
+ import { WebSocketServer } from 'ws'
3
+ import { existsSync, readFileSync, readdirSync, realpathSync, watch } from 'fs'
4
+ import { join, dirname, basename, sep, resolve, relative } from 'path'
5
+ import { fileURLToPath } from 'url'
6
+ import { homedir } from 'os'
7
+ import open from 'open'
8
+ import { parseProjectState, parseSillyspecDocsTree, parseProjectOverview, parseGitDetail, parseTechStackDetail } from './parser.js'
9
+ import { startWatcher, stopWatcher, addCustomScanPath, removeCustomScanPath, getCustomScanPaths, customScanPaths } from './watcher.js'
10
+ import { executeCommand } from './executor.js'
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url))
13
+
14
+ // WebSocket clients and active processes
15
+ let wss = null
16
+ const activeProcesses = new Map()
17
+ const allowedStages = new Set(['brainstorm', 'plan', 'execute', 'verify', 'scan', 'quick', 'explore', 'archive', 'status', 'doctor', 'auto'])
18
+
19
+ function isAllowedCliCommand(command) {
20
+ const args = String(command || '').trim().split(/\s+/).filter(Boolean)
21
+ if (args.length === 0) return false
22
+ if (args[0] === 'run') {
23
+ if (!allowedStages.has(args[1])) return false
24
+ const allowedFlags = new Set(['--status', '--reset', '--done', '--skip', '--output', '--input', '--change'])
25
+ for (let i = 2; i < args.length; i++) {
26
+ if (args[i].startsWith('-') && !allowedFlags.has(args[i])) return false
27
+ if (['--output', '--input', '--change'].includes(args[i])) i++
28
+ }
29
+ return true
30
+ }
31
+ if (args[0] === 'progress') {
32
+ const sub = args[1]
33
+ if (!['show', 'status', 'validate', 'reset'].includes(sub)) return false
34
+ for (let i = 2; i < args.length; i++) {
35
+ if (!['--stage', '--deep'].includes(args[i])) return false
36
+ if (args[i] === '--stage') i++
37
+ }
38
+ return true
39
+ }
40
+ return false
41
+ }
42
+
43
+ // Progress file watchers: projectPath -> { watcher, timer, refCount }
44
+ const progressWatchers = new Map()
45
+
46
+ function startProgressWatch(projectPath) {
47
+ if (progressWatchers.has(projectPath)) {
48
+ progressWatchers.get(projectPath).refCount++
49
+ return
50
+ }
51
+ // Watch the SQLite database file for changes (replaces old progress.json watch)
52
+ const dbFile = join(projectPath, '.sillyspec', '.runtime', 'sillyspec.db')
53
+ if (!existsSync(dbFile)) return
54
+
55
+ let timer = null
56
+ try {
57
+ const watcher = watch(dbFile, (eventType) => {
58
+ if (timer) clearTimeout(timer)
59
+ timer = setTimeout(() => {
60
+ timer = null
61
+ try {
62
+ const state = parseProjectState(projectPath)
63
+ broadcast({ type: 'project:update', data: { path: projectPath, state } })
64
+ } catch {}
65
+ }, 200)
66
+ })
67
+ progressWatchers.set(projectPath, { watcher, timer, refCount: 1 })
68
+ } catch {}
69
+ }
70
+
71
+ function stopProgressWatch(projectPath) {
72
+ const entry = progressWatchers.get(projectPath)
73
+ if (!entry) return
74
+ entry.refCount--
75
+ if (entry.refCount <= 0) {
76
+ entry.watcher.close()
77
+ if (entry.timer) clearTimeout(entry.timer)
78
+ progressWatchers.delete(projectPath)
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Broadcast message to all connected WebSocket clients
84
+ * @param {object} data - Data to broadcast
85
+ */
86
+ function broadcast(data) {
87
+ if (!wss) return
88
+ const message = JSON.stringify(data)
89
+ wss.clients.forEach(client => {
90
+ if (client.readyState === 1) { // OPEN
91
+ client.send(message)
92
+ }
93
+ })
94
+ }
95
+
96
+ function isInside(parent, child) {
97
+ const rel = relative(resolve(parent), resolve(child))
98
+ return rel === '' || (!!rel && !rel.startsWith('..') && !rel.includes(`..${sep}`))
99
+ }
100
+
101
+ function isSillyspecPath(filePath) {
102
+ const parts = resolve(filePath).split(/[\\/]+/)
103
+ return parts.includes('.sillyspec')
104
+ }
105
+
106
+ function isViewableDocPath(filePath) {
107
+ return /\.(md|markdown|mdx|html?|txt|log|json|ya?ml|toml|xml|csv)$/i.test(filePath)
108
+ }
109
+
110
+ function isLocalOrigin(origin) {
111
+ if (!origin) return true
112
+ try {
113
+ const { hostname } = new URL(origin)
114
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+
120
+ // --- Shared scan logic (aligned with watcher.js) ---
121
+
122
+ const excludeDirs = new Set([
123
+ '.Trash', '.cache', '.npm', '.local', '.vscode', 'Library',
124
+ '.git', 'node_modules', '.Trash-*', '.DS_Store', '.config',
125
+ '.cocoapods', '.gem', '.rvm', '.nvm', '.asdf', '.brew',
126
+ 'AppData', 'Application Data', '.cargo', '.rustup',
127
+ '.nuget', '.android', '.gradle', '.m2', '.vscode-server'
128
+ ])
129
+
130
+ function shouldExclude(name, cwd) {
131
+ if (excludeDirs.has(name)) return true
132
+ for (const pattern of excludeDirs) {
133
+ if (pattern.includes('*')) {
134
+ const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
135
+ if (regex.test(name)) return true
136
+ }
137
+ }
138
+ const cwdName = cwd.split(sep).pop() || cwd.split('/').pop() || ''
139
+ if (name.startsWith('.') && name !== cwdName) return true
140
+ return false
141
+ }
142
+
143
+ function scanDirectory(baseDir, seen, maxDepth = 2, currentDepth = 0) {
144
+ const cwd = process.cwd()
145
+ const projects = []
146
+ try {
147
+ const entries = readdirSync(baseDir, { withFileTypes: true })
148
+ for (const entry of entries) {
149
+ if (!entry.isDirectory()) continue
150
+ if (shouldExclude(entry.name, cwd)) continue
151
+ const dirPath = join(baseDir, entry.name)
152
+ let realPath
153
+ try { realPath = realpathSync(dirPath) } catch { realPath = dirPath }
154
+ const normalizedPath = realPath.toLowerCase()
155
+ if (seen.has(normalizedPath)) continue
156
+ seen.add(normalizedPath)
157
+ if (existsSync(join(dirPath, '.sillyspec'))) {
158
+ projects.push({ name: entry.name, path: dirPath })
159
+ }
160
+ if (currentDepth < maxDepth) {
161
+ projects.push(...scanDirectory(dirPath, seen, maxDepth, currentDepth + 1))
162
+ }
163
+ }
164
+ } catch (err) {}
165
+ return projects
166
+ }
167
+
168
+ /**
169
+ * Discover all SillySpec projects
170
+ * @returns {Promise<Array>} Array of project objects
171
+ */
172
+ async function discoverProjects() {
173
+ const home = homedir()
174
+ const cwd = process.cwd()
175
+
176
+ const scanDirs = new Set()
177
+ scanDirs.add(cwd)
178
+ scanDirs.add(dirname(cwd))
179
+ scanDirs.add(dirname(dirname(cwd)))
180
+ scanDirs.add(home)
181
+
182
+ const extraDirs = [
183
+ 'Desktop', '桌面', 'Documents', '文档', 'Downloads', '下载',
184
+ 'Projects', '项目', 'Work', '工作', 'Repos', 'Code', 'src', 'dev',
185
+ 'workspace', '工作区'
186
+ ]
187
+
188
+ for (const extra of extraDirs) {
189
+ const extraPath = join(home, extra)
190
+ if (existsSync(extraPath)) scanDirs.add(extraPath)
191
+ }
192
+
193
+ for (const customPath of customScanPaths) {
194
+ if (existsSync(customPath)) scanDirs.add(customPath)
195
+ }
196
+
197
+ const seen = new Set()
198
+ const projects = []
199
+
200
+ // Normalize cwd for dedup
201
+ let cwdReal
202
+ try { cwdReal = realpathSync(cwd) } catch { cwdReal = cwd }
203
+ seen.add(cwdReal.toLowerCase())
204
+ if (existsSync(join(cwd, '.sillyspec'))) {
205
+ projects.push({ name: basename(cwd), path: cwd })
206
+ }
207
+
208
+ for (const baseDir of scanDirs) {
209
+ projects.push(...scanDirectory(baseDir, seen, 2, 0))
210
+ }
211
+
212
+ return projects
213
+ }
214
+
215
+ /**
216
+ * Handle WebSocket message for CLI execution
217
+ * @param {object} ws - WebSocket client
218
+ * @param {object} data - Message data
219
+ */
220
+ function handleCliExecute(ws, data) {
221
+ const { projectName, command } = data
222
+
223
+ if (!projectName || !command) {
224
+ ws.send(JSON.stringify({
225
+ type: 'cli:error',
226
+ data: { message: 'Missing projectName or command' }
227
+ }))
228
+ return
229
+ }
230
+
231
+ if (!isAllowedCliCommand(command)) {
232
+ ws.send(JSON.stringify({
233
+ type: 'cli:error',
234
+ data: { message: `Command not allowed: ${command}` }
235
+ }))
236
+ return
237
+ }
238
+
239
+ discoverProjects().then(projects => {
240
+ const project = projects.find(p => p.name === projectName)
241
+ if (!project) {
242
+ ws.send(JSON.stringify({
243
+ type: 'cli:error',
244
+ data: { message: `Project not found: ${projectName}` }
245
+ }))
246
+ return
247
+ }
248
+
249
+ const existingKill = activeProcesses.get(projectName)
250
+ if (existingKill) existingKill()
251
+
252
+ const kill = executeCommand(
253
+ project.path,
254
+ command,
255
+ (output) => {
256
+ broadcast({
257
+ type: 'cli:output',
258
+ data: { projectName, output: output.data, outputType: output.type }
259
+ })
260
+ },
261
+ (result) => {
262
+ activeProcesses.delete(projectName)
263
+ broadcast({
264
+ type: 'cli:complete',
265
+ data: { projectName, exitCode: result.code, signal: result.signal }
266
+ })
267
+ }
268
+ )
269
+
270
+ activeProcesses.set(projectName, kill)
271
+ broadcast({ type: 'cli:started', data: { projectName, command } })
272
+ })
273
+ }
274
+
275
+ /**
276
+ * Start the dashboard server
277
+ * @param {object} options - Server options
278
+ * @returns {object} HTTP server instance
279
+ */
280
+ function startServer({ port = 3456, open: openBrowser = true } = {}) {
281
+ const server = createServer((req, res) => {
282
+ const origin = req.headers.origin
283
+ if (!isLocalOrigin(origin)) {
284
+ res.writeHead(403)
285
+ res.end('Forbidden')
286
+ return
287
+ }
288
+ res.setHeader('Access-Control-Allow-Origin', origin || `http://127.0.0.1:${port}`)
289
+ res.setHeader('Vary', 'Origin')
290
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
291
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
292
+
293
+ if (req.method === 'OPTIONS') {
294
+ res.writeHead(200)
295
+ res.end()
296
+ return
297
+ }
298
+
299
+ if (req.url === '/api/projects') {
300
+ discoverProjects().then(projects => {
301
+ res.setHeader('Content-Type', 'application/json')
302
+ res.writeHead(200)
303
+ res.end(JSON.stringify(projects))
304
+ }).catch(err => {
305
+ res.writeHead(500)
306
+ res.end(JSON.stringify({ error: err.message }))
307
+ })
308
+ return
309
+ }
310
+
311
+ // Detail API
312
+ const detailMatch = req.url?.match(/^\/api\/projects\/(.+)\/detail(\?|$)/)
313
+ if (detailMatch) {
314
+ const projectPath = decodeURIComponent(detailMatch[1])
315
+ const url = new URL(req.url, `http://${req.headers.host}`)
316
+ const type = url.searchParams.get('type')
317
+ let data
318
+ try {
319
+ if (type === 'git') data = parseGitDetail(projectPath)
320
+ else if (type === 'tech') data = parseTechStackDetail(projectPath)
321
+ else if (type === 'docs') data = parseSillyspecDocsTree(projectPath).groups
322
+ else { res.writeHead(400); res.end(JSON.stringify({ error: 'Invalid type' })); return }
323
+ res.setHeader('Content-Type', 'application/json')
324
+ res.writeHead(200)
325
+ res.end(JSON.stringify(data))
326
+ } catch (err) {
327
+ res.writeHead(500)
328
+ res.end(JSON.stringify({ error: err.message }))
329
+ }
330
+ return
331
+ }
332
+
333
+ // Overview API
334
+ if (req.url?.startsWith('/api/projects/') && req.url.endsWith('/overview')) {
335
+ const parts = req.url.replace('/api/projects/', '').replace('/overview', '').split('/')
336
+ const projectPath = decodeURIComponent(parts.join('/'))
337
+ try {
338
+ const overview = parseProjectOverview(projectPath)
339
+ res.setHeader('Content-Type', 'application/json')
340
+ res.writeHead(200)
341
+ res.end(JSON.stringify(overview))
342
+ } catch (err) {
343
+ res.writeHead(500)
344
+ res.end(JSON.stringify({ error: err.message }))
345
+ }
346
+ return
347
+ }
348
+
349
+ if (req.url?.startsWith('/api/project/')) {
350
+ const projectName = decodeURIComponent(req.url.split('/').pop())
351
+ discoverProjects().then(projects => {
352
+ const project = projects.find(p => p.name === projectName)
353
+ if (project) {
354
+ const state = parseProjectState(project.path)
355
+ res.setHeader('Content-Type', 'application/json')
356
+ res.writeHead(200)
357
+ res.end(JSON.stringify({ ...project, state }))
358
+ } else {
359
+ res.writeHead(404)
360
+ res.end(JSON.stringify({ error: 'Project not found' }))
361
+ }
362
+ }).catch(err => {
363
+ res.writeHead(500)
364
+ res.end(JSON.stringify({ error: err.message }))
365
+ })
366
+ return
367
+ }
368
+
369
+ // Docs API
370
+ if (req.url?.startsWith('/api/docs/content')) {
371
+ const url = new URL(req.url, `http://${req.headers.host}`)
372
+ const filePath = url.searchParams.get('path')
373
+ if (!filePath) {
374
+ res.writeHead(400)
375
+ res.end(JSON.stringify({ error: 'Missing path parameter' }))
376
+ return
377
+ }
378
+ // Security: only allow reading viewable text documents under a .sillyspec tree.
379
+ const normalizedPath = resolve(filePath)
380
+ if (!isSillyspecPath(normalizedPath) || !isViewableDocPath(normalizedPath)) {
381
+ res.writeHead(403)
382
+ res.end(JSON.stringify({ error: 'Access denied' }))
383
+ return
384
+ }
385
+ if (existsSync(normalizedPath)) {
386
+ try {
387
+ const content = readFileSync(normalizedPath, 'utf-8')
388
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8')
389
+ res.writeHead(200)
390
+ res.end(content)
391
+ } catch (err) {
392
+ res.writeHead(500)
393
+ res.end(JSON.stringify({ error: err.message }))
394
+ }
395
+ } else {
396
+ res.writeHead(404)
397
+ res.end(JSON.stringify({ error: 'File not found' }))
398
+ }
399
+ return
400
+ }
401
+
402
+ if (req.url?.startsWith('/api/docs')) {
403
+ const url = new URL(req.url, `http://${req.headers.host}`)
404
+ const projectPath = url.searchParams.get('project')
405
+ if (!projectPath) {
406
+ res.writeHead(400)
407
+ res.end(JSON.stringify({ error: 'Missing project parameter' }))
408
+ return
409
+ }
410
+ const docs = parseSillyspecDocsTree(projectPath)
411
+ res.setHeader('Content-Type', 'application/json')
412
+ res.writeHead(200)
413
+ res.end(JSON.stringify(docs))
414
+ return
415
+ }
416
+
417
+ // Serve static files (dist/)
418
+ const distDir = join(__dirname, '../dist')
419
+ const indexPath = join(distDir, 'index.html')
420
+ if (existsSync(distDir)) {
421
+ const url = new URL(req.url || '/', `http://${req.headers.host}`)
422
+ const requestPath = decodeURIComponent(url.pathname)
423
+ const filePath = resolve(distDir, requestPath === '/' ? 'index.html' : `.${requestPath}`)
424
+ if (!isInside(distDir, filePath)) {
425
+ res.writeHead(403)
426
+ res.end('Access denied')
427
+ return
428
+ }
429
+ if (existsSync(filePath)) {
430
+ const ext = filePath.split('.').pop()
431
+ const mimeTypes = { html: 'text/html', js: 'application/javascript', css: 'text/css', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg' }
432
+ res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream')
433
+ res.writeHead(200)
434
+ res.end(readFileSync(filePath))
435
+ return
436
+ }
437
+ if (existsSync(indexPath)) {
438
+ res.setHeader('Content-Type', 'text/html')
439
+ res.writeHead(200)
440
+ res.end(readFileSync(indexPath, 'utf8'))
441
+ return
442
+ }
443
+ }
444
+
445
+ res.writeHead(404)
446
+ res.end('Not found')
447
+ })
448
+
449
+ wss = new WebSocketServer({ server })
450
+
451
+ wss.on('error', (err) => {
452
+ console.error('WebSocket server error:', err)
453
+ })
454
+
455
+ wss.on('connection', (ws, req) => {
456
+ if (!isLocalOrigin(req.headers.origin)) {
457
+ ws.close(1008, 'Forbidden origin')
458
+ return
459
+ }
460
+ console.log('WebSocket client connected')
461
+
462
+ // Send initial projects list
463
+ discoverProjects().then(projects => {
464
+ const projectsWithState = projects.map(p => ({
465
+ ...p,
466
+ state: parseProjectState(p.path),
467
+ overview: parseProjectOverview(p.path)
468
+ }))
469
+
470
+ ws.send(JSON.stringify({
471
+ type: 'projects:init',
472
+ data: projectsWithState
473
+ }))
474
+
475
+ // Start watching progress files for all discovered projects
476
+ for (const p of projectsWithState) {
477
+ startProgressWatch(p.path)
478
+ }
479
+
480
+ // Send current scan paths
481
+ ws.send(JSON.stringify({
482
+ type: 'scan:paths',
483
+ data: getCustomScanPaths()
484
+ }))
485
+ }).catch(err => {
486
+ console.error('Error sending initial projects:', err)
487
+ })
488
+
489
+ ws.on('message', (message) => {
490
+ try {
491
+ const data = JSON.parse(message)
492
+
493
+ switch (data.type) {
494
+ case 'cli:execute':
495
+ handleCliExecute(ws, data.data)
496
+ break
497
+ case 'cli:kill':
498
+ const kill = activeProcesses.get(data.data.projectName)
499
+ if (kill) {
500
+ kill()
501
+ activeProcesses.delete(data.data.projectName)
502
+ broadcast({ type: 'cli:killed', data: { projectName: data.data.projectName } })
503
+ }
504
+ break
505
+ case 'scan:add-path':
506
+ if (data.data?.path) {
507
+ addCustomScanPath(data.data.path)
508
+ broadcast({ type: 'scan:paths', data: getCustomScanPaths() })
509
+ // Resend projects after scan
510
+ discoverProjects().then(projects => {
511
+ broadcast({
512
+ type: 'projects:updated',
513
+ data: projects.map(p => ({
514
+ ...p,
515
+ state: parseProjectState(p.path),
516
+ overview: parseProjectOverview(p.path)
517
+ }))
518
+ })
519
+ })
520
+ }
521
+ break
522
+ case 'scan:remove-path':
523
+ if (data.data?.path) {
524
+ removeCustomScanPath(data.data.path)
525
+ ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
526
+ }
527
+ break
528
+ case 'scan:get-paths':
529
+ ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
530
+ break
531
+ case 'docs:get':
532
+ if (data.data?.projectPath) {
533
+ const docs = parseSillyspecDocsTree(data.data.projectPath)
534
+ ws.send(JSON.stringify({ type: 'docs:tree', data: docs }))
535
+ }
536
+ break
537
+ default:
538
+ console.log('Unknown message type:', data.type)
539
+ }
540
+ } catch (err) {
541
+ console.error('Error handling message:', err)
542
+ }
543
+ })
544
+
545
+ ws.on('close', () => {
546
+ console.log('WebSocket client disconnected')
547
+ // Decrement progress watchers — we track which projects this client was watching
548
+ // via the initial projects:init. For simplicity, stop all that we started.
549
+ // (Other clients will re-start via their connection if still active)
550
+ for (const [path] of progressWatchers) {
551
+ stopProgressWatch(path)
552
+ }
553
+ })
554
+
555
+ ws.on('error', (err) => {
556
+ console.error('WebSocket error:', err)
557
+ })
558
+ })
559
+
560
+ try {
561
+ startWatcher((projects) => {
562
+ broadcast({ type: 'projects:updated', data: projects })
563
+ })
564
+ } catch (err) {
565
+ console.error('Failed to start file watcher:', err)
566
+ }
567
+
568
+ server.listen(port, '127.0.0.1', () => {
569
+ console.log(`Dashboard server running on http://127.0.0.1:${port}`)
570
+ if (openBrowser) {
571
+ open(`http://127.0.0.1:${port}`)
572
+ }
573
+ })
574
+
575
+ const shutdown = () => {
576
+ stopWatcher()
577
+ activeProcesses.forEach(kill => kill())
578
+ activeProcesses.clear()
579
+ server.close()
580
+ }
581
+
582
+ process.on('SIGTERM', shutdown)
583
+ process.on('SIGINT', shutdown)
584
+
585
+ return server
586
+ }
587
+
588
+ export { startServer, broadcast, discoverProjects }