sillyspec 3.9.0 → 3.10.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.
Files changed (206) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +17 -0
  2. package/.claude/skills/sillyspec-auto/SKILL.md +78 -0
  3. package/.claude/skills/sillyspec-brainstorm/SKILL.md +17 -0
  4. package/{templates/commit.md → .claude/skills/sillyspec-commit/SKILL.md} +32 -47
  5. package/.claude/skills/sillyspec-continue/SKILL.md +45 -0
  6. package/.claude/skills/sillyspec-doctor/SKILL.md +27 -0
  7. package/.claude/skills/sillyspec-execute/SKILL.md +17 -0
  8. package/.claude/skills/sillyspec-explore/SKILL.md +105 -0
  9. package/.claude/skills/sillyspec-export/SKILL.md +53 -0
  10. package/.claude/skills/sillyspec-init/SKILL.md +170 -0
  11. package/.claude/skills/sillyspec-plan/SKILL.md +17 -0
  12. package/.claude/skills/sillyspec-propose/SKILL.md +17 -0
  13. package/.claude/skills/sillyspec-quick/SKILL.md +17 -0
  14. package/.claude/skills/sillyspec-resume/SKILL.md +111 -0
  15. package/.claude/skills/sillyspec-scan/SKILL.md +17 -0
  16. package/.claude/skills/sillyspec-state/SKILL.md +54 -0
  17. package/.claude/skills/sillyspec-status/SKILL.md +17 -0
  18. package/.claude/skills/sillyspec-verify/SKILL.md +17 -0
  19. package/.claude/skills/sillyspec-workspace/SKILL.md +149 -0
  20. package/README.md +19 -11
  21. package/SKILL.md +15 -10
  22. package/package.json +7 -9
  23. package/packages/dashboard/dist/assets/index-BcM2J-hv.css +1 -0
  24. package/packages/dashboard/dist/assets/index-DpLHK4jv.js +7446 -0
  25. package/packages/dashboard/dist/index.html +16 -16
  26. package/packages/dashboard/dist/prototype-dashboard.html +836 -0
  27. package/packages/dashboard/dist/prototype-overview.html +256 -0
  28. package/packages/dashboard/package-lock.json +226 -6
  29. package/packages/dashboard/package.json +8 -5
  30. package/packages/dashboard/public/logo.jpg +0 -0
  31. package/packages/dashboard/public/prototype-dashboard.html +836 -0
  32. package/packages/dashboard/public/prototype-overview.html +256 -0
  33. package/packages/dashboard/server/executor.js +1 -1
  34. package/packages/dashboard/server/index.js +341 -113
  35. package/packages/dashboard/server/parser.js +442 -30
  36. package/packages/dashboard/server/watcher.js +214 -134
  37. package/packages/dashboard/src/App.vue +475 -71
  38. package/packages/dashboard/src/components/ActionBar.vue +36 -43
  39. package/packages/dashboard/src/components/CommandPalette.vue +45 -66
  40. package/packages/dashboard/src/components/DetailPanel.vue +68 -53
  41. package/packages/dashboard/src/components/DocPreview.vue +257 -0
  42. package/packages/dashboard/src/components/DocTree.vue +114 -0
  43. package/packages/dashboard/src/components/HResizeHandle.vue +48 -0
  44. package/packages/dashboard/src/components/LogStream.vue +13 -33
  45. package/packages/dashboard/src/components/PipelineStage.vue +8 -8
  46. package/packages/dashboard/src/components/PipelineView.vue +99 -45
  47. package/packages/dashboard/src/components/ProjectCard.vue +187 -0
  48. package/packages/dashboard/src/components/ProjectList.vue +103 -45
  49. package/packages/dashboard/src/components/ProjectOverview.vue +152 -0
  50. package/packages/dashboard/src/components/StageBadge.vue +13 -13
  51. package/packages/dashboard/src/components/StepCard.vue +15 -15
  52. package/packages/dashboard/src/components/VResizeHandle.vue +61 -0
  53. package/packages/dashboard/src/components/detail/DocsDetail.vue +48 -0
  54. package/packages/dashboard/src/components/detail/GitDetail.vue +61 -0
  55. package/packages/dashboard/src/components/detail/TechDetail.vue +43 -0
  56. package/packages/dashboard/src/composables/useDashboard.js +48 -6
  57. package/packages/dashboard/src/composables/useKeyboard.js +6 -4
  58. package/packages/dashboard/src/composables/useLayout.js +131 -0
  59. package/packages/dashboard/src/main.js +4 -1
  60. package/packages/dashboard/src/style.css +17 -17
  61. package/src/index.js +141 -22
  62. package/src/init.js +93 -231
  63. package/src/migrate.js +117 -0
  64. package/src/progress.js +460 -0
  65. package/src/run.js +635 -0
  66. package/src/setup.js +2 -72
  67. package/src/stages/archive.js +54 -0
  68. package/src/stages/brainstorm.js +264 -0
  69. package/src/stages/doctor.js +303 -0
  70. package/src/stages/execute.js +287 -0
  71. package/src/stages/explore.js +34 -0
  72. package/src/stages/index.js +28 -0
  73. package/src/stages/plan.js +354 -0
  74. package/src/stages/propose.js +115 -0
  75. package/src/stages/quick.js +64 -0
  76. package/src/stages/scan.js +141 -0
  77. package/src/stages/status.js +65 -0
  78. package/src/stages/verify.js +135 -0
  79. package/.sillyspec/changes/dashboard/design.md +0 -219
  80. package/.sillyspec/plans/2026-04-05-dashboard.md +0 -737
  81. package/.sillyspec/specs/2026-04-05-dashboard-design.md +0 -206
  82. package/dist/steps/brainstorm/01-load-context.md +0 -30
  83. package/dist/steps/brainstorm/02-reuse-check.md +0 -6
  84. package/dist/steps/brainstorm/03-prototype-analysis.md +0 -11
  85. package/dist/steps/brainstorm/04-module-split.md +0 -23
  86. package/dist/steps/brainstorm/05-dialog-explore.md +0 -8
  87. package/dist/steps/brainstorm/06-propose-approaches.md +0 -3
  88. package/dist/steps/brainstorm/07-present-design.md +0 -3
  89. package/dist/steps/brainstorm/08-write-design.md +0 -21
  90. package/dist/steps/brainstorm/09-self-review.md +0 -15
  91. package/dist/steps/brainstorm/10-user-confirm.md +0 -3
  92. package/dist/steps/brainstorm/11-output-spec.md +0 -7
  93. package/dist/steps/brainstorm/manifest.yaml +0 -26
  94. package/dist/steps/execute/01-load-context.md +0 -41
  95. package/dist/steps/execute/02-scan-conventions.md +0 -47
  96. package/dist/steps/execute/03-skill-mcp.md +0 -19
  97. package/dist/steps/execute/04-assign-task.md +0 -22
  98. package/dist/steps/execute/04b-prompt-template.md +0 -54
  99. package/dist/steps/execute/05-write-test.md +0 -7
  100. package/dist/steps/execute/06-write-code.md +0 -8
  101. package/dist/steps/execute/07-run-test.md +0 -26
  102. package/dist/steps/execute/08-fix-issues.md +0 -28
  103. package/dist/steps/execute/09-next-task.md +0 -33
  104. package/dist/steps/execute/manifest.yaml +0 -28
  105. package/dist/steps/plan/01-load-context.md +0 -22
  106. package/dist/steps/plan/02-anchor-confirm.md +0 -1
  107. package/dist/steps/plan/03-expand-tasks.md +0 -33
  108. package/dist/steps/plan/04-mark-order.md +0 -15
  109. package/dist/steps/plan/05-e2e-planning.md +0 -17
  110. package/dist/steps/plan/06-self-check.md +0 -16
  111. package/dist/steps/plan/07-save.md +0 -1
  112. package/dist/steps/plan/manifest.yaml +0 -18
  113. package/dist/steps/scan/01-env-detect.md +0 -51
  114. package/dist/steps/scan/02-tech-stack.md +0 -16
  115. package/dist/steps/scan/03-conventions.md +0 -16
  116. package/dist/steps/scan/04-structure.md +0 -19
  117. package/dist/steps/scan/05-quality.md +0 -18
  118. package/dist/steps/scan/06-complete.md +0 -49
  119. package/dist/steps/scan/manifest.yaml +0 -16
  120. package/dist/steps/verify/01-load-specs.md +0 -28
  121. package/dist/steps/verify/02-check-tasks.md +0 -1
  122. package/dist/steps/verify/03-check-design.md +0 -6
  123. package/dist/steps/verify/04-run-tests.md +0 -7
  124. package/dist/steps/verify/05-e2e-tests.md +0 -27
  125. package/dist/steps/verify/05b-e2e-fix.md +0 -33
  126. package/dist/steps/verify/06-code-quality.md +0 -25
  127. package/dist/steps/verify/07-lint-check.md +0 -27
  128. package/dist/steps/verify/08-output-report.md +0 -14
  129. package/dist/steps/verify/manifest.yaml +0 -22
  130. package/docs/.vitepress/config.mts +0 -45
  131. package/docs/.vitepress/dist/404.html +0 -25
  132. package/docs/.vitepress/dist/assets/app.YytxICdd.js +0 -1
  133. package/docs/.vitepress/dist/assets/chunks/framework.Czhw_PXq.js +0 -19
  134. package/docs/.vitepress/dist/assets/chunks/theme.DusTRZQk.js +0 -1
  135. package/docs/.vitepress/dist/assets/index.md.C3VCvtQA.js +0 -1
  136. package/docs/.vitepress/dist/assets/index.md.C3VCvtQA.lean.js +0 -1
  137. package/docs/.vitepress/dist/assets/inter-italic-cyrillic-ext.r48I6akx.woff2 +0 -0
  138. package/docs/.vitepress/dist/assets/inter-italic-cyrillic.By2_1cv3.woff2 +0 -0
  139. package/docs/.vitepress/dist/assets/inter-italic-greek-ext.1u6EdAuj.woff2 +0 -0
  140. package/docs/.vitepress/dist/assets/inter-italic-greek.DJ8dCoTZ.woff2 +0 -0
  141. package/docs/.vitepress/dist/assets/inter-italic-latin-ext.CN1xVJS-.woff2 +0 -0
  142. package/docs/.vitepress/dist/assets/inter-italic-latin.C2AdPX0b.woff2 +0 -0
  143. package/docs/.vitepress/dist/assets/inter-italic-vietnamese.BSbpV94h.woff2 +0 -0
  144. package/docs/.vitepress/dist/assets/inter-roman-cyrillic-ext.BBPuwvHQ.woff2 +0 -0
  145. package/docs/.vitepress/dist/assets/inter-roman-cyrillic.C5lxZ8CY.woff2 +0 -0
  146. package/docs/.vitepress/dist/assets/inter-roman-greek-ext.CqjqNYQ-.woff2 +0 -0
  147. package/docs/.vitepress/dist/assets/inter-roman-greek.BBVDIX6e.woff2 +0 -0
  148. package/docs/.vitepress/dist/assets/inter-roman-latin-ext.4ZJIpNVo.woff2 +0 -0
  149. package/docs/.vitepress/dist/assets/inter-roman-latin.Di8DUHzh.woff2 +0 -0
  150. package/docs/.vitepress/dist/assets/inter-roman-vietnamese.BjW4sHH5.woff2 +0 -0
  151. package/docs/.vitepress/dist/assets/sillyspec_commands.md.CXFFsj08.js +0 -15
  152. package/docs/.vitepress/dist/assets/sillyspec_commands.md.CXFFsj08.lean.js +0 -1
  153. package/docs/.vitepress/dist/assets/sillyspec_dashboard.md.BuPXHqjX.js +0 -4
  154. package/docs/.vitepress/dist/assets/sillyspec_dashboard.md.BuPXHqjX.lean.js +0 -1
  155. package/docs/.vitepress/dist/assets/sillyspec_file-io.md.Cz3x7llx.js +0 -1
  156. package/docs/.vitepress/dist/assets/sillyspec_file-io.md.Cz3x7llx.lean.js +0 -1
  157. package/docs/.vitepress/dist/assets/sillyspec_getting-started.md.ClcvV8k3.js +0 -4
  158. package/docs/.vitepress/dist/assets/sillyspec_getting-started.md.ClcvV8k3.lean.js +0 -1
  159. package/docs/.vitepress/dist/assets/sillyspec_install.md.CKuR2tiT.js +0 -5
  160. package/docs/.vitepress/dist/assets/sillyspec_install.md.CKuR2tiT.lean.js +0 -1
  161. package/docs/.vitepress/dist/assets/sillyspec_lifecycle.md.DY293cR1.js +0 -28
  162. package/docs/.vitepress/dist/assets/sillyspec_lifecycle.md.DY293cR1.lean.js +0 -1
  163. package/docs/.vitepress/dist/assets/sillyspec_structure.md.sVYS4zPs.js +0 -30
  164. package/docs/.vitepress/dist/assets/sillyspec_structure.md.sVYS4zPs.lean.js +0 -1
  165. package/docs/.vitepress/dist/assets/style.DFTx90Kk.css +0 -1
  166. package/docs/.vitepress/dist/hashmap.json +0 -1
  167. package/docs/.vitepress/dist/index.html +0 -28
  168. package/docs/.vitepress/dist/sillyspec/commands.html +0 -42
  169. package/docs/.vitepress/dist/sillyspec/dashboard.html +0 -31
  170. package/docs/.vitepress/dist/sillyspec/file-io.html +0 -28
  171. package/docs/.vitepress/dist/sillyspec/getting-started.html +0 -31
  172. package/docs/.vitepress/dist/sillyspec/install.html +0 -32
  173. package/docs/.vitepress/dist/sillyspec/lifecycle.html +0 -55
  174. package/docs/.vitepress/dist/sillyspec/structure.html +0 -57
  175. package/docs/.vitepress/dist/vp-icons.css +0 -1
  176. package/docs/index.md +0 -34
  177. package/docs/sillyspec/commands.md +0 -218
  178. package/docs/sillyspec/dashboard.md +0 -51
  179. package/docs/sillyspec/file-io.md +0 -34
  180. package/docs/sillyspec/getting-started.md +0 -61
  181. package/docs/sillyspec/install.md +0 -51
  182. package/docs/sillyspec/lifecycle.md +0 -146
  183. package/docs/sillyspec/structure.md +0 -62
  184. package/packages/dashboard/dist/assets/index-Bh-GPjKY.css +0 -1
  185. package/packages/dashboard/dist/assets/index-CrCn5Gg6.js +0 -17
  186. package/src/step.js +0 -543
  187. package/templates/archive.md +0 -120
  188. package/templates/brainstorm.md +0 -170
  189. package/templates/continue.md +0 -32
  190. package/templates/execute.md +0 -304
  191. package/templates/explore.md +0 -59
  192. package/templates/export.md +0 -21
  193. package/templates/init.md +0 -61
  194. package/templates/plan.md +0 -146
  195. package/templates/quick.md +0 -135
  196. package/templates/scan-quick.md +0 -49
  197. package/templates/scan.md +0 -156
  198. package/templates/skills/playwright-e2e/SKILL.md +0 -340
  199. package/templates/status.md +0 -75
  200. package/templates/verify.md +0 -236
  201. package/templates/workspace-sync.md +0 -99
  202. package/templates/workspace.md +0 -70
  203. /package/{docs/.vitepress/dist/logo.jpg → logo.jpg} +0 -0
  204. /package/{docs/.vitepress → packages/dashboard}/dist/favicon.jpg +0 -0
  205. /package/{docs/public → packages/dashboard/dist}/logo.jpg +0 -0
  206. /package/{docs → packages/dashboard}/public/favicon.jpg +0 -0
