@antigenic-oss/paint 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +178 -0
- package/NOTICE +4 -0
- package/README.md +180 -0
- package/bin/paint.js +266 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +19 -0
- package/package.json +81 -0
- package/postcss.config.mjs +8 -0
- package/public/dev-editor-inspector.js +1872 -0
- package/src/app/api/claude/analyze/route.ts +319 -0
- package/src/app/api/claude/apply/route.ts +185 -0
- package/src/app/api/claude/pick-folder/route.ts +64 -0
- package/src/app/api/claude/scan/route.ts +221 -0
- package/src/app/api/claude/status/route.ts +55 -0
- package/src/app/api/project/scan/route.ts +634 -0
- package/src/app/api/project-scan/css-variables/route.ts +238 -0
- package/src/app/api/project-scan/route.ts +40 -0
- package/src/app/api/project-scan/tailwind-config/route.ts +172 -0
- package/src/app/api/proxy/[[...path]]/route.ts +2400 -0
- package/src/app/docs/DocsClient.tsx +322 -0
- package/src/app/docs/layout.tsx +7 -0
- package/src/app/docs/page.tsx +855 -0
- package/src/app/globals.css +176 -0
- package/src/app/layout.tsx +19 -0
- package/src/app/page.tsx +46 -0
- package/src/bridge/api-handlers.ts +885 -0
- package/src/bridge/proxy-handler.ts +329 -0
- package/src/bridge/server.ts +113 -0
- package/src/components/BreakpointTabs.tsx +72 -0
- package/src/components/ChangeSummaryModal.tsx +267 -0
- package/src/components/ConnectModal.tsx +994 -0
- package/src/components/Editor.tsx +90 -0
- package/src/components/PageSelector.tsx +208 -0
- package/src/components/PreviewFrame.tsx +299 -0
- package/src/components/ProjectFolderBanner.tsx +91 -0
- package/src/components/ResponsiveToolbar.tsx +222 -0
- package/src/components/TargetSelector.tsx +243 -0
- package/src/components/TopBar.tsx +315 -0
- package/src/components/common/CollapsibleSection.tsx +36 -0
- package/src/components/common/ColorPicker.tsx +920 -0
- package/src/components/common/EditablePre.tsx +136 -0
- package/src/components/common/ErrorBoundary.tsx +65 -0
- package/src/components/common/ResizablePanel.tsx +83 -0
- package/src/components/common/ScanAnimation.tsx +76 -0
- package/src/components/common/ToastContainer.tsx +97 -0
- package/src/components/common/UnitInput.tsx +77 -0
- package/src/components/common/VariableColorPicker.tsx +622 -0
- package/src/components/left-panel/AddElementPanel.tsx +237 -0
- package/src/components/left-panel/ComponentsPanel.tsx +609 -0
- package/src/components/left-panel/IconSidebar.tsx +99 -0
- package/src/components/left-panel/LayerNode.tsx +874 -0
- package/src/components/left-panel/LayerSearch.tsx +23 -0
- package/src/components/left-panel/LayersPanel.tsx +52 -0
- package/src/components/left-panel/LeftPanel.tsx +122 -0
- package/src/components/left-panel/PagesPanel.tsx +114 -0
- package/src/components/left-panel/icons.tsx +162 -0
- package/src/components/left-panel/terminal/ScanOverlay.tsx +66 -0
- package/src/components/left-panel/terminal/TerminalPanel.tsx +217 -0
- package/src/components/right-panel/ElementLogBox.tsx +248 -0
- package/src/components/right-panel/PanelTabs.tsx +83 -0
- package/src/components/right-panel/RightPanel.tsx +41 -0
- package/src/components/right-panel/changes/AiScanResultPanel.tsx +285 -0
- package/src/components/right-panel/changes/ChangeEntry.tsx +59 -0
- package/src/components/right-panel/changes/ChangelogActions.tsx +105 -0
- package/src/components/right-panel/changes/ChangesPanel.tsx +1474 -0
- package/src/components/right-panel/claude/ApplyConfirmModal.tsx +376 -0
- package/src/components/right-panel/claude/ClaudeErrorState.tsx +125 -0
- package/src/components/right-panel/claude/ClaudeIntegrationPanel.tsx +482 -0
- package/src/components/right-panel/claude/ClaudeProgressIndicator.tsx +76 -0
- package/src/components/right-panel/claude/DiffCard.tsx +130 -0
- package/src/components/right-panel/claude/DiffViewer.tsx +54 -0
- package/src/components/right-panel/claude/ProjectRootSelector.tsx +275 -0
- package/src/components/right-panel/claude/ResultsSummary.tsx +119 -0
- package/src/components/right-panel/claude/SetupFlow.tsx +315 -0
- package/src/components/right-panel/console/ConsolePanel.tsx +209 -0
- package/src/components/right-panel/design/AppearanceSection.tsx +703 -0
- package/src/components/right-panel/design/BackgroundSection.tsx +516 -0
- package/src/components/right-panel/design/BorderSection.tsx +161 -0
- package/src/components/right-panel/design/CSSRawView.tsx +412 -0
- package/src/components/right-panel/design/DesignCSSTabToggle.tsx +51 -0
- package/src/components/right-panel/design/DesignPanel.tsx +275 -0
- package/src/components/right-panel/design/ElementBreadcrumb.tsx +51 -0
- package/src/components/right-panel/design/GradientEditor.tsx +726 -0
- package/src/components/right-panel/design/LayoutSection.tsx +1948 -0
- package/src/components/right-panel/design/PositionSection.tsx +865 -0
- package/src/components/right-panel/design/PropertiesSection.tsx +86 -0
- package/src/components/right-panel/design/SVGSection.tsx +361 -0
- package/src/components/right-panel/design/ShadowBlurSection.tsx +227 -0
- package/src/components/right-panel/design/SizeSection.tsx +183 -0
- package/src/components/right-panel/design/TextSection.tsx +719 -0
- package/src/components/right-panel/design/icons.tsx +948 -0
- package/src/components/right-panel/design/inputs/BoxModelPreview.tsx +467 -0
- package/src/components/right-panel/design/inputs/ColorInput.tsx +43 -0
- package/src/components/right-panel/design/inputs/CompactInput.tsx +333 -0
- package/src/components/right-panel/design/inputs/DraggableLabel.tsx +118 -0
- package/src/components/right-panel/design/inputs/IconToggleGroup.tsx +54 -0
- package/src/components/right-panel/design/inputs/LinkedInputPair.tsx +174 -0
- package/src/components/right-panel/design/inputs/SectionHeader.tsx +79 -0
- package/src/components/right-panel/variables/VariablesPanel.tsx +388 -0
- package/src/hooks/useBridge.ts +95 -0
- package/src/hooks/useChangeTracker.ts +563 -0
- package/src/hooks/useClaudeAPI.ts +118 -0
- package/src/hooks/useDOMTree.ts +25 -0
- package/src/hooks/useKeyboardShortcuts.ts +76 -0
- package/src/hooks/usePostMessage.ts +589 -0
- package/src/hooks/useProjectScan.ts +204 -0
- package/src/hooks/useResizable.ts +20 -0
- package/src/hooks/useSelectedElement.ts +51 -0
- package/src/hooks/useTargetUrl.ts +81 -0
- package/src/inspector/DOMTraverser.ts +71 -0
- package/src/inspector/ElementSelector.ts +23 -0
- package/src/inspector/HoverHighlighter.ts +54 -0
- package/src/inspector/SelectionHighlighter.ts +27 -0
- package/src/inspector/StyleExtractor.ts +19 -0
- package/src/inspector/inspector.ts +17 -0
- package/src/inspector/messaging.ts +30 -0
- package/src/lib/apiBase.ts +15 -0
- package/src/lib/classifyElement.ts +430 -0
- package/src/lib/claude-bin.ts +197 -0
- package/src/lib/claude-stream.ts +158 -0
- package/src/lib/clientProjectScanner.ts +344 -0
- package/src/lib/componentMatcher.ts +156 -0
- package/src/lib/constants.ts +573 -0
- package/src/lib/cssVariableUtils.ts +409 -0
- package/src/lib/diffParser.ts +206 -0
- package/src/lib/folderPicker.ts +84 -0
- package/src/lib/gradientParser.ts +160 -0
- package/src/lib/projectScanner.ts +355 -0
- package/src/lib/promptBuilder.ts +402 -0
- package/src/lib/shadowParser.ts +124 -0
- package/src/lib/tailwindClassParser.ts +248 -0
- package/src/lib/textShadowUtils.ts +106 -0
- package/src/lib/utils.ts +299 -0
- package/src/lib/validatePath.ts +40 -0
- package/src/proxy.ts +92 -0
- package/src/server/terminal-server.ts +104 -0
- package/src/store/changeSlice.ts +288 -0
- package/src/store/claudeSlice.ts +222 -0
- package/src/store/componentSlice.ts +90 -0
- package/src/store/consoleSlice.ts +51 -0
- package/src/store/cssVariableSlice.ts +94 -0
- package/src/store/elementSlice.ts +78 -0
- package/src/store/index.ts +35 -0
- package/src/store/terminalSlice.ts +30 -0
- package/src/store/treeSlice.ts +69 -0
- package/src/store/uiSlice.ts +327 -0
- package/src/types/changelog.ts +49 -0
- package/src/types/claude.ts +131 -0
- package/src/types/component.ts +49 -0
- package/src/types/cssVariables.ts +18 -0
- package/src/types/element.ts +21 -0
- package/src/types/file-system-access.d.ts +27 -0
- package/src/types/gradient.ts +12 -0
- package/src/types/messages.ts +392 -0
- package/src/types/shadow.ts +8 -0
- package/src/types/tree.ts +9 -0
- package/tsconfig.json +42 -0
- package/tsconfig.server.json +12 -0
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge API handlers.
|
|
3
|
+
*
|
|
4
|
+
* Wraps existing shared modules (claude-bin, validatePath, projectScanner)
|
|
5
|
+
* to provide the same API surface as the Next.js routes, but running
|
|
6
|
+
* directly on the user's machine via the bridge server.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFile } from 'node:child_process'
|
|
10
|
+
import { promisify } from 'node:util'
|
|
11
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import {
|
|
14
|
+
getClaudeBin,
|
|
15
|
+
spawnClaude,
|
|
16
|
+
spawnClaudeStreaming,
|
|
17
|
+
isAuthError,
|
|
18
|
+
} from '../lib/claude-bin'
|
|
19
|
+
import { validateProjectRoot } from '../lib/validatePath'
|
|
20
|
+
import { scanProject } from '../lib/projectScanner'
|
|
21
|
+
import { stripControlChars } from '../lib/utils'
|
|
22
|
+
import { buildSmartAnalysisPrompt } from '../lib/promptBuilder'
|
|
23
|
+
|
|
24
|
+
const execFileAsync = promisify(execFile)
|
|
25
|
+
|
|
26
|
+
const MAX_CHANGELOG_BYTES = 50 * 1024
|
|
27
|
+
const TIMEOUT_MS = 120_000
|
|
28
|
+
|
|
29
|
+
function json(
|
|
30
|
+
data: unknown,
|
|
31
|
+
init?: { status?: number; headers?: Record<string, string> },
|
|
32
|
+
): Response {
|
|
33
|
+
const headers = {
|
|
34
|
+
'Content-Type': 'application/json',
|
|
35
|
+
...(init?.headers || {}),
|
|
36
|
+
}
|
|
37
|
+
return new Response(JSON.stringify(data), {
|
|
38
|
+
status: init?.status || 200,
|
|
39
|
+
headers,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Project Scan ────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
async function handleProjectScan(
|
|
46
|
+
req: Request,
|
|
47
|
+
cors: Record<string, string>,
|
|
48
|
+
): Promise<Response> {
|
|
49
|
+
if (req.method !== 'POST') {
|
|
50
|
+
return json({ error: 'Method not allowed' }, { status: 405, headers: cors })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let body: { projectRoot?: string }
|
|
54
|
+
try {
|
|
55
|
+
body = await req.json()
|
|
56
|
+
} catch {
|
|
57
|
+
return json({ error: 'Invalid JSON body' }, { status: 400, headers: cors })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { projectRoot } = body
|
|
61
|
+
if (!projectRoot || typeof projectRoot !== 'string') {
|
|
62
|
+
return json(
|
|
63
|
+
{ error: 'projectRoot is required' },
|
|
64
|
+
{ status: 400, headers: cors },
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const rootError = validateProjectRoot(projectRoot)
|
|
69
|
+
if (rootError) {
|
|
70
|
+
return json({ error: rootError }, { status: 400, headers: cors })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const resolved = path.resolve(projectRoot)
|
|
74
|
+
const pkgPath = path.join(resolved, 'package.json')
|
|
75
|
+
if (!existsSync(pkgPath)) {
|
|
76
|
+
return json(
|
|
77
|
+
{ error: 'Not a valid project directory — no package.json found' },
|
|
78
|
+
{ status: 400, headers: cors },
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const result = scanProject(resolved)
|
|
83
|
+
return json(result, { headers: cors })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── CSS Variables Scan ─────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
const CSS_VAR_SKIP_DIRS = new Set([
|
|
89
|
+
'node_modules',
|
|
90
|
+
'.next',
|
|
91
|
+
'dist',
|
|
92
|
+
'build',
|
|
93
|
+
'.git',
|
|
94
|
+
'__tests__',
|
|
95
|
+
'__mocks__',
|
|
96
|
+
'.turbo',
|
|
97
|
+
'.vercel',
|
|
98
|
+
'coverage',
|
|
99
|
+
'.cache',
|
|
100
|
+
'.output',
|
|
101
|
+
])
|
|
102
|
+
const CSS_EXTENSIONS = new Set(['.css', '.scss', '.less'])
|
|
103
|
+
const MAX_SCAN_FILES = 2000
|
|
104
|
+
const MAX_FILE_SIZE = 512 * 1024
|
|
105
|
+
const CSS_VAR_RE = /^\s*(--[\w-]+)\s*:\s*([^;]+);/gm
|
|
106
|
+
|
|
107
|
+
const FRAMEWORK_PREFIXES = [
|
|
108
|
+
'--tw-',
|
|
109
|
+
'--next-',
|
|
110
|
+
'--radix-',
|
|
111
|
+
'--chakra-',
|
|
112
|
+
'--mantine-',
|
|
113
|
+
'--mui-',
|
|
114
|
+
'--framer-',
|
|
115
|
+
'--sb-',
|
|
116
|
+
'--css-interop-',
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
interface ScannedVariable {
|
|
120
|
+
value: string
|
|
121
|
+
resolvedValue: string
|
|
122
|
+
selector: string
|
|
123
|
+
source: string
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function walkForCSS(
|
|
127
|
+
dir: string,
|
|
128
|
+
projectRoot: string,
|
|
129
|
+
results: Map<string, ScannedVariable>,
|
|
130
|
+
counter: { count: number },
|
|
131
|
+
): void {
|
|
132
|
+
if (counter.count >= MAX_SCAN_FILES) return
|
|
133
|
+
let entries: import('node:fs').Dirent[]
|
|
134
|
+
try {
|
|
135
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
136
|
+
} catch {
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
if (counter.count >= MAX_SCAN_FILES) return
|
|
142
|
+
if (entry.name.startsWith('.')) continue
|
|
143
|
+
if (CSS_VAR_SKIP_DIRS.has(entry.name)) continue
|
|
144
|
+
const fullPath = path.join(dir, entry.name)
|
|
145
|
+
|
|
146
|
+
if (entry.isDirectory()) {
|
|
147
|
+
walkForCSS(fullPath, projectRoot, results, counter)
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
if (!entry.isFile()) continue
|
|
151
|
+
counter.count++
|
|
152
|
+
|
|
153
|
+
const ext = path.extname(entry.name)
|
|
154
|
+
if (!CSS_EXTENSIONS.has(ext)) continue
|
|
155
|
+
|
|
156
|
+
let content: string
|
|
157
|
+
try {
|
|
158
|
+
const stat = statSync(fullPath)
|
|
159
|
+
if (stat.size > MAX_FILE_SIZE) continue
|
|
160
|
+
content = readFileSync(fullPath, 'utf-8')
|
|
161
|
+
} catch {
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const relativePath = path.relative(projectRoot, fullPath)
|
|
166
|
+
CSS_VAR_RE.lastIndex = 0
|
|
167
|
+
let match: RegExpExecArray | null
|
|
168
|
+
while ((match = CSS_VAR_RE.exec(content)) !== null) {
|
|
169
|
+
const name = match[1].trim()
|
|
170
|
+
const rawValue = match[2].trim()
|
|
171
|
+
if (FRAMEWORK_PREFIXES.some((p) => name.startsWith(p))) continue
|
|
172
|
+
if (!results.has(name)) {
|
|
173
|
+
results.set(name, {
|
|
174
|
+
value: rawValue,
|
|
175
|
+
resolvedValue: rawValue,
|
|
176
|
+
selector: ':root',
|
|
177
|
+
source: relativePath,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function handleCSSVariablesScan(
|
|
185
|
+
req: Request,
|
|
186
|
+
cors: Record<string, string>,
|
|
187
|
+
): Promise<Response> {
|
|
188
|
+
if (req.method !== 'POST')
|
|
189
|
+
return json({ error: 'Method not allowed' }, { status: 405, headers: cors })
|
|
190
|
+
|
|
191
|
+
let body: { projectRoot?: string }
|
|
192
|
+
try {
|
|
193
|
+
body = await req.json()
|
|
194
|
+
} catch {
|
|
195
|
+
return json({ error: 'Invalid JSON body' }, { status: 400, headers: cors })
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const { projectRoot } = body
|
|
199
|
+
if (!projectRoot || typeof projectRoot !== 'string')
|
|
200
|
+
return json(
|
|
201
|
+
{ error: 'projectRoot is required' },
|
|
202
|
+
{ status: 400, headers: cors },
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
const rootError = validateProjectRoot(projectRoot)
|
|
206
|
+
if (rootError)
|
|
207
|
+
return json({ error: rootError }, { status: 400, headers: cors })
|
|
208
|
+
|
|
209
|
+
const resolved = path.resolve(projectRoot)
|
|
210
|
+
try {
|
|
211
|
+
const cssResults = new Map<string, ScannedVariable>()
|
|
212
|
+
const counter = { count: 0 }
|
|
213
|
+
walkForCSS(resolved, resolved, cssResults, counter)
|
|
214
|
+
|
|
215
|
+
const definitions: Record<
|
|
216
|
+
string,
|
|
217
|
+
{ value: string; resolvedValue: string; selector: string }
|
|
218
|
+
> = {}
|
|
219
|
+
for (const [name, def] of cssResults) {
|
|
220
|
+
definitions[name] = {
|
|
221
|
+
value: def.value,
|
|
222
|
+
resolvedValue: def.resolvedValue,
|
|
223
|
+
selector: def.selector,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return json(
|
|
228
|
+
{
|
|
229
|
+
definitions,
|
|
230
|
+
count: Object.keys(definitions).length,
|
|
231
|
+
filesScanned: counter.count,
|
|
232
|
+
},
|
|
233
|
+
{ headers: cors },
|
|
234
|
+
)
|
|
235
|
+
} catch (error) {
|
|
236
|
+
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
237
|
+
return json(
|
|
238
|
+
{ error: 'Scan failed', details: message },
|
|
239
|
+
{ status: 500, headers: cors },
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ─── Tailwind Config Scan ───────────────────────────────────
|
|
245
|
+
|
|
246
|
+
const TW_CONFIG_FILES = [
|
|
247
|
+
'tailwind.config.ts',
|
|
248
|
+
'tailwind.config.js',
|
|
249
|
+
'tailwind.config.mjs',
|
|
250
|
+
'tailwind.config.cjs',
|
|
251
|
+
]
|
|
252
|
+
const MAX_CONFIG_SIZE = 256 * 1024
|
|
253
|
+
|
|
254
|
+
function kebab(str: string): string {
|
|
255
|
+
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function parseColorBlock(
|
|
259
|
+
body: string,
|
|
260
|
+
prefix: string,
|
|
261
|
+
results: Record<
|
|
262
|
+
string,
|
|
263
|
+
{ value: string; resolvedValue: string; selector: string }
|
|
264
|
+
>,
|
|
265
|
+
filePath: string,
|
|
266
|
+
): void {
|
|
267
|
+
const nestedRe = /(\w[\w-]*)\s*:\s*\{([^}]*)\}/g
|
|
268
|
+
const nestedKeys = new Set<string>()
|
|
269
|
+
let nestedMatch: RegExpExecArray | null
|
|
270
|
+
nestedRe.lastIndex = 0
|
|
271
|
+
while ((nestedMatch = nestedRe.exec(body)) !== null) {
|
|
272
|
+
nestedKeys.add(nestedMatch[1])
|
|
273
|
+
const nestedPrefix = prefix
|
|
274
|
+
? `${prefix}-${kebab(nestedMatch[1])}`
|
|
275
|
+
: kebab(nestedMatch[1])
|
|
276
|
+
parseColorBlock(nestedMatch[2], nestedPrefix, results, filePath)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const entryRe = /(\w[\w-]*)\s*:\s*(?:'([^']*)'|"([^"]*)")/g
|
|
280
|
+
let entryMatch: RegExpExecArray | null
|
|
281
|
+
entryRe.lastIndex = 0
|
|
282
|
+
while ((entryMatch = entryRe.exec(body)) !== null) {
|
|
283
|
+
const key = entryMatch[1]
|
|
284
|
+
if (nestedKeys.has(key)) continue
|
|
285
|
+
const value = entryMatch[2] ?? entryMatch[3] ?? ''
|
|
286
|
+
if (!value) continue
|
|
287
|
+
if (
|
|
288
|
+
value.includes('(') &&
|
|
289
|
+
!value.startsWith('rgb') &&
|
|
290
|
+
!value.startsWith('hsl') &&
|
|
291
|
+
!value.startsWith('oklch')
|
|
292
|
+
)
|
|
293
|
+
continue
|
|
294
|
+
const varName = prefix ? `--${prefix}-${kebab(key)}` : `--${kebab(key)}`
|
|
295
|
+
results[varName] = {
|
|
296
|
+
value,
|
|
297
|
+
resolvedValue: value,
|
|
298
|
+
selector: `tailwind:${filePath}`,
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function handleTailwindConfigScan(
|
|
304
|
+
req: Request,
|
|
305
|
+
cors: Record<string, string>,
|
|
306
|
+
): Promise<Response> {
|
|
307
|
+
if (req.method !== 'POST')
|
|
308
|
+
return json({ error: 'Method not allowed' }, { status: 405, headers: cors })
|
|
309
|
+
|
|
310
|
+
let body: { projectRoot?: string }
|
|
311
|
+
try {
|
|
312
|
+
body = await req.json()
|
|
313
|
+
} catch {
|
|
314
|
+
return json({ error: 'Invalid JSON body' }, { status: 400, headers: cors })
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const { projectRoot } = body
|
|
318
|
+
if (!projectRoot || typeof projectRoot !== 'string')
|
|
319
|
+
return json(
|
|
320
|
+
{ error: 'projectRoot is required' },
|
|
321
|
+
{ status: 400, headers: cors },
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
const rootError = validateProjectRoot(projectRoot)
|
|
325
|
+
if (rootError)
|
|
326
|
+
return json({ error: rootError }, { status: 400, headers: cors })
|
|
327
|
+
|
|
328
|
+
const resolved = path.resolve(projectRoot)
|
|
329
|
+
|
|
330
|
+
let configPath: string | null = null
|
|
331
|
+
for (const name of TW_CONFIG_FILES) {
|
|
332
|
+
const candidate = path.join(resolved, name)
|
|
333
|
+
if (existsSync(candidate)) {
|
|
334
|
+
configPath = candidate
|
|
335
|
+
break
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (!configPath)
|
|
340
|
+
return json({ definitions: {}, count: 0, found: false }, { headers: cors })
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
const raw = readFileSync(configPath)
|
|
344
|
+
if (raw.length > MAX_CONFIG_SIZE)
|
|
345
|
+
return json(
|
|
346
|
+
{ error: 'Config file too large' },
|
|
347
|
+
{ status: 400, headers: cors },
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
const content = raw.toString('utf-8')
|
|
351
|
+
const cleaned = content
|
|
352
|
+
.replace(/\/\/.*$/gm, '')
|
|
353
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
354
|
+
const relativePath = path.relative(resolved, configPath)
|
|
355
|
+
const definitions: Record<
|
|
356
|
+
string,
|
|
357
|
+
{ value: string; resolvedValue: string; selector: string }
|
|
358
|
+
> = {}
|
|
359
|
+
|
|
360
|
+
const colorsBlockRe = /colors\s*:\s*\{([^]*?)\}(?:\s*,|\s*\})/g
|
|
361
|
+
let blockMatch: RegExpExecArray | null
|
|
362
|
+
colorsBlockRe.lastIndex = 0
|
|
363
|
+
while ((blockMatch = colorsBlockRe.exec(cleaned)) !== null) {
|
|
364
|
+
parseColorBlock(blockMatch[1], '', definitions, relativePath)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return json(
|
|
368
|
+
{
|
|
369
|
+
definitions,
|
|
370
|
+
count: Object.keys(definitions).length,
|
|
371
|
+
found: true,
|
|
372
|
+
configFile: relativePath,
|
|
373
|
+
},
|
|
374
|
+
{ headers: cors },
|
|
375
|
+
)
|
|
376
|
+
} catch (error) {
|
|
377
|
+
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
378
|
+
return json(
|
|
379
|
+
{ error: 'Parse failed', details: message },
|
|
380
|
+
{ status: 500, headers: cors },
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ─── Claude Status ───────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
async function handleClaudeStatus(
|
|
388
|
+
req: Request,
|
|
389
|
+
cors: Record<string, string>,
|
|
390
|
+
): Promise<Response> {
|
|
391
|
+
if (req.method === 'GET') {
|
|
392
|
+
try {
|
|
393
|
+
const claudeBin = getClaudeBin()
|
|
394
|
+
const { stdout } = await execFileAsync(claudeBin, ['--version'], {
|
|
395
|
+
timeout: 10_000,
|
|
396
|
+
})
|
|
397
|
+
return json(
|
|
398
|
+
{ available: true, version: stdout.trim() },
|
|
399
|
+
{ headers: cors },
|
|
400
|
+
)
|
|
401
|
+
} catch (err) {
|
|
402
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
403
|
+
return json(
|
|
404
|
+
{
|
|
405
|
+
available: false,
|
|
406
|
+
error: `claude CLI not found: ${msg.slice(0, 200)}`,
|
|
407
|
+
},
|
|
408
|
+
{ headers: cors },
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (req.method === 'POST') {
|
|
414
|
+
let body: { projectRoot?: string }
|
|
415
|
+
try {
|
|
416
|
+
body = await req.json()
|
|
417
|
+
} catch {
|
|
418
|
+
return json(
|
|
419
|
+
{ error: 'Invalid JSON body' },
|
|
420
|
+
{ status: 400, headers: cors },
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const { projectRoot } = body
|
|
425
|
+
if (!projectRoot || typeof projectRoot !== 'string') {
|
|
426
|
+
return json(
|
|
427
|
+
{ error: 'projectRoot is required' },
|
|
428
|
+
{ status: 400, headers: cors },
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const rootError = validateProjectRoot(projectRoot)
|
|
433
|
+
if (rootError) {
|
|
434
|
+
return json({ error: rootError }, { status: 400, headers: cors })
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const resolved = path.resolve(projectRoot)
|
|
438
|
+
return json({ valid: true, resolved }, { headers: cors })
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return json({ error: 'Method not allowed' }, { status: 405, headers: cors })
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ─── Claude Analyze ──────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
function parseDiffs(output: string) {
|
|
447
|
+
const diffs: Array<{
|
|
448
|
+
filePath: string
|
|
449
|
+
hunks: Array<{
|
|
450
|
+
header: string
|
|
451
|
+
lines: Array<{ type: string; content: string }>
|
|
452
|
+
}>
|
|
453
|
+
linesAdded: number
|
|
454
|
+
linesRemoved: number
|
|
455
|
+
}> = []
|
|
456
|
+
const lines = output.split('\n')
|
|
457
|
+
let currentDiff: (typeof diffs)[0] | null = null
|
|
458
|
+
let currentHunk: (typeof diffs)[0]['hunks'][0] | null = null
|
|
459
|
+
|
|
460
|
+
for (const line of lines) {
|
|
461
|
+
if (line.startsWith('+++ b/') || line.startsWith('+++ ')) {
|
|
462
|
+
const filePath = line.startsWith('+++ b/')
|
|
463
|
+
? line.slice(6).trim()
|
|
464
|
+
: line.slice(4).trim()
|
|
465
|
+
if (filePath && filePath !== '/dev/null') {
|
|
466
|
+
currentDiff = { filePath, hunks: [], linesAdded: 0, linesRemoved: 0 }
|
|
467
|
+
diffs.push(currentDiff)
|
|
468
|
+
currentHunk = null
|
|
469
|
+
}
|
|
470
|
+
continue
|
|
471
|
+
}
|
|
472
|
+
if (line.startsWith('--- a/') || line.startsWith('--- ')) continue
|
|
473
|
+
|
|
474
|
+
const hunkMatch = line.match(/^@@\s+[^@]+\s+@@(.*)?$/)
|
|
475
|
+
if (hunkMatch && currentDiff) {
|
|
476
|
+
currentHunk = { header: line, lines: [] }
|
|
477
|
+
currentDiff.hunks.push(currentHunk)
|
|
478
|
+
continue
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (currentHunk && currentDiff) {
|
|
482
|
+
if (line.startsWith('+')) {
|
|
483
|
+
currentHunk.lines.push({ type: 'addition', content: line.slice(1) })
|
|
484
|
+
currentDiff.linesAdded++
|
|
485
|
+
} else if (line.startsWith('-')) {
|
|
486
|
+
currentHunk.lines.push({ type: 'removal', content: line.slice(1) })
|
|
487
|
+
currentDiff.linesRemoved++
|
|
488
|
+
} else if (line.startsWith(' ')) {
|
|
489
|
+
currentHunk.lines.push({ type: 'context', content: line.slice(1) })
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return diffs
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function buildSummary(diffs: ReturnType<typeof parseDiffs>): string {
|
|
497
|
+
if (diffs.length === 0)
|
|
498
|
+
return 'No file changes detected in the analysis output.'
|
|
499
|
+
const totalAdded = diffs.reduce((sum, d) => sum + d.linesAdded, 0)
|
|
500
|
+
const totalRemoved = diffs.reduce((sum, d) => sum + d.linesRemoved, 0)
|
|
501
|
+
const fileList = diffs.map((d) => d.filePath).join(', ')
|
|
502
|
+
return `${diffs.length} file${diffs.length !== 1 ? 's' : ''} to modify: ${fileList}. +${totalAdded} / -${totalRemoved} lines.`
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function handleClaudeAnalyze(
|
|
506
|
+
req: Request,
|
|
507
|
+
cors: Record<string, string>,
|
|
508
|
+
): Promise<Response> {
|
|
509
|
+
if (req.method !== 'POST') {
|
|
510
|
+
return json({ error: 'Method not allowed' }, { status: 405, headers: cors })
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
let body: { changelog?: string; projectRoot?: string; smartPrompt?: string }
|
|
514
|
+
try {
|
|
515
|
+
body = await req.json()
|
|
516
|
+
} catch {
|
|
517
|
+
return json({ error: 'Invalid JSON body' }, { status: 400, headers: cors })
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const { changelog, projectRoot, smartPrompt } = body
|
|
521
|
+
const hasSmartPrompt =
|
|
522
|
+
typeof smartPrompt === 'string' && smartPrompt.length > 0
|
|
523
|
+
|
|
524
|
+
if (
|
|
525
|
+
!hasSmartPrompt &&
|
|
526
|
+
(!changelog || typeof changelog !== 'string' || changelog.length === 0)
|
|
527
|
+
) {
|
|
528
|
+
return json(
|
|
529
|
+
{ error: 'changelog is required' },
|
|
530
|
+
{ status: 400, headers: cors },
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
if (!projectRoot || typeof projectRoot !== 'string') {
|
|
534
|
+
return json(
|
|
535
|
+
{ error: 'projectRoot is required' },
|
|
536
|
+
{ status: 400, headers: cors },
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const rootError = validateProjectRoot(projectRoot)
|
|
541
|
+
if (rootError) {
|
|
542
|
+
return json({ error: rootError }, { status: 400, headers: cors })
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const resolvedRoot = path.resolve(projectRoot)
|
|
546
|
+
|
|
547
|
+
let prompt: string
|
|
548
|
+
if (hasSmartPrompt) {
|
|
549
|
+
const sanitized = stripControlChars(smartPrompt).slice(
|
|
550
|
+
0,
|
|
551
|
+
MAX_CHANGELOG_BYTES,
|
|
552
|
+
)
|
|
553
|
+
prompt = buildSmartAnalysisPrompt(sanitized, resolvedRoot)
|
|
554
|
+
} else {
|
|
555
|
+
const sanitizedChangelog = stripControlChars(changelog!).slice(
|
|
556
|
+
0,
|
|
557
|
+
MAX_CHANGELOG_BYTES,
|
|
558
|
+
)
|
|
559
|
+
prompt = [
|
|
560
|
+
'You are a code assistant. A user has made visual changes in a design editor.',
|
|
561
|
+
'Below is the changelog of changes they made. Analyze the project source code',
|
|
562
|
+
'and generate unified diffs that would apply these visual changes to the source files.',
|
|
563
|
+
'',
|
|
564
|
+
'IMPORTANT:',
|
|
565
|
+
'- Output ONLY unified diff format (diff --git a/... b/...)',
|
|
566
|
+
'- Use paths relative to the project root',
|
|
567
|
+
'- Do not include any explanatory text outside of the diff',
|
|
568
|
+
'- Make minimal, targeted changes',
|
|
569
|
+
'',
|
|
570
|
+
'--- CHANGELOG START ---',
|
|
571
|
+
sanitizedChangelog,
|
|
572
|
+
'--- CHANGELOG END ---',
|
|
573
|
+
].join('\n')
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Check if client wants SSE
|
|
577
|
+
const wantsStream = req.headers.get('accept')?.includes('text/event-stream')
|
|
578
|
+
|
|
579
|
+
if (wantsStream) {
|
|
580
|
+
const encoder = new TextEncoder()
|
|
581
|
+
const stream = new ReadableStream({
|
|
582
|
+
start(controller) {
|
|
583
|
+
const enqueue = (event: string, data: Record<string, unknown>) => {
|
|
584
|
+
try {
|
|
585
|
+
controller.enqueue(
|
|
586
|
+
encoder.encode(
|
|
587
|
+
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`,
|
|
588
|
+
),
|
|
589
|
+
)
|
|
590
|
+
} catch {
|
|
591
|
+
/* closed */
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
spawnClaudeStreaming(
|
|
596
|
+
['--print', '--allowedTools', 'Read', '-p', prompt],
|
|
597
|
+
{
|
|
598
|
+
cwd: resolvedRoot,
|
|
599
|
+
timeout: TIMEOUT_MS,
|
|
600
|
+
onStderr: (line) => enqueue('stderr', { line }),
|
|
601
|
+
},
|
|
602
|
+
)
|
|
603
|
+
.then((result) => {
|
|
604
|
+
if (result.exitCode !== 0) {
|
|
605
|
+
enqueue('error', {
|
|
606
|
+
code: 'CLI_ERROR',
|
|
607
|
+
message: 'Claude CLI exited with an error',
|
|
608
|
+
})
|
|
609
|
+
} else {
|
|
610
|
+
const diffs = parseDiffs(result.stdout)
|
|
611
|
+
const summary = buildSummary(diffs)
|
|
612
|
+
const sessionId = crypto.randomUUID()
|
|
613
|
+
enqueue('result', { sessionId, diffs, summary })
|
|
614
|
+
}
|
|
615
|
+
})
|
|
616
|
+
.catch((err) => {
|
|
617
|
+
const message = err instanceof Error ? err.message : 'Unknown error'
|
|
618
|
+
enqueue('error', {
|
|
619
|
+
code: message === 'TIMEOUT' ? 'TIMEOUT' : 'SPAWN_ERROR',
|
|
620
|
+
message,
|
|
621
|
+
})
|
|
622
|
+
})
|
|
623
|
+
.finally(() => {
|
|
624
|
+
enqueue('done', {})
|
|
625
|
+
controller.close()
|
|
626
|
+
})
|
|
627
|
+
},
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
return new Response(stream, {
|
|
631
|
+
headers: {
|
|
632
|
+
...cors,
|
|
633
|
+
'Content-Type': 'text/event-stream',
|
|
634
|
+
'Cache-Control': 'no-cache',
|
|
635
|
+
Connection: 'keep-alive',
|
|
636
|
+
},
|
|
637
|
+
})
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// JSON fallback
|
|
641
|
+
try {
|
|
642
|
+
const result = await spawnClaude(
|
|
643
|
+
['--print', '--allowedTools', 'Read', '-p', prompt],
|
|
644
|
+
{ cwd: resolvedRoot, timeout: TIMEOUT_MS },
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
if (result.exitCode !== 0) {
|
|
648
|
+
const stderr = result.stderr.trim()
|
|
649
|
+
if (isAuthError(stderr)) {
|
|
650
|
+
return json(
|
|
651
|
+
{
|
|
652
|
+
error:
|
|
653
|
+
'Claude CLI is not authenticated. Run `claude login` in your terminal.',
|
|
654
|
+
authRequired: true,
|
|
655
|
+
},
|
|
656
|
+
{ status: 401, headers: cors },
|
|
657
|
+
)
|
|
658
|
+
}
|
|
659
|
+
return json(
|
|
660
|
+
{
|
|
661
|
+
error: 'Claude CLI exited with an error',
|
|
662
|
+
details: stderr || 'Unknown CLI error',
|
|
663
|
+
},
|
|
664
|
+
{ status: 500, headers: cors },
|
|
665
|
+
)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const sessionIdMatch = result.stderr.match(/session[:\s]+([a-f0-9-]+)/i)
|
|
669
|
+
const sessionId = sessionIdMatch ? sessionIdMatch[1] : crypto.randomUUID()
|
|
670
|
+
const diffs = parseDiffs(result.stdout)
|
|
671
|
+
const summary = buildSummary(diffs)
|
|
672
|
+
return json({ sessionId, diffs, summary }, { headers: cors })
|
|
673
|
+
} catch (error) {
|
|
674
|
+
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
675
|
+
if (message === 'TIMEOUT') {
|
|
676
|
+
return json(
|
|
677
|
+
{ error: 'Claude CLI timed out after 120 seconds' },
|
|
678
|
+
{ status: 504, headers: cors },
|
|
679
|
+
)
|
|
680
|
+
}
|
|
681
|
+
return json(
|
|
682
|
+
{ error: 'Failed to run Claude CLI', details: message },
|
|
683
|
+
{ status: 500, headers: cors },
|
|
684
|
+
)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ─── Claude Apply ────────────────────────────────────────────
|
|
689
|
+
|
|
690
|
+
function extractModifiedFiles(output: string): string[] {
|
|
691
|
+
const files = new Set<string>()
|
|
692
|
+
const lines = output.split('\n')
|
|
693
|
+
for (const line of lines) {
|
|
694
|
+
const editMatch = line.match(
|
|
695
|
+
/(?:Edit(?:ed)?|Modified|Updated|Changed)\s+([^\s]+\.\w+)/i,
|
|
696
|
+
)
|
|
697
|
+
if (editMatch) files.add(editMatch[1])
|
|
698
|
+
if (line.startsWith('+++ b/')) {
|
|
699
|
+
const filePath = line.slice(6).trim()
|
|
700
|
+
if (filePath && filePath !== '/dev/null') files.add(filePath)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return Array.from(files)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function handleClaudeApply(
|
|
707
|
+
req: Request,
|
|
708
|
+
cors: Record<string, string>,
|
|
709
|
+
): Promise<Response> {
|
|
710
|
+
if (req.method !== 'POST') {
|
|
711
|
+
return json({ error: 'Method not allowed' }, { status: 405, headers: cors })
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
let body: { sessionId?: string; projectRoot?: string }
|
|
715
|
+
try {
|
|
716
|
+
body = await req.json()
|
|
717
|
+
} catch {
|
|
718
|
+
return json({ error: 'Invalid JSON body' }, { status: 400, headers: cors })
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const { sessionId, projectRoot } = body
|
|
722
|
+
|
|
723
|
+
if (!sessionId || typeof sessionId !== 'string') {
|
|
724
|
+
return json(
|
|
725
|
+
{ error: 'sessionId is required' },
|
|
726
|
+
{ status: 400, headers: cors },
|
|
727
|
+
)
|
|
728
|
+
}
|
|
729
|
+
if (!/^[a-f0-9-]+$/i.test(sessionId)) {
|
|
730
|
+
return json(
|
|
731
|
+
{ error: 'sessionId contains invalid characters' },
|
|
732
|
+
{ status: 400, headers: cors },
|
|
733
|
+
)
|
|
734
|
+
}
|
|
735
|
+
if (!projectRoot || typeof projectRoot !== 'string') {
|
|
736
|
+
return json(
|
|
737
|
+
{ error: 'projectRoot is required' },
|
|
738
|
+
{ status: 400, headers: cors },
|
|
739
|
+
)
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const rootError = validateProjectRoot(projectRoot)
|
|
743
|
+
if (rootError) {
|
|
744
|
+
return json({ error: rootError }, { status: 400, headers: cors })
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const resolvedRoot = path.resolve(projectRoot)
|
|
748
|
+
|
|
749
|
+
try {
|
|
750
|
+
const result = await spawnClaude(
|
|
751
|
+
[
|
|
752
|
+
'--resume',
|
|
753
|
+
sessionId,
|
|
754
|
+
'--allowedTools',
|
|
755
|
+
'Read,Edit',
|
|
756
|
+
'--print',
|
|
757
|
+
'-p',
|
|
758
|
+
'Apply the changes discussed in the previous analysis. Edit the source files to implement all the visual changes from the changelog.',
|
|
759
|
+
],
|
|
760
|
+
{ cwd: resolvedRoot, timeout: TIMEOUT_MS },
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
if (result.exitCode !== 0) {
|
|
764
|
+
return json(
|
|
765
|
+
{
|
|
766
|
+
error: 'Claude CLI exited with an error',
|
|
767
|
+
details: result.stderr.trim() || 'Unknown CLI error',
|
|
768
|
+
},
|
|
769
|
+
{ status: 500, headers: cors },
|
|
770
|
+
)
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const combinedOutput = result.stdout + '\n' + result.stderr
|
|
774
|
+
const filesModified = extractModifiedFiles(combinedOutput)
|
|
775
|
+
const fileCount = filesModified.length
|
|
776
|
+
const summary =
|
|
777
|
+
fileCount > 0
|
|
778
|
+
? `Successfully modified ${fileCount} file${fileCount !== 1 ? 's' : ''}: ${filesModified.join(', ')}`
|
|
779
|
+
: result.stdout.trim().slice(0, 200) || 'Changes applied.'
|
|
780
|
+
|
|
781
|
+
return json({ success: true, filesModified, summary }, { headers: cors })
|
|
782
|
+
} catch (error) {
|
|
783
|
+
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
784
|
+
if (message === 'TIMEOUT') {
|
|
785
|
+
return json(
|
|
786
|
+
{ error: 'Claude CLI timed out after 120 seconds' },
|
|
787
|
+
{ status: 504, headers: cors },
|
|
788
|
+
)
|
|
789
|
+
}
|
|
790
|
+
return json(
|
|
791
|
+
{ error: 'Failed to run Claude CLI', details: message },
|
|
792
|
+
{ status: 500, headers: cors },
|
|
793
|
+
)
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ─── Folder Picker ───────────────────────────────────────────
|
|
798
|
+
|
|
799
|
+
async function handlePickFolder(
|
|
800
|
+
cors: Record<string, string>,
|
|
801
|
+
): Promise<Response> {
|
|
802
|
+
const platform = process.platform
|
|
803
|
+
|
|
804
|
+
try {
|
|
805
|
+
let folderPath: string | null = null
|
|
806
|
+
|
|
807
|
+
if (platform === 'darwin') {
|
|
808
|
+
const { stdout } = await execFileAsync(
|
|
809
|
+
'osascript',
|
|
810
|
+
[
|
|
811
|
+
'-e',
|
|
812
|
+
'set theFolder to POSIX path of (choose folder with prompt "Select your project root folder")',
|
|
813
|
+
'-e',
|
|
814
|
+
'return theFolder',
|
|
815
|
+
],
|
|
816
|
+
{ timeout: 60_000 },
|
|
817
|
+
)
|
|
818
|
+
folderPath = stdout.trim().replace(/\/$/, '')
|
|
819
|
+
} else if (platform === 'linux') {
|
|
820
|
+
const { stdout } = await execFileAsync(
|
|
821
|
+
'zenity',
|
|
822
|
+
[
|
|
823
|
+
'--file-selection',
|
|
824
|
+
'--directory',
|
|
825
|
+
'--title=Select your project root folder',
|
|
826
|
+
],
|
|
827
|
+
{ timeout: 60_000 },
|
|
828
|
+
)
|
|
829
|
+
folderPath = stdout.trim()
|
|
830
|
+
} else {
|
|
831
|
+
return json(
|
|
832
|
+
{ error: 'Folder picker not supported on this platform' },
|
|
833
|
+
{ status: 501, headers: cors },
|
|
834
|
+
)
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (!folderPath) {
|
|
838
|
+
return json({ cancelled: true }, { headers: cors })
|
|
839
|
+
}
|
|
840
|
+
return json({ path: folderPath }, { headers: cors })
|
|
841
|
+
} catch (err) {
|
|
842
|
+
const code = (err as { code?: number }).code
|
|
843
|
+
if (code === 1) {
|
|
844
|
+
return json({ cancelled: true }, { headers: cors })
|
|
845
|
+
}
|
|
846
|
+
return json(
|
|
847
|
+
{ error: 'Failed to open folder picker' },
|
|
848
|
+
{ status: 500, headers: cors },
|
|
849
|
+
)
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ─── Main Router ─────────────────────────────────────────────
|
|
854
|
+
|
|
855
|
+
export async function handleAPI(
|
|
856
|
+
req: Request,
|
|
857
|
+
url: URL,
|
|
858
|
+
cors: Record<string, string>,
|
|
859
|
+
): Promise<Response> {
|
|
860
|
+
const pathname = url.pathname
|
|
861
|
+
|
|
862
|
+
if (pathname === '/api/project-scan') {
|
|
863
|
+
return handleProjectScan(req, cors)
|
|
864
|
+
}
|
|
865
|
+
if (pathname === '/api/project-scan/css-variables') {
|
|
866
|
+
return handleCSSVariablesScan(req, cors)
|
|
867
|
+
}
|
|
868
|
+
if (pathname === '/api/project-scan/tailwind-config') {
|
|
869
|
+
return handleTailwindConfigScan(req, cors)
|
|
870
|
+
}
|
|
871
|
+
if (pathname === '/api/claude/status') {
|
|
872
|
+
return handleClaudeStatus(req, cors)
|
|
873
|
+
}
|
|
874
|
+
if (pathname === '/api/claude/analyze') {
|
|
875
|
+
return handleClaudeAnalyze(req, cors)
|
|
876
|
+
}
|
|
877
|
+
if (pathname === '/api/claude/apply') {
|
|
878
|
+
return handleClaudeApply(req, cors)
|
|
879
|
+
}
|
|
880
|
+
if (pathname === '/api/claude/pick-folder') {
|
|
881
|
+
return handlePickFolder(cors)
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
return json({ error: 'Not found' }, { status: 404, headers: cors })
|
|
885
|
+
}
|