mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-03-17 02:43:09 +00:00
Merge pull request #213 from AutoForgeAI/feat/scaffold-template-selection
feat: add scaffold router and project template selection
This commit is contained in:
@@ -36,6 +36,7 @@ from .routers import (
|
|||||||
features_router,
|
features_router,
|
||||||
filesystem_router,
|
filesystem_router,
|
||||||
projects_router,
|
projects_router,
|
||||||
|
scaffold_router,
|
||||||
schedules_router,
|
schedules_router,
|
||||||
settings_router,
|
settings_router,
|
||||||
spec_creation_router,
|
spec_creation_router,
|
||||||
@@ -169,6 +170,7 @@ app.include_router(filesystem_router)
|
|||||||
app.include_router(assistant_chat_router)
|
app.include_router(assistant_chat_router)
|
||||||
app.include_router(settings_router)
|
app.include_router(settings_router)
|
||||||
app.include_router(terminal_router)
|
app.include_router(terminal_router)
|
||||||
|
app.include_router(scaffold_router)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from .expand_project import router as expand_project_router
|
|||||||
from .features import router as features_router
|
from .features import router as features_router
|
||||||
from .filesystem import router as filesystem_router
|
from .filesystem import router as filesystem_router
|
||||||
from .projects import router as projects_router
|
from .projects import router as projects_router
|
||||||
|
from .scaffold import router as scaffold_router
|
||||||
from .schedules import router as schedules_router
|
from .schedules import router as schedules_router
|
||||||
from .settings import router as settings_router
|
from .settings import router as settings_router
|
||||||
from .spec_creation import router as spec_creation_router
|
from .spec_creation import router as spec_creation_router
|
||||||
@@ -29,4 +30,5 @@ __all__ = [
|
|||||||
"assistant_chat_router",
|
"assistant_chat_router",
|
||||||
"settings_router",
|
"settings_router",
|
||||||
"terminal_router",
|
"terminal_router",
|
||||||
|
"scaffold_router",
|
||||||
]
|
]
|
||||||
|
|||||||
136
server/routers/scaffold.py
Normal file
136
server/routers/scaffold.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""
|
||||||
|
Scaffold Router
|
||||||
|
================
|
||||||
|
|
||||||
|
SSE streaming endpoint for running project scaffold commands.
|
||||||
|
Supports templated project creation (e.g., Next.js agentic starter).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .filesystem import is_path_blocked
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/scaffold", tags=["scaffold"])
|
||||||
|
|
||||||
|
# Hardcoded templates — no arbitrary commands allowed
|
||||||
|
TEMPLATES: dict[str, list[str]] = {
|
||||||
|
"agentic-starter": ["npx", "create-agentic-app@latest", ".", "-y", "-p", "npm", "--skip-git"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ScaffoldRequest(BaseModel):
|
||||||
|
template: str
|
||||||
|
target_path: str
|
||||||
|
|
||||||
|
|
||||||
|
def _sse_event(data: dict) -> str:
|
||||||
|
"""Format a dict as an SSE data line."""
|
||||||
|
return f"data: {json.dumps(data)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream_scaffold(template: str, target_path: str, request: Request):
|
||||||
|
"""Run the scaffold command and yield SSE events."""
|
||||||
|
# Validate template
|
||||||
|
if template not in TEMPLATES:
|
||||||
|
yield _sse_event({"type": "error", "message": f"Unknown template: {template}"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validate path
|
||||||
|
path = Path(target_path)
|
||||||
|
try:
|
||||||
|
path = path.resolve()
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
yield _sse_event({"type": "error", "message": f"Invalid path: {e}"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if is_path_blocked(path):
|
||||||
|
yield _sse_event({"type": "error", "message": "Access to this directory is not allowed"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if not path.exists() or not path.is_dir():
|
||||||
|
yield _sse_event({"type": "error", "message": "Target directory does not exist"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check npx is available
|
||||||
|
npx_name = "npx"
|
||||||
|
if sys.platform == "win32":
|
||||||
|
npx_name = "npx.cmd"
|
||||||
|
|
||||||
|
if not shutil.which(npx_name):
|
||||||
|
yield _sse_event({"type": "error", "message": "npx is not available. Please install Node.js."})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build command
|
||||||
|
argv = list(TEMPLATES[template])
|
||||||
|
if sys.platform == "win32" and not argv[0].lower().endswith(".cmd"):
|
||||||
|
argv[0] = argv[0] + ".cmd"
|
||||||
|
|
||||||
|
process = None
|
||||||
|
try:
|
||||||
|
popen_kwargs: dict = {
|
||||||
|
"stdout": subprocess.PIPE,
|
||||||
|
"stderr": subprocess.STDOUT,
|
||||||
|
"stdin": subprocess.DEVNULL,
|
||||||
|
"cwd": str(path),
|
||||||
|
}
|
||||||
|
if sys.platform == "win32":
|
||||||
|
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||||
|
|
||||||
|
process = subprocess.Popen(argv, **popen_kwargs)
|
||||||
|
logger.info("Scaffold process started: pid=%s, template=%s, path=%s", process.pid, template, target_path)
|
||||||
|
|
||||||
|
# Stream stdout lines
|
||||||
|
assert process.stdout is not None
|
||||||
|
for raw_line in iter(process.stdout.readline, b""):
|
||||||
|
# Check if client disconnected
|
||||||
|
if await request.is_disconnected():
|
||||||
|
logger.info("Client disconnected during scaffold, terminating process")
|
||||||
|
break
|
||||||
|
|
||||||
|
line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r")
|
||||||
|
yield _sse_event({"type": "output", "line": line})
|
||||||
|
# Yield control to event loop so disconnect checks work
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
process.wait()
|
||||||
|
exit_code = process.returncode
|
||||||
|
success = exit_code == 0
|
||||||
|
logger.info("Scaffold process completed: exit_code=%s, template=%s", exit_code, template)
|
||||||
|
yield _sse_event({"type": "complete", "success": success, "exit_code": exit_code})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Scaffold error: %s", e)
|
||||||
|
yield _sse_event({"type": "error", "message": str(e)})
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if process and process.poll() is None:
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
process.kill()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run")
|
||||||
|
async def run_scaffold(body: ScaffoldRequest, request: Request):
|
||||||
|
"""Run a scaffold template command with SSE streaming output."""
|
||||||
|
return StreamingResponse(
|
||||||
|
_stream_scaffold(body.template, body.target_path, request),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -4,14 +4,15 @@
|
|||||||
* Multi-step modal for creating new projects:
|
* Multi-step modal for creating new projects:
|
||||||
* 1. Enter project name
|
* 1. Enter project name
|
||||||
* 2. Select project folder
|
* 2. Select project folder
|
||||||
* 3. Choose spec method (Claude or manual)
|
* 3. Choose project template (blank or agentic starter)
|
||||||
* 4a. If Claude: Show SpecCreationChat
|
* 4. Choose spec method (Claude or manual)
|
||||||
* 4b. If manual: Create project and close
|
* 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 { 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 { useCreateProject } from '../hooks/useProjects'
|
||||||
import { SpecCreationChat } from './SpecCreationChat'
|
import { SpecCreationChat } from './SpecCreationChat'
|
||||||
import { FolderBrowser } from './FolderBrowser'
|
import { FolderBrowser } from './FolderBrowser'
|
||||||
@@ -32,8 +33,9 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
|
||||||
type InitializerStatus = 'idle' | 'starting' | 'error'
|
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'
|
type SpecMethod = 'claude' | 'manual'
|
||||||
|
|
||||||
interface NewProjectModalProps {
|
interface NewProjectModalProps {
|
||||||
@@ -57,6 +59,10 @@ export function NewProjectModal({
|
|||||||
const [initializerStatus, setInitializerStatus] = useState<InitializerStatus>('idle')
|
const [initializerStatus, setInitializerStatus] = useState<InitializerStatus>('idle')
|
||||||
const [initializerError, setInitializerError] = useState<string | null>(null)
|
const [initializerError, setInitializerError] = useState<string | null>(null)
|
||||||
const [yoloModeSelected, setYoloModeSelected] = useState(false)
|
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
|
// Suppress unused variable warning - specMethod may be used in future
|
||||||
void _specMethod
|
void _specMethod
|
||||||
@@ -91,13 +97,84 @@ export function NewProjectModal({
|
|||||||
|
|
||||||
const handleFolderSelect = (path: string) => {
|
const handleFolderSelect = (path: string) => {
|
||||||
setProjectPath(path)
|
setProjectPath(path)
|
||||||
changeStep('method')
|
changeStep('template')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFolderCancel = () => {
|
const handleFolderCancel = () => {
|
||||||
changeStep('name')
|
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) => {
|
const handleMethodSelect = async (method: SpecMethod) => {
|
||||||
setSpecMethod(method)
|
setSpecMethod(method)
|
||||||
|
|
||||||
@@ -188,13 +265,21 @@ export function NewProjectModal({
|
|||||||
setInitializerStatus('idle')
|
setInitializerStatus('idle')
|
||||||
setInitializerError(null)
|
setInitializerError(null)
|
||||||
setYoloModeSelected(false)
|
setYoloModeSelected(false)
|
||||||
|
setScaffoldStatus('idle')
|
||||||
|
setScaffoldOutput([])
|
||||||
|
setScaffoldError(null)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
if (step === 'method') {
|
if (step === 'method') {
|
||||||
changeStep('folder')
|
changeStep('template')
|
||||||
setSpecMethod(null)
|
setSpecMethod(null)
|
||||||
|
} else if (step === 'template') {
|
||||||
|
changeStep('folder')
|
||||||
|
setScaffoldStatus('idle')
|
||||||
|
setScaffoldOutput([])
|
||||||
|
setScaffoldError(null)
|
||||||
} else if (step === 'folder') {
|
} else if (step === 'folder') {
|
||||||
changeStep('name')
|
changeStep('name')
|
||||||
setProjectPath(null)
|
setProjectPath(null)
|
||||||
@@ -255,6 +340,7 @@ export function NewProjectModal({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{step === 'name' && 'Create New Project'}
|
{step === 'name' && 'Create New Project'}
|
||||||
|
{step === 'template' && 'Choose Project Template'}
|
||||||
{step === 'method' && 'Choose Setup Method'}
|
{step === 'method' && 'Choose Setup Method'}
|
||||||
{step === 'complete' && 'Project Created!'}
|
{step === 'complete' && 'Project Created!'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
@@ -294,7 +380,127 @@ export function NewProjectModal({
|
|||||||
</form>
|
</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' && (
|
{step === 'method' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
Reference in New Issue
Block a user