@@ -1,11 +1,12 @@
1
1
  import { createServer } from 'http'
2
2
  import { WebSocketServer } from 'ws'
3
- import { existsSync, readFileSync } from 'fs'
4
- import { join, dirname } from 'path'
3
+ import { existsSync, readFileSync, readdirSync, realpathSync, watch } from 'fs'
4
+ import { join, dirname, basename, sep, resolve, relative } from 'path'
5
5
  import { fileURLToPath } from 'url'
6
+ import { homedir } from 'os'
6
7
  import open from 'open'
7
- import { parseProjectState } from './parser.js'
8
- import { startWatcher, stopWatcher } from './watcher.js'
8
+ import { parseProjectState, parseSillyspecDocsTree, parseProjectOverview, parseGitDetail, parseTechStackDetail } from './parser.js'
9
+ import { startWatcher, stopWatcher, addCustomScanPath, removeCustomScanPath, getCustomScanPaths, customScanPaths } from './watcher.js'
9
10
  import { executeCommand } from './executor.js'
10
11
 
11
12
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -13,6 +14,69 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
13
14
  // WebSocket clients and active processes
14
15
  let wss = null
15
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
+ const progressFile = join(projectPath, '.sillyspec', '.runtime', 'progress.json')
52
+ if (!existsSync(progressFile)) return
53
+
54
+ let timer = null
55
+ try {
56
+ const watcher = watch(progressFile, (eventType) => {
57
+ if (timer) clearTimeout(timer)
58
+ timer = setTimeout(() => {
59
+ timer = null
60
+ try {
61
+ const state = parseProjectState(projectPath)
62
+ broadcast({ type: 'project:update', data: { path: projectPath, state } })
63
+ } catch {}
64
+ }, 200)
65
+ })
66
+ progressWatchers.set(projectPath, { watcher, timer, refCount: 1 })
67
+ } catch {}
68
+ }
69
+
70
+ function stopProgressWatch(projectPath) {
71
+ const entry = progressWatchers.get(projectPath)
72
+ if (!entry) return
73
+ entry.refCount--
74
+ if (entry.refCount <= 0) {
75
+ entry.watcher.close()
76
+ if (entry.timer) clearTimeout(entry.timer)
77
+ progressWatchers.delete(projectPath)
78
+ }
79
+ }
16
80
 
