sillyspec 3.26.10 → 3.26.11

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 (97) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +85 -85
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-commit/SKILL.md +109 -109
  4. package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
  5. package/.claude/skills/sillyspec-doctor/SKILL.md +76 -76
  6. package/.claude/skills/sillyspec-execute/SKILL.md +200 -200
  7. package/.claude/skills/sillyspec-explore/SKILL.md +95 -95
  8. package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
  9. package/.claude/skills/sillyspec-plan/SKILL.md +148 -148
  10. package/.claude/skills/sillyspec-quick/SKILL.md +111 -111
  11. package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
  12. package/.claude/skills/sillyspec-scan/SKILL.md +79 -79
  13. package/.claude/skills/sillyspec-state/SKILL.md +64 -64
  14. package/.claude/skills/sillyspec-status/SKILL.md +54 -54
  15. package/.claude/skills/sillyspec-verify/SKILL.md +83 -83
  16. package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
  17. package/.gitattributes +2 -0
  18. package/.husky/pre-push +20 -20
  19. package/.npmrc-publish +2 -0
  20. package/README.md +201 -201
  21. package/SKILL.md +90 -90
  22. package/package.json +39 -39
  23. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  24. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  25. package/packages/dashboard/dist/index.html +16 -16
  26. package/packages/dashboard/package.json +25 -25
  27. package/packages/dashboard/server/executor.js +86 -86
  28. package/packages/dashboard/server/index.js +601 -601
  29. package/packages/dashboard/server/parser.js +526 -526
  30. package/src/change-risk-profile.js +254 -254
  31. package/src/classify-change.js +109 -109
  32. package/src/config-schema.js +310 -310
  33. package/src/contract-matrix.js +379 -379
  34. package/src/db.js +350 -350
  35. package/src/dispatch/backends/local-agent.js +155 -155
  36. package/src/dispatch/backends/sillyhub-mcp.js +245 -245
  37. package/src/dispatch/probe.js +210 -210
  38. package/src/dispatch/strategy.js +121 -121
  39. package/src/docs-check.js +131 -4
  40. package/src/doctor-diagnostics.js +711 -711
  41. package/src/endpoint-extractor.js +401 -401
  42. package/src/fs-atomic.js +69 -69
  43. package/src/git-helper.js +79 -79
  44. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  45. package/src/hooks/worktree-guard.js +848 -848
  46. package/src/index.js +90 -8
  47. package/src/init.js +623 -623
  48. package/src/knowledge-match.js +130 -130
  49. package/src/local-detect.js +120 -120
  50. package/src/machine-interface.js +543 -543
  51. package/src/migrate.js +127 -127
  52. package/src/progress/change-registry.js +297 -260
  53. package/src/progress/consistency-doctor.js +355 -355
  54. package/src/progress/shared.js +36 -36
  55. package/src/progress/stage-machine.js +29 -1
  56. package/src/progress/step-store.js +261 -261
  57. package/src/progress.js +1131 -1127
  58. package/src/quicklog.js +693 -693
  59. package/src/review-tier.js +77 -77
  60. package/src/run/command.js +1385 -1364
  61. package/src/run/complete-handlers.js +1443 -1399
  62. package/src/run/complete.js +225 -24
  63. package/src/run/gates.js +755 -755
  64. package/src/run/prompt.js +761 -761
  65. package/src/run/quick-audit.js +155 -146
  66. package/src/run/shared.js +1268 -1215
  67. package/src/run/stage.js +487 -479
  68. package/src/scan-postcheck.js +371 -371
  69. package/src/setup.js +416 -416
  70. package/src/sillyhub-mcp/client.js +529 -529
  71. package/src/spec-sync.js +323 -319
  72. package/src/stage-contract.js +897 -897
  73. package/src/stage-review.js +549 -549
  74. package/src/stages/archive.js +155 -155
  75. package/src/stages/brainstorm-auto.js +236 -236
  76. package/src/stages/brainstorm.js +596 -597
  77. package/src/stages/cmd-existence.js +145 -145
  78. package/src/stages/doctor.js +371 -371
  79. package/src/stages/execute.js +1003 -1003
  80. package/src/stages/explore.js +34 -34
  81. package/src/stages/index.js +29 -29
  82. package/src/stages/knowledge.js +498 -498
  83. package/src/stages/plan-postcheck.js +1302 -1302
  84. package/src/stages/plan.js +625 -623
  85. package/src/stages/quick.js +151 -151
  86. package/src/stages/scan.js +607 -607
  87. package/src/stages/status.js +64 -64
  88. package/src/stages/verify.js +264 -264
  89. package/src/sync.js +1223 -1135
  90. package/src/task-review.js +1019 -1019
  91. package/src/verify-postcheck.js +1186 -1186
  92. package/src/workflow.js +600 -600
  93. package/src/worktree-apply.js +1213 -1136
  94. package/src/worktree-deps.js +353 -353
  95. package/src/worktree.js +1481 -1481
  96. package/templates/workflows/archive-impact.yaml +79 -79
  97. package/templates/workflows/scan-docs.yaml +132 -132
