feat: add scaffold router and project template selection step

Add a new scaffold system that lets users choose a project template
(blank or agentic starter) during project creation. This inserts a
template selection step between folder selection and spec method choice.

Backend:
- New server/routers/scaffold.py with SSE streaming endpoint for
  running hardcoded scaffold commands (npx create-agentic-app)
- Path validation, security checks, and cross-platform npx resolution
- Registered scaffold_router in server/main.py and routers/__init__.py

Frontend (NewProjectModal.tsx):
- New "template" step with Blank Project and Agentic Starter cards
- Real-time scaffold output streaming with auto-scroll log viewer
- Success, error, and retry states with proper back-navigation
- Updated step flow: name → folder → template → method → chat/complete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Auto
2026-02-26 13:18:55 +02:00
parent 472064c3da
commit 41c1a14ae3
4 changed files with 355 additions and 9 deletions

View File

@@ -4,14 +4,15 @@
* Multi-step modal for creating new projects:
* 1. Enter project name
* 2. Select project folder
* 3. Choose spec method (Claude or manual)
* 4a. If Claude: Show SpecCreationChat
* 4b. If manual: Create project and close
* 3. Choose project template (blank or agentic starter)
* 4. Choose spec method (Claude or manual)
* 5a. If Claude: Show SpecCreationChat
* 5b. If manual: Create project and close
*/
import { useState } from 'react'
import { useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Bot, FileEdit, ArrowRight, ArrowLeft, Loader2, CheckCircle2, Folder } from 'lucide-react'
import { Bot, FileEdit, ArrowRight, ArrowLeft, Loader2, CheckCircle2, Folder, Zap, FileCode2, AlertCircle, RotateCcw } from 'lucide-react'
import { useCreateProject } from '../hooks/useProjects'
import { SpecCreationChat } from './SpecCreationChat'
import { FolderBrowser } from './FolderBrowser'
@@ -32,8 +33,9 @@ import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
type InitializerStatus = 'idle' | 'starting' | 'error'
type ScaffoldStatus = 'idle' | 'running' | 'success' | 'error'
type Step = 'name' | 'folder' | 'method' | 'chat' | 'complete'
type Step = 'name' | 'folder' | 'template' | 'method' | 'chat' | 'complete'
type SpecMethod = 'claude' | 'manual'
interface NewProjectModalProps {
@@ -57,6 +59,10 @@ export function NewProjectModal({
const [initializerStatus, setInitializerStatus] = useState<InitializerStatus>('idle')
const [initializerError, setInitializerError] = useState<string | null>(null)
const [yoloModeSelected, setYoloModeSelected] = useState(false)
const [scaffoldStatus, setScaffoldStatus] = useState<ScaffoldStatus>('idle')
const [scaffoldOutput, setScaffoldOutput] = useState<string[]>([])
const [scaffoldError, setScaffoldError] = useState<string | null>(null)
const scaffoldLogRef = useRef<HTMLDivElement>(null)
// Suppress unused variable warning - specMethod may be used in future
void _specMethod
@@ -91,13 +97,84 @@ export function NewProjectModal({
const handleFolderSelect = (path: string) => {
setProjectPath(path)
changeStep('method')
changeStep('template')
}
const handleFolderCancel = () => {
changeStep('name')
}
const handleTemplateSelect = async (choice: 'blank' | 'agentic-starter') => {
if (choice === 'blank') {
changeStep('method')
return
}
if (!projectPath) return
setScaffoldStatus('running')
setScaffoldOutput([])
setScaffoldError(null)
try {
const res = await fetch('/api/scaffold/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template: 'agentic-starter', target_path: projectPath }),
})
if (!res.ok || !res.body) {
setScaffoldStatus('error')
setScaffoldError(`Server error: ${res.status}`)
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
try {
const event = JSON.parse(line.slice(6))
if (event.type === 'output') {
setScaffoldOutput(prev => {
const next = [...prev, event.line]
return next.length > 100 ? next.slice(-100) : next
})
// Auto-scroll
setTimeout(() => scaffoldLogRef.current?.scrollTo(0, scaffoldLogRef.current.scrollHeight), 0)
} else if (event.type === 'complete') {
if (event.success) {
setScaffoldStatus('success')
setTimeout(() => changeStep('method'), 1200)
} else {
setScaffoldStatus('error')
setScaffoldError(`Scaffold exited with code ${event.exit_code}`)
}
} else if (event.type === 'error') {
setScaffoldStatus('error')
setScaffoldError(event.message)
}
} catch {
// skip malformed SSE lines
}
}
}
} catch (err) {
setScaffoldStatus('error')
setScaffoldError(err instanceof Error ? err.message : 'Failed to run scaffold')
}
}
const handleMethodSelect = async (method: SpecMethod) => {
setSpecMethod(method)
@@ -188,13 +265,21 @@ export function NewProjectModal({
setInitializerStatus('idle')
setInitializerError(null)
setYoloModeSelected(false)
setScaffoldStatus('idle')
setScaffoldOutput([])
setScaffoldError(null)
onClose()
}
const handleBack = () => {
if (step === 'method') {
changeStep('folder')
changeStep('template')
setSpecMethod(null)
} else if (step === 'template') {
changeStep('folder')
setScaffoldStatus('idle')
setScaffoldOutput([])
setScaffoldError(null)
} else if (step === 'folder') {
changeStep('name')
setProjectPath(null)
@@ -255,6 +340,7 @@ export function NewProjectModal({
<DialogHeader>
<DialogTitle>
{step === 'name' && 'Create New Project'}
{step === 'template' && 'Choose Project Template'}
{step === 'method' && 'Choose Setup Method'}
{step === 'complete' && 'Project Created!'}
</DialogTitle>
@@ -294,7 +380,127 @@ export function NewProjectModal({
</form>
)}
{/* Step 2: Spec Method */}
{/* Step 2: Project Template */}
{step === 'template' && (
<div className="space-y-4">
{scaffoldStatus === 'idle' && (
<>
<DialogDescription>
Start with a blank project or use a pre-configured template.
</DialogDescription>
<div className="space-y-3">
<Card
className="cursor-pointer hover:border-primary transition-colors"
onClick={() => handleTemplateSelect('blank')}
>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="p-2 bg-secondary rounded-lg">
<FileCode2 size={24} className="text-secondary-foreground" />
</div>
<div className="flex-1">
<span className="font-semibold">Blank Project</span>
<p className="text-sm text-muted-foreground mt-1">
Start from scratch. AutoForge will scaffold your app based on the spec you define.
</p>
</div>
</div>
</CardContent>
</Card>
<Card
className="cursor-pointer hover:border-primary transition-colors"
onClick={() => handleTemplateSelect('agentic-starter')}
>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<Zap size={24} className="text-primary" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold">Agentic Starter</span>
<Badge variant="secondary">Next.js</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
Pre-configured Next.js app with BetterAuth, Drizzle ORM, Postgres, and AI capabilities.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
<DialogFooter className="sm:justify-start">
<Button variant="ghost" onClick={handleBack}>
<ArrowLeft size={16} />
Back
</Button>
</DialogFooter>
</>
)}
{scaffoldStatus === 'running' && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Loader2 size={16} className="animate-spin text-primary" />
<span className="font-medium">Setting up Agentic Starter...</span>
</div>
<div
ref={scaffoldLogRef}
className="bg-muted rounded-lg p-3 max-h-60 overflow-y-auto font-mono text-xs leading-relaxed"
>
{scaffoldOutput.map((line, i) => (
<div key={i} className="whitespace-pre-wrap break-all">{line}</div>
))}
</div>
</div>
)}
{scaffoldStatus === 'success' && (
<div className="text-center py-6">
<div className="inline-flex items-center justify-center w-12 h-12 bg-primary/10 rounded-full mb-3">
<CheckCircle2 size={24} className="text-primary" />
</div>
<p className="font-medium">Template ready!</p>
<p className="text-sm text-muted-foreground mt-1">Proceeding to setup method...</p>
</div>
)}
{scaffoldStatus === 'error' && (
<div className="space-y-3">
<Alert variant="destructive">
<AlertCircle size={16} />
<AlertDescription>
{scaffoldError || 'An unknown error occurred'}
</AlertDescription>
</Alert>
{scaffoldOutput.length > 0 && (
<div className="bg-muted rounded-lg p-3 max-h-40 overflow-y-auto font-mono text-xs leading-relaxed">
{scaffoldOutput.slice(-10).map((line, i) => (
<div key={i} className="whitespace-pre-wrap break-all">{line}</div>
))}
</div>
)}
<DialogFooter className="sm:justify-start gap-2">
<Button variant="ghost" onClick={handleBack}>
<ArrowLeft size={16} />
Back
</Button>
<Button variant="outline" onClick={() => handleTemplateSelect('agentic-starter')}>
<RotateCcw size={16} />
Retry
</Button>
</DialogFooter>
</div>
)}
</div>
)}
{/* Step 3: Spec Method */}
{step === 'method' && (
<div className="space-y-4">
<DialogDescription>