17
81
  /**
18
82
  * Broadcast message to all connected WebSocket clients
@@ -28,83 +92,120 @@ function broadcast(data) {
28
92
  })
29
93
  }
30
94
 
95
+ function isInside(parent, child) {
96
+ const rel = relative(resolve(parent), resolve(child))
97
+ return rel === '' || (!!rel && !rel.startsWith('..') && !rel.includes(`..${sep}`))
98
+ }
99
+
100
+ function isSillyspecPath(filePath) {
101
+ const parts = resolve(filePath).split(/[\\/]+/)
102
+ return parts.includes('.sillyspec')
103
+ }
104
+
105
+ function isViewableDocPath(filePath) {
106
+ return /\.(md|markdown|mdx|html?|txt|log|json|ya?ml|toml|xml|csv)$/i.test(filePath)
107
+ }
108
+
109
+ function isLocalOrigin(origin) {
110
+ if (!origin) return true
111
+ try {
112
+ const { hostname } = new URL(origin)
113
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
114
+ } catch {
115
+ return false
116
+ }
117
+ }
118
+
119
+ // --- Shared scan logic (aligned with watcher.js) ---
120
+
121
+ const excludeDirs = new Set([
122
+ '.Trash', '.cache', '.npm', '.local', '.vscode', 'Library',
123
+ '.git', 'node_modules', '.Trash-*', '.DS_Store', '.config',
124
+ '.cocoapods', '.gem', '.rvm', '.nvm', '.asdf', '.brew',
125
+ 'AppData', 'Application Data', '.cargo', '.rustup',
126
+ '.nuget', '.android', '.gradle', '.m2', '.vscode-server'
127
+ ])
128
+
129
+ function shouldExclude(name, cwd) {
130
+ if (excludeDirs.has(name)) return true
131
+ for (const pattern of excludeDirs) {
132
+ if (pattern.includes('*')) {
133
+ const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
134
+ if (regex.test(name)) return true
135
+ }
136
+ }
137
+ const cwdName = cwd.split(sep).pop() || cwd.split('/').pop() || ''
138
+ if (name.startsWith('.') && name !== cwdName) return true
139
+ return false
140
+ }
141
+
142
+ function scanDirectory(baseDir, seen, maxDepth = 2, currentDepth = 0) {
143
+ const cwd = process.cwd()
144
+ const projects = []
145
+ try {
146
+ const entries = readdirSync(baseDir, { withFileTypes: true })
147
+ for (const entry of entries) {
148
+ if (!entry.isDirectory()) continue
149
+ if (shouldExclude(entry.name, cwd)) continue
150
+ const dirPath = join(baseDir, entry.name)
151
+ let realPath
152
+ try { realPath = realpathSync(dirPath) } catch { realPath = dirPath }
153
+ const normalizedPath = realPath.toLowerCase()
154
+ if (seen.has(normalizedPath)) continue
155
+ seen.add(normalizedPath)
156
+ if (existsSync(join(dirPath, '.sillyspec'))) {
157
+ projects.push({ name: entry.name, path: dirPath })
158
+ }
159
+ if (currentDepth < maxDepth) {
160
+ projects.push(...scanDirectory(dirPath, seen, maxDepth, currentDepth + 1))
161
+ }
162
+ }
163
+ } catch (err) {}
164
+ return projects
165
+ }
166
+
31
167
  /**
32
168
  * Discover all SillySpec projects
33
169
  * @returns {Promise<Array>} Array of project objects
34
170
  */