@@ -1,601 +1,601 @@
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
- // 凭据/敏感运行时文件:local.yaml 含 SillyHub token(connect 明文写入),.plan.md.lock 等锁文件
107
- // 是并发原语非文档。/api/docs/content 无鉴权(仅 Origin 检查),这些文件绝不经 HTTP 暴露。
108
- function isCredentialPath(filePath) {
109
- const base = basename(resolve(filePath)).toLowerCase()
110
- return base === 'local.yaml' || base === 'local.yml' || base.endsWith('.lock') || base === 'sillyspec-platform.json'
111
- }
112
-
113
- function isViewableDocPath(filePath) {
114
- return /\.(md|markdown|mdx|html?|txt|log|json|ya?ml|toml|xml|csv)$/i.test(filePath)
115
- }
116
-
117
- function isLocalOrigin(origin) {
118
- if (!origin) return true
119
- try {
120
- const { hostname } = new URL(origin)
121
- return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
122
- } catch {
123
- return false
124
- }
125
- }
126
-
127
- // --- Shared scan logic (aligned with watcher.js) ---
128
-
129
- const excludeDirs = new Set([
130
- '.Trash', '.cache', '.npm', '.local', '.vscode', 'Library',
131
- '.git', 'node_modules', '.Trash-*', '.DS_Store', '.config',
132
- '.cocoapods', '.gem', '.rvm', '.nvm', '.asdf', '.brew',
133
- 'AppData', 'Application Data', '.cargo', '.rustup',
134
- '.nuget', '.android', '.gradle', '.m2', '.vscode-server'
135
- ])
136
-
137
- function shouldExclude(name, cwd) {
138
- if (excludeDirs.has(name)) return true
139
- for (const pattern of excludeDirs) {
140
- if (pattern.includes('*')) {
141
- const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
142
- if (regex.test(name)) return true
143
- }
144
- }
145
- const cwdName = cwd.split(sep).pop() || cwd.split('/').pop() || ''
146
- if (name.startsWith('.') && name !== cwdName) return true
147
- return false
148
- }
149
-
150
- function scanDirectory(baseDir, seen, maxDepth = 2, currentDepth = 0) {
151
- const cwd = process.cwd()
152
- const projects = []
153
- try {
154
- const entries = readdirSync(baseDir, { withFileTypes: true })
155
- for (const entry of entries) {
156
- if (!entry.isDirectory()) continue
157
- if (shouldExclude(entry.name, cwd)) continue
158
- const dirPath = join(baseDir, entry.name)
159
- let realPath
160
- try { realPath = realpathSync(dirPath) } catch { realPath = dirPath }
161
- const normalizedPath = realPath.toLowerCase()
162
- if (seen.has(normalizedPath)) continue
163
- seen.add(normalizedPath)
164
- if (existsSync(join(dirPath, '.sillyspec'))) {
165
- projects.push({ name: entry.name, path: dirPath })
166
- }
167
- if (currentDepth < maxDepth) {
168
- projects.push(...scanDirectory(dirPath, seen, maxDepth, currentDepth + 1))
169
- }
170
- }
171
- } catch (err) {}
172
- return projects
173
- }
174
-
175
- /**
176
- * Discover all SillySpec projects
177
- * @returns {Promise<Array>} Array of project objects
178
- */
179
- async function discoverProjects() {
180
- const home = homedir()
181
- const cwd = process.cwd()
182
-
183
- const scanDirs = new Set()
184
- scanDirs.add(cwd)
185
- scanDirs.add(dirname(cwd))
186
- scanDirs.add(dirname(dirname(cwd)))
187
- scanDirs.add(home)
188
-
189
- const extraDirs = [
190
- 'Desktop', '桌面', 'Documents', '文档', 'Downloads', '下载',
191
- 'Projects', '项目', 'Work', '工作', 'Repos', 'Code', 'src', 'dev',
192
- 'workspace', '工作区'
193
- ]
194
-
195
- for (const extra of extraDirs) {
196
- const extraPath = join(home, extra)
197
- if (existsSync(extraPath)) scanDirs.add(extraPath)
198
- }
199
-
200
- for (const customPath of customScanPaths) {
201
- if (existsSync(customPath)) scanDirs.add(customPath)
202
- }
203
-
204
- const seen = new Set()
205
- const projects = []
206
-
207
- // Normalize cwd for dedup
208
- let cwdReal
209
- try { cwdReal = realpathSync(cwd) } catch { cwdReal = cwd }
210
- seen.add(cwdReal.toLowerCase())
211
- if (existsSync(join(cwd, '.sillyspec'))) {
212
- projects.push({ name: basename(cwd), path: cwd })
213
- }
214
-
215
- for (const baseDir of scanDirs) {
216
- projects.push(...scanDirectory(baseDir, seen, 2, 0))
217
- }
218
-
219
- return projects
220
- }
221
-
222
- /**
223
- * Handle WebSocket message for CLI execution
224
- * @param {object} ws - WebSocket client
225
- * @param {object} data - Message data
226
- */
227
- function handleCliExecute(ws, data) {
228
- const { projectName, command } = data
229
-
230
- if (!projectName || !command) {
231
- ws.send(JSON.stringify({
232
- type: 'cli:error',
233
- data: { message: 'Missing projectName or command' }
234
- }))
235
- return
236
- }
237
-
238
- if (!isAllowedCliCommand(command)) {
239
- ws.send(JSON.stringify({
240
- type: 'cli:error',
241
- data: { message: `Command not allowed: ${command}` }
242
- }))
243
- return
244
- }
245
-
246
- discoverProjects().then(projects => {
247
- const project = projects.find(p => p.name === projectName)
248
- if (!project) {
249
- ws.send(JSON.stringify({
250
- type: 'cli:error',
251
- data: { message: `Project not found: ${projectName}` }
252
- }))
253
- return
254
- }
255
-
256
- const existingKill = activeProcesses.get(projectName)
257
- if (existingKill) existingKill()
258
-
259
- const kill = executeCommand(
260
- project.path,
261
- command,
262
- (output) => {
263
- broadcast({
264
- type: 'cli:output',
265
- data: { projectName, output: output.data, outputType: output.type }
266
- })
267
- },
268
- (result) => {
269
- activeProcesses.delete(projectName)
270
- broadcast({
271
- type: 'cli:complete',
272
- data: { projectName, exitCode: result.code, signal: result.signal }
273
- })
274
- }
275
- )
276
-
277
- activeProcesses.set(projectName, kill)
278
- broadcast({ type: 'cli:started', data: { projectName, command } })
279
- })
280
- }
281
-
282
- /**
283
- * Start the dashboard server
284
- * @param {object} options - Server options
285
- * @returns {object} HTTP server instance
286
- */
287
- function startServer({ port = 3456, open: openBrowser = true } = {}) {
288
- const server = createServer((req, res) => {
289
- const origin = req.headers.origin
290
- if (!isLocalOrigin(origin)) {
291
- res.writeHead(403)
292
- res.end('Forbidden')
293
- return
294
- }
295
- res.setHeader('Access-Control-Allow-Origin', origin || `http://127.0.0.1:${port}`)
296
- res.setHeader('Vary', 'Origin')
297
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
298
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
299
-
300
- if (req.method === 'OPTIONS') {
301
- res.writeHead(200)
302
- res.end()
303
- return
304
- }
305
-
306
- if (req.url === '/api/projects') {
307
- discoverProjects().then(projects => {
308
- res.setHeader('Content-Type', 'application/json')
309
- res.writeHead(200)
310
- res.end(JSON.stringify(projects))
311
- }).catch(err => {
312
- res.writeHead(500)
313
- res.end(JSON.stringify({ error: err.message }))
314
- })
315
- return
316
- }
317
-
318
- // Detail API
319
- const detailMatch = req.url?.match(/^\/api\/projects\/(.+)\/detail(\?|$)/)
320
- if (detailMatch) {
321
- const projectPath = decodeURIComponent(detailMatch[1])
322
- const url = new URL(req.url, `http://${req.headers.host}`)
323
- const type = url.searchParams.get('type')
324
- let data
325
- try {
326
- if (type === 'git') data = parseGitDetail(projectPath)
327
- else if (type === 'tech') data = parseTechStackDetail(projectPath)
328
- else if (type === 'docs') data = parseSillyspecDocsTree(projectPath).groups
329
- else { res.writeHead(400); res.end(JSON.stringify({ error: 'Invalid type' })); return }
330
- res.setHeader('Content-Type', 'application/json')
331
- res.writeHead(200)
332
- res.end(JSON.stringify(data))
333
- } catch (err) {
334
- res.writeHead(500)
335
- res.end(JSON.stringify({ error: err.message }))
336
- }
337
- return
338
- }
339
-
340
- // Overview API
341
- if (req.url?.startsWith('/api/projects/') && req.url.endsWith('/overview')) {
342
- const parts = req.url.replace('/api/projects/', '').replace('/overview', '').split('/')
343
- const projectPath = decodeURIComponent(parts.join('/'))
344
- try {
345
- const overview = parseProjectOverview(projectPath)
346
- res.setHeader('Content-Type', 'application/json')
347
- res.writeHead(200)
348
- res.end(JSON.stringify(overview))
349
- } catch (err) {
350
- res.writeHead(500)
351
- res.end(JSON.stringify({ error: err.message }))
352
- }
353
- return
354
- }
355
-
356
- if (req.url?.startsWith('/api/project/')) {
357
- const projectName = decodeURIComponent(req.url.split('/').pop())
358
- discoverProjects().then(projects => {
359
- const project = projects.find(p => p.name === projectName)
360
- if (project) {
361
- const state = parseProjectState(project.path)
362
- res.setHeader('Content-Type', 'application/json')
363
- res.writeHead(200)
364
- res.end(JSON.stringify({ ...project, state }))
365
- } else {
366
- res.writeHead(404)
367
- res.end(JSON.stringify({ error: 'Project not found' }))
368
- }
369
- }).catch(err => {
370
- res.writeHead(500)
371
- res.end(JSON.stringify({ error: err.message }))
372
- })
373
- return
374
- }
375
-
376
- // Docs API
377
- if (req.url?.startsWith('/api/docs/content')) {
378
- const url = new URL(req.url, `http://${req.headers.host}`)
379
- const filePath = url.searchParams.get('path')
380
- if (!filePath) {
381
- res.writeHead(400)
382
- res.end(JSON.stringify({ error: 'Missing path parameter' }))
383
- return
384
- }
385
- // Security: only allow reading viewable text documents under a .sillyspec tree.
386
- const normalizedPath = resolve(filePath)
387
- if (!isSillyspecPath(normalizedPath) || !isViewableDocPath(normalizedPath) || isCredentialPath(normalizedPath)) {
388
- res.writeHead(403)
389
- res.end(JSON.stringify({ error: 'Access denied' }))
390
- return
391
- }
392
- if (existsSync(normalizedPath)) {
393
- try {
394
- const content = readFileSync(normalizedPath, 'utf-8')
395
- res.setHeader('Content-Type', 'text/plain; charset=utf-8')
396
- res.writeHead(200)
397
- res.end(content)
398
- } catch (err) {
399
- res.writeHead(500)
400
- res.end(JSON.stringify({ error: err.message }))
401
- }
402
- } else {
403
- res.writeHead(404)
404
- res.end(JSON.stringify({ error: 'File not found' }))
405
- }
406
- return
407
- }
408
-
409
- if (req.url?.startsWith('/api/docs')) {
410
- const url = new URL(req.url, `http://${req.headers.host}`)
411
- const projectPath = url.searchParams.get('project')
412
- if (!projectPath) {
413
- res.writeHead(400)
414
- res.end(JSON.stringify({ error: 'Missing project parameter' }))
415
- return
416
- }
417
- const docs = parseSillyspecDocsTree(projectPath)
418
- res.setHeader('Content-Type', 'application/json')
419
- res.writeHead(200)
420
- res.end(JSON.stringify(docs))
421
- return
422
- }
423
-
424
- // Serve static files (dist/)
425
- const distDir = join(__dirname, '../dist')
426
- const indexPath = join(distDir, 'index.html')
427
- if (existsSync(distDir)) {
428
- const url = new URL(req.url || '/', `http://${req.headers.host}`)
429
- const requestPath = decodeURIComponent(url.pathname)
430
- const filePath = resolve(distDir, requestPath === '/' ? 'index.html' : `.${requestPath}`)
431
- if (!isInside(distDir, filePath)) {
432
- res.writeHead(403)
433
- res.end('Access denied')
434
- return
435
- }
436
- if (existsSync(filePath)) {
437
- const ext = filePath.split('.').pop()
438
- const mimeTypes = { html: 'text/html', js: 'application/javascript', css: 'text/css', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg' }
439
- res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream')
440
- res.writeHead(200)
441
- res.end(readFileSync(filePath))
442
- return
443
- }
444
- if (existsSync(indexPath)) {
445
- res.setHeader('Content-Type', 'text/html')
446
- res.writeHead(200)
447
- res.end(readFileSync(indexPath, 'utf8'))
448
- return
449
- }
450
- }
451
-
452
- res.writeHead(404)
453
- res.end('Not found')
454
- })
455
-
456
- wss = new WebSocketServer({ server })
457
-
458
- wss.on('error', (err) => {
459
- console.error('WebSocket server error:', err)
460
- })
461
-
462
- wss.on('connection', (ws, req) => {
463
- if (!isLocalOrigin(req.headers.origin)) {
464
- ws.close(1008, 'Forbidden origin')
465
- return
466
- }
467
- console.log('WebSocket client connected')
468
-
469
- // Send initial projects list
470
- discoverProjects().then(projects => {
471
- const projectsWithState = projects.map(p => ({
472
- ...p,
473
- state: parseProjectState(p.path),
474
- overview: parseProjectOverview(p.path)
475
- }))
476
-
477
- ws.send(JSON.stringify({
478
- type: 'projects:init',
479
- data: projectsWithState
480
- }))
481
-
482
- // Start watching progress files for all discovered projects
483
- for (const p of projectsWithState) {
484
- startProgressWatch(p.path)
485
- }
486
-
487
- // Send current scan paths
488
- ws.send(JSON.stringify({
489
- type: 'scan:paths',
490
- data: getCustomScanPaths()
491
- }))
492
- }).catch(err => {
493
- console.error('Error sending initial projects:', err)
494
- })
495
-
496
- ws.on('message', (message) => {
497
- try {
498
- const data = JSON.parse(message)
499
-
500
- switch (data.type) {
501
- case 'cli:execute':
502
- handleCliExecute(ws, data.data)
503
- break
504
- case 'cli:kill':
505
- const kill = activeProcesses.get(data.data.projectName)
506
- if (kill) {
507
- kill()
508
- activeProcesses.delete(data.data.projectName)
509
- broadcast({ type: 'cli:killed', data: { projectName: data.data.projectName } })
510
- }
511
- break
512
- case 'scan:add-path':
513
- if (data.data?.path) {
514
- addCustomScanPath(data.data.path)
515
- broadcast({ type: 'scan:paths', data: getCustomScanPaths() })
516
- // Resend projects after scan
517
- discoverProjects().then(projects => {
518
- broadcast({
519
- type: 'projects:updated',
520
- data: projects.map(p => ({
521
- ...p,
522
- state: parseProjectState(p.path),
523
- overview: parseProjectOverview(p.path)
524
- }))
525
- })
526
- })
527
- }
528
- break
529
- case 'scan:remove-path':
530
- if (data.data?.path) {
531
- removeCustomScanPath(data.data.path)
532
- ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
533
- }
534
- break
535
- case 'scan:get-paths':
536
- ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
537
- break
538
- case 'docs:get':
539
- if (data.data?.projectPath) {
540
- const docs = parseSillyspecDocsTree(data.data.projectPath)
541
- ws.send(JSON.stringify({ type: 'docs:tree', data: docs }))
542
- }
543
- break
544
- default:
545
- console.log('Unknown message type:', data.type)
546
- }
547
- } catch (err) {
548
- console.error('Error handling message:', err)
549
- }
550
- })
551
-
552
- ws.on('close', () => {
553
- console.log('WebSocket client disconnected')
554
- // Decrement progress watchers — we track which projects this client was watching
555
- // via the initial projects:init. For simplicity, stop all that we started.
556
- // (Other clients will re-start via their connection if still active)
557
- for (const [path] of progressWatchers) {
558
- stopProgressWatch(path)
559
- }
560
- })
561
-
562
- ws.on('error', (err) => {
563
- console.error('WebSocket error:', err)
564
- })
565
- })
566
-
567
- // listen 前置:Windows 下 startWatcher 的初始全盘同步扫描(homedir/Temp/桌面 depth 2 +
568
- // 每项目多次 execSync('git'))实测 150s+,若排在其后则服务永不响应(假死)。
569
- // 先绑定端口(listen 回调立即可达),扫描用 setImmediate 延后到事件循环——端口立即响应,
570
- // 初始 projects 数据由扫描完成后 broadcast 补发。
571
- server.listen(port, '127.0.0.1', () => {
572
- console.log(`Dashboard server running on http://127.0.0.1:${port}`)
573
- if (openBrowser) {
574
- open(`http://127.0.0.1:${port}`)
575
- }
576
- })
577
-
578
- setImmediate(() => {
579
- try {
580
- startWatcher((projects) => {
581
- broadcast({ type: 'projects:updated', data: projects })
582
- })
583
- } catch (err) {
584
- console.error('Failed to start file watcher:', err)
585
- }
586
- })
587
-
588
- const shutdown = () => {
589
- stopWatcher()
590
- activeProcesses.forEach(kill => kill())
591
- activeProcesses.clear()
592
- server.close()
593
- }
594
-
595
- process.on('SIGTERM', shutdown)
596
- process.on('SIGINT', shutdown)
597
-
598
- return server
599
- }
600
-
601
- 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
+ // 凭据/敏感运行时文件:local.yaml 含 SillyHub token(connect 明文写入),.plan.md.lock 等锁文件
107
+ // 是并发原语非文档。/api/docs/content 无鉴权(仅 Origin 检查),这些文件绝不经 HTTP 暴露。
108
+ function isCredentialPath(filePath) {
109
+ const base = basename(resolve(filePath)).toLowerCase()
110
+ return base === 'local.yaml' || base === 'local.yml' || base.endsWith('.lock') || base === 'sillyspec-platform.json'
111
+ }
112
+
113
+ function isViewableDocPath(filePath) {
114
+ return /\.(md|markdown|mdx|html?|txt|log|json|ya?ml|toml|xml|csv)$/i.test(filePath)
115
+ }
116
+
117
+ function isLocalOrigin(origin) {
118
+ if (!origin) return true
119
+ try {
120
+ const { hostname } = new URL(origin)
121
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
122
+ } catch {
123
+ return false
124
+ }
125
+ }
126
+
127
+ // --- Shared scan logic (aligned with watcher.js) ---
128
+
129
+ const excludeDirs = new Set([
130
+ '.Trash', '.cache', '.npm', '.local', '.vscode', 'Library',
131
+ '.git', 'node_modules', '.Trash-*', '.DS_Store', '.config',
132
+ '.cocoapods', '.gem', '.rvm', '.nvm', '.asdf', '.brew',
133
+ 'AppData', 'Application Data', '.cargo', '.rustup',
134
+ '.nuget', '.android', '.gradle', '.m2', '.vscode-server'
135
+ ])
136
+
137
+ function shouldExclude(name, cwd) {
138
+ if (excludeDirs.has(name)) return true
139
+ for (const pattern of excludeDirs) {
140
+ if (pattern.includes('*')) {
141
+ const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
142
+ if (regex.test(name)) return true
143
+ }
144
+ }
145
+ const cwdName = cwd.split(sep).pop() || cwd.split('/').pop() || ''
146
+ if (name.startsWith('.') && name !== cwdName) return true
147
+ return false
148
+ }
149
+
150
+ function scanDirectory(baseDir, seen, maxDepth = 2, currentDepth = 0) {
151
+ const cwd = process.cwd()
152
+ const projects = []
153
+ try {
154
+ const entries = readdirSync(baseDir, { withFileTypes: true })
155
+ for (const entry of entries) {
156
+ if (!entry.isDirectory()) continue
157
+ if (shouldExclude(entry.name, cwd)) continue
158
+ const dirPath = join(baseDir, entry.name)
159
+ let realPath
160
+ try { realPath = realpathSync(dirPath) } catch { realPath = dirPath }
161
+ const normalizedPath = realPath.toLowerCase()
162
+ if (seen.has(normalizedPath)) continue
163
+ seen.add(normalizedPath)
164
+ if (existsSync(join(dirPath, '.sillyspec'))) {
165
+ projects.push({ name: entry.name, path: dirPath })
166
+ }
167
+ if (currentDepth < maxDepth) {
168
+ projects.push(...scanDirectory(dirPath, seen, maxDepth, currentDepth + 1))
169
+ }
170
+ }
171
+ } catch (err) {}
172
+ return projects
173
+ }
174
+
175
+ /**
176
+ * Discover all SillySpec projects
177
+ * @returns {Promise<Array>} Array of project objects
178
+ */
179
+ async function discoverProjects() {
180
+ const home = homedir()
181
+ const cwd = process.cwd()
182
+
183
+ const scanDirs = new Set()
184
+ scanDirs.add(cwd)
185
+ scanDirs.add(dirname(cwd))
186
+ scanDirs.add(dirname(dirname(cwd)))
187
+ scanDirs.add(home)
188
+
189
+ const extraDirs = [
190
+ 'Desktop', '桌面', 'Documents', '文档', 'Downloads', '下载',
191
+ 'Projects', '项目', 'Work', '工作', 'Repos', 'Code', 'src', 'dev',
192
+ 'workspace', '工作区'
193
+ ]
194
+
195
+ for (const extra of extraDirs) {
196
+ const extraPath = join(home, extra)
197
+ if (existsSync(extraPath)) scanDirs.add(extraPath)
198
+ }
199
+
200
+ for (const customPath of customScanPaths) {
201
+ if (existsSync(customPath)) scanDirs.add(customPath)
202
+ }
203
+
204
+ const seen = new Set()
205
+ const projects = []
206
+
207
+ // Normalize cwd for dedup
208
+ let cwdReal
209
+ try { cwdReal = realpathSync(cwd) } catch { cwdReal = cwd }
210
+ seen.add(cwdReal.toLowerCase())
211
+ if (existsSync(join(cwd, '.sillyspec'))) {
212
+ projects.push({ name: basename(cwd), path: cwd })
213
+ }
214
+
215
+ for (const baseDir of scanDirs) {
216
+ projects.push(...scanDirectory(baseDir, seen, 2, 0))
217
+ }
218
+
219
+ return projects
220
+ }
221
+
222
+ /**
223
+ * Handle WebSocket message for CLI execution
224
+ * @param {object} ws - WebSocket client
225
+ * @param {object} data - Message data
226
+ */
227
+ function handleCliExecute(ws, data) {
228
+ const { projectName, command } = data
229
+
230
+ if (!projectName || !command) {
231
+ ws.send(JSON.stringify({
232
+ type: 'cli:error',
233
+ data: { message: 'Missing projectName or command' }
234
+ }))
235
+ return
236
+ }
237
+
238
+ if (!isAllowedCliCommand(command)) {
239
+ ws.send(JSON.stringify({
240
+ type: 'cli:error',
241
+ data: { message: `Command not allowed: ${command}` }
242
+ }))
243
+ return
244
+ }
245
+
246
+ discoverProjects().then(projects => {
247
+ const project = projects.find(p => p.name === projectName)
248
+ if (!project) {
249
+ ws.send(JSON.stringify({
250
+ type: 'cli:error',
251
+ data: { message: `Project not found: ${projectName}` }
252
+ }))
253
+ return
254
+ }
255
+
256
+ const existingKill = activeProcesses.get(projectName)
257
+ if (existingKill) existingKill()
258
+
259
+ const kill = executeCommand(
260
+ project.path,
261
+ command,
262
+ (output) => {
263
+ broadcast({
264
+ type: 'cli:output',
265
+ data: { projectName, output: output.data, outputType: output.type }
266
+ })
267
+ },
268
+ (result) => {
269
+ activeProcesses.delete(projectName)
270
+ broadcast({
271
+ type: 'cli:complete',
272
+ data: { projectName, exitCode: result.code, signal: result.signal }
273
+ })
274
+ }
275
+ )
276
+
277
+ activeProcesses.set(projectName, kill)
278
+ broadcast({ type: 'cli:started', data: { projectName, command } })
279
+ })
280
+ }
281
+
282
+ /**
283
+ * Start the dashboard server
284
+ * @param {object} options - Server options
285
+ * @returns {object} HTTP server instance
286
+ */
287
+ function startServer({ port = 3456, open: openBrowser = true } = {}) {
288
+ const server = createServer((req, res) => {
289
+ const origin = req.headers.origin
290
+ if (!isLocalOrigin(origin)) {
291
+ res.writeHead(403)
292
+ res.end('Forbidden')
293
+ return
294
+ }
295
+ res.setHeader('Access-Control-Allow-Origin', origin || `http://127.0.0.1:${port}`)
296
+ res.setHeader('Vary', 'Origin')
297
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
298
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
299
+
300
+ if (req.method === 'OPTIONS') {
301
+ res.writeHead(200)
302
+ res.end()
303
+ return
304
+ }
305
+
306
+ if (req.url === '/api/projects') {
307
+ discoverProjects().then(projects => {
308
+ res.setHeader('Content-Type', 'application/json')
309
+ res.writeHead(200)
310
+ res.end(JSON.stringify(projects))
311
+ }).catch(err => {
312
+ res.writeHead(500)
313
+ res.end(JSON.stringify({ error: err.message }))
314
+ })
315
+ return
316
+ }
317
+
318
+ // Detail API
319
+ const detailMatch = req.url?.match(/^\/api\/projects\/(.+)\/detail(\?|$)/)
320
+ if (detailMatch) {
321
+ const projectPath = decodeURIComponent(detailMatch[1])
322
+ const url = new URL(req.url, `http://${req.headers.host}`)
323
+ const type = url.searchParams.get('type')
324
+ let data
325
+ try {
326
+ if (type === 'git') data = parseGitDetail(projectPath)
327
+ else if (type === 'tech') data = parseTechStackDetail(projectPath)
328
+ else if (type === 'docs') data = parseSillyspecDocsTree(projectPath).groups
329
+ else { res.writeHead(400); res.end(JSON.stringify({ error: 'Invalid type' })); return }
330
+ res.setHeader('Content-Type', 'application/json')
331
+ res.writeHead(200)
332
+ res.end(JSON.stringify(data))
333
+ } catch (err) {
334
+ res.writeHead(500)
335
+ res.end(JSON.stringify({ error: err.message }))
336
+ }
337
+ return
338
+ }
339
+
340
+ // Overview API
341
+ if (req.url?.startsWith('/api/projects/') && req.url.endsWith('/overview')) {
342
+ const parts = req.url.replace('/api/projects/', '').replace('/overview', '').split('/')
343
+ const projectPath = decodeURIComponent(parts.join('/'))
344
+ try {
345
+ const overview = parseProjectOverview(projectPath)
346
+ res.setHeader('Content-Type', 'application/json')
347
+ res.writeHead(200)
348
+ res.end(JSON.stringify(overview))
349
+ } catch (err) {
350
+ res.writeHead(500)
351
+ res.end(JSON.stringify({ error: err.message }))
352
+ }
353
+ return
354
+ }
355
+
356
+ if (req.url?.startsWith('/api/project/')) {
357
+ const projectName = decodeURIComponent(req.url.split('/').pop())
358
+ discoverProjects().then(projects => {
359
+ const project = projects.find(p => p.name === projectName)
360
+ if (project) {
361
+ const state = parseProjectState(project.path)
362
+ res.setHeader('Content-Type', 'application/json')
363
+ res.writeHead(200)
364
+ res.end(JSON.stringify({ ...project, state }))
365
+ } else {
366
+ res.writeHead(404)
367
+ res.end(JSON.stringify({ error: 'Project not found' }))
368
+ }
369
+ }).catch(err => {
370
+ res.writeHead(500)
371
+ res.end(JSON.stringify({ error: err.message }))
372
+ })
373
+ return
374
+ }
375
+
376
+ // Docs API
377
+ if (req.url?.startsWith('/api/docs/content')) {
378
+ const url = new URL(req.url, `http://${req.headers.host}`)
379
+ const filePath = url.searchParams.get('path')
380
+ if (!filePath) {
381
+ res.writeHead(400)
382
+ res.end(JSON.stringify({ error: 'Missing path parameter' }))
383
+ return
384
+ }
385
+ // Security: only allow reading viewable text documents under a .sillyspec tree.
386
+ const normalizedPath = resolve(filePath)
387
+ if (!isSillyspecPath(normalizedPath) || !isViewableDocPath(normalizedPath) || isCredentialPath(normalizedPath)) {
388
+ res.writeHead(403)
389
+ res.end(JSON.stringify({ error: 'Access denied' }))
390
+ return
391
+ }
392
+ if (existsSync(normalizedPath)) {
393
+ try {
394
+ const content = readFileSync(normalizedPath, 'utf-8')
395
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8')
396
+ res.writeHead(200)
397
+ res.end(content)
398
+ } catch (err) {
399
+ res.writeHead(500)
400
+ res.end(JSON.stringify({ error: err.message }))
401
+ }
402
+ } else {
403
+ res.writeHead(404)
404
+ res.end(JSON.stringify({ error: 'File not found' }))
405
+ }
406
+ return
407
+ }
408
+
409
+ if (req.url?.startsWith('/api/docs')) {
410
+ const url = new URL(req.url, `http://${req.headers.host}`)
411
+ const projectPath = url.searchParams.get('project')
412
+ if (!projectPath) {
413
+ res.writeHead(400)
414
+ res.end(JSON.stringify({ error: 'Missing project parameter' }))
415
+ return
416
+ }
417
+ const docs = parseSillyspecDocsTree(projectPath)
418
+ res.setHeader('Content-Type', 'application/json')
419
+ res.writeHead(200)
420
+ res.end(JSON.stringify(docs))
421
+ return
422
+ }
423
+
424
+ // Serve static files (dist/)
425
+ const distDir = join(__dirname, '../dist')
426
+ const indexPath = join(distDir, 'index.html')
427
+ if (existsSync(distDir)) {
428
+ const url = new URL(req.url || '/', `http://${req.headers.host}`)
429
+ const requestPath = decodeURIComponent(url.pathname)
430
+ const filePath = resolve(distDir, requestPath === '/' ? 'index.html' : `.${requestPath}`)
431
+ if (!isInside(distDir, filePath)) {
432
+ res.writeHead(403)
433
+ res.end('Access denied')
434
+ return
435
+ }
436
+ if (existsSync(filePath)) {
437
+ const ext = filePath.split('.').pop()
438
+ const mimeTypes = { html: 'text/html', js: 'application/javascript', css: 'text/css', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg' }
439
+ res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream')
440
+ res.writeHead(200)
441
+ res.end(readFileSync(filePath))
442
+ return
443
+ }
444
+ if (existsSync(indexPath)) {
445
+ res.setHeader('Content-Type', 'text/html')
446
+ res.writeHead(200)
447
+ res.end(readFileSync(indexPath, 'utf8'))
448
+ return
449
+ }
450
+ }
451
+
452
+ res.writeHead(404)
453
+ res.end('Not found')
454
+ })
455
+
456
+ wss = new WebSocketServer({ server })
457
+
458
+ wss.on('error', (err) => {
459
+ console.error('WebSocket server error:', err)
460
+ })
461
+
462
+ wss.on('connection', (ws, req) => {
463
+ if (!isLocalOrigin(req.headers.origin)) {
464
+ ws.close(1008, 'Forbidden origin')
465
+ return
466
+ }
467
+ console.log('WebSocket client connected')
468
+
469
+ // Send initial projects list
470
+ discoverProjects().then(projects => {
471
+ const projectsWithState = projects.map(p => ({
472
+ ...p,
473
+ state: parseProjectState(p.path),
474
+ overview: parseProjectOverview(p.path)
475
+ }))
476
+
477
+ ws.send(JSON.stringify({
478
+ type: 'projects:init',
479
+ data: projectsWithState
480
+ }))
481
+
482
+ // Start watching progress files for all discovered projects
483
+ for (const p of projectsWithState) {
484
+ startProgressWatch(p.path)
485
+ }
486
+
487
+ // Send current scan paths
488
+ ws.send(JSON.stringify({
489
+ type: 'scan:paths',
490
+ data: getCustomScanPaths()
491
+ }))
492
+ }).catch(err => {
493
+ console.error('Error sending initial projects:', err)
494
+ })
495
+
496
+ ws.on('message', (message) => {
497
+ try {
498
+ const data = JSON.parse(message)
499
+
500
+ switch (data.type) {
501
+ case 'cli:execute':
502
+ handleCliExecute(ws, data.data)
503
+ break
504
+ case 'cli:kill':
505
+ const kill = activeProcesses.get(data.data.projectName)
506
+ if (kill) {
507
+ kill()
508
+ activeProcesses.delete(data.data.projectName)
509
+ broadcast({ type: 'cli:killed', data: { projectName: data.data.projectName } })
510
+ }
511
+ break
512
+ case 'scan:add-path':
513
+ if (data.data?.path) {
514
+ addCustomScanPath(data.data.path)
515
+ broadcast({ type: 'scan:paths', data: getCustomScanPaths() })
516
+ // Resend projects after scan
517
+ discoverProjects().then(projects => {
518
+ broadcast({
519
+ type: 'projects:updated',
520
+ data: projects.map(p => ({
521
+ ...p,
522
+ state: parseProjectState(p.path),
523
+ overview: parseProjectOverview(p.path)
524
+ }))
525
+ })
526
+ })
527
+ }
528
+ break
529
+ case 'scan:remove-path':
530
+ if (data.data?.path) {
531
+ removeCustomScanPath(data.data.path)
532
+ ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
533
+ }
534
+ break
535
+ case 'scan:get-paths':
536
+ ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
537
+ break
538
+ case 'docs:get':
539
+ if (data.data?.projectPath) {
540
+ const docs = parseSillyspecDocsTree(data.data.projectPath)
541
+ ws.send(JSON.stringify({ type: 'docs:tree', data: docs }))
542
+ }
543
+ break
544
+ default:
545
+ console.log('Unknown message type:', data.type)
546
+ }
547
+ } catch (err) {
548
+ console.error('Error handling message:', err)
549
+ }
550
+ })
551
+
552
+ ws.on('close', () => {
553
+ console.log('WebSocket client disconnected')
554
+ // Decrement progress watchers — we track which projects this client was watching
555
+ // via the initial projects:init. For simplicity, stop all that we started.
556
+ // (Other clients will re-start via their connection if still active)
557
+ for (const [path] of progressWatchers) {
558
+ stopProgressWatch(path)
559
+ }
560
+ })
561
+
562
+ ws.on('error', (err) => {
563
+ console.error('WebSocket error:', err)
564
+ })
565
+ })
566
+
567
+ // listen 前置:Windows 下 startWatcher 的初始全盘同步扫描(homedir/Temp/桌面 depth 2 +
568
+ // 每项目多次 execSync('git'))实测 150s+,若排在其后则服务永不响应(假死)。
569
+ // 先绑定端口(listen 回调立即可达),扫描用 setImmediate 延后到事件循环——端口立即响应,
570
+ // 初始 projects 数据由扫描完成后 broadcast 补发。
571
+ server.listen(port, '127.0.0.1', () => {
572
+ console.log(`Dashboard server running on http://127.0.0.1:${port}`)
573
+ if (openBrowser) {
574
+ open(`http://127.0.0.1:${port}`)
575
+ }
576
+ })
577
+
578
+ setImmediate(() => {
579
+ try {
580
+ startWatcher((projects) => {
581
+ broadcast({ type: 'projects:updated', data: projects })
582
+ })
583
+ } catch (err) {
584
+ console.error('Failed to start file watcher:', err)
585
+ }
586
+ })
587
+
588
+ const shutdown = () => {
589
+ stopWatcher()
590
+ activeProcesses.forEach(kill => kill())
591
+ activeProcesses.clear()
592
+ server.close()
593
+ }
594
+
595
+ process.on('SIGTERM', shutdown)
596
+ process.on('SIGINT', shutdown)
597
+
598
+ return server
599
+ }
600
+
601
+ export { startServer, broadcast, discoverProjects }