35
171
  async function discoverProjects() {
36
- const { homedir } = await import('os')
37
172
  const home = homedir()
38
173
  const cwd = process.cwd()
39
174
 
40
- // Directories to exclude (system junk, cache, etc.)
41
- const excludeDirs = new Set([
42
- '.Trash', '.cache', '.npm', '.local', '.vscode', 'Library',
43
- '.git', 'node_modules', '.Trash-*', '.DS_Store', '.config',
44
- '.cocoapods', '.gem', '.rvm', '.nvm', '.asdf', '.brew'
45
- ])
46
-
47
- // Helper to check if directory should be excluded
48
- const shouldExclude = (name) => {
49
- // Check exact matches
50
- if (excludeDirs.has(name)) return true
51
- // Check wildcard patterns (like .Trash-*)
52
- for (const pattern of excludeDirs) {
53
- if (pattern.includes('*')) {
54
- const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
55
- if (regex.test(name)) return true
56
- }
57
- }
58
- // Exclude hidden directories (starting with .) unless it's the cwd basename
59
- if (name.startsWith('.') && name !== cwd.split('/').pop()) {
60
- return true
61
- }
62
- return false
63
- }
175
+ const scanDirs = new Set()
176
+ scanDirs.add(cwd)
177
+ scanDirs.add(dirname(cwd))
178
+ scanDirs.add(dirname(dirname(cwd)))
179
+ scanDirs.add(home)
64
180
 
65
- // Build scan directories: cwd + home subdirs + common project locations
66
- const scanDirs = [cwd, home]
67
- const extraDirs = ['Desktop', 'Documents', 'Projects', 'Work', 'Repos', 'Code', 'src', 'dev']
181
+ const extraDirs = [
182
+ 'Desktop', '桌面', 'Documents', '文档', 'Downloads', '下载',
183
+ 'Projects', '项目', 'Work', '工作', 'Repos', 'Code', 'src', 'dev',
184
+ 'workspace', '工作区'
185
+ ]
68
186
 
69
187
  for (const extra of extraDirs) {
70
188
  const extraPath = join(home, extra)
71
- if (existsSync(extraPath)) {
72
- scanDirs.push(extraPath)
73
- }
189
+ if (existsSync(extraPath)) scanDirs.add(extraPath)
74
190
  }
75
191
 
76
- const projects = []
77
- const seen = new Set() // Dedupe by path
78
-
79
- for (const baseDir of scanDirs) {
80
- try {
81
- const { readdirSync } = await import('fs')
82
- const entries = readdirSync(baseDir, { withFileTypes: true })
83
-
84
- for (const entry of entries) {
85
- if (!entry.isDirectory()) continue
86
- if (shouldExclude(entry.name)) continue
87
-
88
- const dirPath = join(baseDir, entry.name)
192
+ for (const customPath of customScanPaths) {
193
+ if (existsSync(customPath)) scanDirs.add(customPath)
194
+ }
89
195
 
90
- // Skip if we've already seen this path (handles symlinks, dupes)
91
- const realPath = dirPath // Could add realpath for true deduping
92
- if (seen.has(realPath)) continue
93
- seen.add(realPath)
196
+ const seen = new Set()
197
+ const projects = []
94
198
 
95
- const sillyspecPath = join(dirPath, '.sillyspec')
199
+ // Normalize cwd for dedup
200
+ let cwdReal
201
+ try { cwdReal = realpathSync(cwd) } catch { cwdReal = cwd }
202
+ seen.add(cwdReal.toLowerCase())
203
+ if (existsSync(join(cwd, '.sillyspec'))) {
204
+ projects.push({ name: basename(cwd), path: cwd })
205
+ }
96
206
 
97
- if (existsSync(sillyspecPath)) {
98
- projects.push({
99
- name: entry.name,
100
- path: dirPath
101
- })
102
- }
103
- }
104
- } catch (err) {
105
- // Skip directories we can't read
106
- continue
107
- }
207
+ for (const baseDir of scanDirs) {
208
+ projects.push(...scanDirectory(baseDir, seen, 2, 0))
108
209
  }
109
210
 
110
211
  return projects
@@ -126,7 +227,14 @@ function handleCliExecute(ws, data) {
126
227
  return
127
228
  }
128
229
 
129
- // Find project
230
+ if (!isAllowedCliCommand(command)) {
231
+ ws.send(JSON.stringify({
232
+ type: 'cli:error',
233
+ data: { message: `Command not allowed: ${command}` }
234
+ }))
235
+ return
236
+ }
237
+
130
238
  discoverProjects().then(projects => {
131
239
  const project = projects.find(p => p.name === projectName)
132
240
  if (!project) {
@@ -137,45 +245,29 @@ function handleCliExecute(ws, data) {
137
245
  return
138
246
  }
139
247
 
140
- // Kill existing process for this project if any
141
248
  const existingKill = activeProcesses.get(projectName)
142
- if (existingKill) {
143
- existingKill()
144
- }
249
+ if (existingKill) existingKill()
145
250
 
146
- // Execute command
147
251
  const kill = executeCommand(
148
252
  project.path,
149
253
  command,
150
254
  (output) => {
151
255
  broadcast({
152
256
  type: 'cli:output',
153
- data: {
154
- projectName,
155
- output: output.data,
156
- outputType: output.type
157
- }
257
+ data: { projectName, output: output.data, outputType: output.type }
158
258
  })
159
259
  },
160
260
  (result) => {
161
261
  activeProcesses.delete(projectName)
162
262
  broadcast({
163
263
  type: 'cli:complete',
164
- data: {
165
- projectName,
166
- exitCode: result.code,
167
- signal: result.signal
168
- }
264
+ data: { projectName, exitCode: result.code, signal: result.signal }
169
265
  })
170
266
  }
171
267
  )
172
268
 
173
269
  activeProcesses.set(projectName, kill)
174
-
175
- broadcast({
176
- type: 'cli:started',
177
- data: { projectName, command }
178
- })
270
+ broadcast({ type: 'cli:started', data: { projectName, command } })
179
271
  })
180
272
  }
181
273
 
@@ -186,8 +278,14 @@ function handleCliExecute(ws, data) {
186
278
  */
187
279
  function startServer({ port = 3456, open: openBrowser = true } = {}) {
188
280
  const server = createServer((req, res) => {
189
- // CORS headers
190
- res.setHeader('Access-Control-Allow-Origin', '*')
281
+ const origin = req.headers.origin
282
+ if (!isLocalOrigin(origin)) {
283
+ res.writeHead(403)
284
+ res.end('Forbidden')
285
+ return
286
+ }
287
+ res.setHeader('Access-Control-Allow-Origin', origin || `http://127.0.0.1:${port}`)
288
+ res.setHeader('Vary', 'Origin')
191
289
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
192
290
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
193
291
 
@@ -197,7 +295,6 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
197
295
  return
198
296
  }
199
297
 
200
- // API: List all projects
201
298
  if (req.url === '/api/projects') {
202
299
  discoverProjects().then(projects => {
203
300
  res.setHeader('Content-Type', 'application/json')
@@ -210,7 +307,44 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
210
307
  return
211
308
  }
212
309
 
213
- // API: Get project details with state
310
+ // Detail API
311
+ const detailMatch = req.url?.match(/^\/api\/projects\/(.+)\/detail(\?|$)/)
312
+ if (detailMatch) {
313
+ const projectPath = decodeURIComponent(detailMatch[1])
314
+ const url = new URL(req.url, `http://${req.headers.host}`)
315
+ const type = url.searchParams.get('type')
316
+ let data
317
+ try {
318
+ if (type === 'git') data = parseGitDetail(projectPath)
319
+ else if (type === 'tech') data = parseTechStackDetail(projectPath)
320
+ else if (type === 'docs') data = parseSillyspecDocsTree(projectPath).groups
321
+ else { res.writeHead(400); res.end(JSON.stringify({ error: 'Invalid type' })); return }
322
+ res.setHeader('Content-Type', 'application/json')
323
+ res.writeHead(200)
324
+ res.end(JSON.stringify(data))
325
+ } catch (err) {
326
+ res.writeHead(500)
327
+ res.end(JSON.stringify({ error: err.message }))
328
+ }
329
+ return
330
+ }
331
+
332
+ // Overview API
333
+ if (req.url?.startsWith('/api/projects/') && req.url.endsWith('/overview')) {
334
+ const parts = req.url.replace('/api/projects/', '').replace('/overview', '').split('/')
335
+ const projectPath = decodeURIComponent(parts.join('/'))
336
+ try {
337
+ const overview = parseProjectOverview(projectPath)
338
+ res.setHeader('Content-Type', 'application/json')
339
+ res.writeHead(200)
340
+ res.end(JSON.stringify(overview))
341
+ } catch (err) {
342
+ res.writeHead(500)
343
+ res.end(JSON.stringify({ error: err.message }))
344
+ }
345
+ return
346
+ }
347
+
214
348
  if (req.url?.startsWith('/api/project/')) {
215
349
  const projectName = decodeURIComponent(req.url.split('/').pop())
216
350
  discoverProjects().then(projects => {
@@ -219,10 +353,7 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
219
353
  const state = parseProjectState(project.path)
220
354
  res.setHeader('Content-Type', 'application/json')
221
355
  res.writeHead(200)
222
- res.end(JSON.stringify({
223
- ...project,
224
- state
225
- }))
356
+ res.end(JSON.stringify({ ...project, state }))
226
357
  } else {
227
358
  res.writeHead(404)
228
359
  res.end(JSON.stringify({ error: 'Project not found' }))
@@ -234,12 +365,67 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
234
365
  return
235
366
  }
236
367
 
368
+ // Docs API
369
+ if (req.url?.startsWith('/api/docs/content')) {
370
+ const url = new URL(req.url, `http://${req.headers.host}`)
371
+ const filePath = url.searchParams.get('path')
372
+ if (!filePath) {
373
+ res.writeHead(400)
374
+ res.end(JSON.stringify({ error: 'Missing path parameter' }))
375
+ return
376
+ }
377
+ // Security: only allow reading viewable text documents under a .sillyspec tree.
378
+ const normalizedPath = resolve(filePath)
379
+ if (!isSillyspecPath(normalizedPath) || !isViewableDocPath(normalizedPath)) {
380
+ res.writeHead(403)
381
+ res.end(JSON.stringify({ error: 'Access denied' }))
382
+ return
383
+ }
384
+ if (existsSync(normalizedPath)) {
385
+ try {
386
+ const content = readFileSync(normalizedPath, 'utf-8')
387
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8')
388
+ res.writeHead(200)
389
+ res.end(content)
390
+ } catch (err) {
391
+ res.writeHead(500)
392
+ res.end(JSON.stringify({ error: err.message }))
393
+ }
394
+ } else {
395
+ res.writeHead(404)
396
+ res.end(JSON.stringify({ error: 'File not found' }))
397
+ }
398
+ return
399
+ }
400
+
401
+ if (req.url?.startsWith('/api/docs')) {
402
+ const url = new URL(req.url, `http://${req.headers.host}`)
403
+ const projectPath = url.searchParams.get('project')
404
+ if (!projectPath) {
405
+ res.writeHead(400)
406
+ res.end(JSON.stringify({ error: 'Missing project parameter' }))
407
+ return
408
+ }
409
+ const docs = parseSillyspecDocsTree(projectPath)
410
+ res.setHeader('Content-Type', 'application/json')
411
+ res.writeHead(200)
412
+ res.end(JSON.stringify(docs))
413
+ return
414
+ }
415
+
237
416
  // Serve static files (dist/)
238
417
  const distDir = join(__dirname, '../dist')
239
418
  const indexPath = join(distDir, 'index.html')
240
419
  if (existsSync(distDir)) {
241
- const filePath = join(distDir, req.url === '/' ? 'index.html' : req.url.replace(/^\//, ''))
242
- if (existsSync(filePath) && !filePath.includes('..')) {
420
+ const url = new URL(req.url || '/', `http://${req.headers.host}`)
421
+ const requestPath = decodeURIComponent(url.pathname)
422
+ const filePath = resolve(distDir, requestPath === '/' ? 'index.html' : `.${requestPath}`)
423
+ if (!isInside(distDir, filePath)) {
424
+ res.writeHead(403)
425
+ res.end('Access denied')
426
+ return
427
+ }
428
+ if (existsSync(filePath)) {
243
429
  const ext = filePath.split('.').pop()
244
430
  const mimeTypes = { html: 'text/html', js: 'application/javascript', css: 'text/css', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg' }
245
431
  res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream')
@@ -247,7 +433,6 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
247
433
  res.end(readFileSync(filePath))
248
434
  return
249
435
  }
250
- // SPA fallback: serve index.html for unknown routes
251
436
  if (existsSync(indexPath)) {
252
437
  res.setHeader('Content-Type', 'text/html')
253
438
  res.writeHead(200)
@@ -256,32 +441,46 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
256
441
  }
257
442
  }
258
443
 
259
- // 404
260
444
  res.writeHead(404)
261
445
  res.end('Not found')
262
446
  })
263
447
 
264
- // WebSocket Server
265
448
  wss = new WebSocketServer({ server })
266
449
 
267
450
  wss.on('error', (err) => {
268
451
  console.error('WebSocket server error:', err)
269
452
  })
270
453
 
271
- wss.on('connection', (ws) => {
454
+ wss.on('connection', (ws, req) => {
455
+ if (!isLocalOrigin(req.headers.origin)) {
456
+ ws.close(1008, 'Forbidden origin')
457
+ return
458
+ }
272
459
  console.log('WebSocket client connected')
273
460
 
274
461
  // Send initial projects list
275
462
  discoverProjects().then(projects => {
276
463
  const projectsWithState = projects.map(p => ({
277
464
  ...p,
278
- state: parseProjectState(p.path)
465
+ state: parseProjectState(p.path),
466
+ overview: parseProjectOverview(p.path)
279
467
  }))
280
468
 
281
469
  ws.send(JSON.stringify({
282
470
  type: 'projects:init',
283
471
  data: projectsWithState
284
472
  }))
473
+
474
+ // Start watching progress files for all discovered projects
475
+ for (const p of projectsWithState) {
476
+ startProgressWatch(p.path)
477
+ }
478
+
479
+ // Send current scan paths
480
+ ws.send(JSON.stringify({
481
+ type: 'scan:paths',
482
+ data: getCustomScanPaths()
483
+ }))
285
484
  }).catch(err => {
286
485
  console.error('Error sending initial projects:', err)
287
486
  })
@@ -299,12 +498,41 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
299
498
  if (kill) {
300
499
  kill()
301
500
  activeProcesses.delete(data.data.projectName)
302
- broadcast({
303
- type: 'cli:killed',
304
- data: { projectName: data.data.projectName }
501
+ broadcast({ type: 'cli:killed', data: { projectName: data.data.projectName } })
502
+ }
503
+ break
504
+ case 'scan:add-path':
505
+ if (data.data?.path) {
506
+ addCustomScanPath(data.data.path)
507
+ broadcast({ type: 'scan:paths', data: getCustomScanPaths() })
508
+ // Resend projects after scan
509
+ discoverProjects().then(projects => {
510
+ broadcast({
511
+ type: 'projects:updated',
512
+ data: projects.map(p => ({
513
+ ...p,
514
+ state: parseProjectState(p.path),
515
+ overview: parseProjectOverview(p.path)
516
+ }))
517
+ })
305
518
  })
306
519
  }
307
520
  break
521
+ case 'scan:remove-path':
522
+ if (data.data?.path) {
523
+ removeCustomScanPath(data.data.path)
524
+ ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
525
+ }
526
+ break
527
+ case 'scan:get-paths':
528
+ ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
529
+ break
530
+ case 'docs:get':
531
+ if (data.data?.projectPath) {
532
+ const docs = parseSillyspecDocsTree(data.data.projectPath)
533
+ ws.send(JSON.stringify({ type: 'docs:tree', data: docs }))
534
+ }
535
+ break
308
536
  default:
309
537
  console.log('Unknown message type:', data.type)
310
538
  }
@@ -315,6 +543,12 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
315
543
 
316
544
  ws.on('close', () => {
317
545
  console.log('WebSocket client disconnected')
546
+ // Decrement progress watchers — we track which projects this client was watching
547
+ // via the initial projects:init. For simplicity, stop all that we started.
548
+ // (Other clients will re-start via their connection if still active)
549
+ for (const [path] of progressWatchers) {
550
+ stopProgressWatch(path)
551
+ }
318
552
  })
319
553
 
320
554
  ws.on('error', (err) => {
@@ -322,27 +556,21 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
322
556
  })
323
557
  })
324
558
 
325
- // Start file watcher (wrapped to avoid crashing server)
326
559
  try {
327
560
  startWatcher((projects) => {
328
- broadcast({
329
- type: 'projects:updated',
330
- data: projects
331
- })
561
+ broadcast({ type: 'projects:updated', data: projects })
332
562
  })
333
563
  } catch (err) {
334
564
  console.error('Failed to start file watcher:', err)
335
565
  }
336
566
 
337
- server.listen(port, () => {
338
- console.log(`Dashboard server running on http://localhost:${port}`)
339
-
567
+ server.listen(port, '127.0.0.1', () => {
568
+ console.log(`Dashboard server running on http://127.0.0.1:${port}`)
340
569
  if (openBrowser) {
341
- open(`http://localhost:${port}`)
570
+ open(`http://127.0.0.1:${port}`)
342
571
  }
343
572
  })
344
573
 
345
- // Handle shutdown
346
574
  const shutdown = () => {
347
575
  stopWatcher()
348
576
  activeProcesses.forEach(kill => kill())