From 41c1a14ae34a7fd5deddb8f61a09dba4d46053ef Mon Sep 17 00:00:00 2001 From: Auto Date: Thu, 26 Feb 2026 13:18:55 +0200 Subject: [PATCH] feat: add scaffold router and project template selection step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/main.py | 2 + server/routers/__init__.py | 2 + server/routers/scaffold.py | 136 ++++++++++++++++ ui/src/components/NewProjectModal.tsx | 224 ++++++++++++++++++++++++-- 4 files changed, 355 insertions(+), 9 deletions(-) create mode 100644 server/routers/scaffold.py diff --git a/server/main.py b/server/main.py index 20ccd0be..803c1072 100644 --- a/server/main.py +++ b/server/main.py @@ -36,6 +36,7 @@ features_router, filesystem_router, projects_router, + scaffold_router, schedules_router, settings_router, spec_creation_router, @@ -169,6 +170,7 @@ async def require_localhost(request: Request, call_next): app.include_router(assistant_chat_router) app.include_router(settings_router) app.include_router(terminal_router) +app.include_router(scaffold_router) # ============================================================================ diff --git a/server/routers/__init__.py b/server/routers/__init__.py index f4d02f51..58f2d00d 100644 --- a/server/routers/__init__.py +++ b/server/routers/__init__.py @@ -12,6 +12,7 @@ from .features import router as features_router from .filesystem import router as filesystem_router from .projects import router as projects_router +from .scaffold import router as scaffold_router from .schedules import router as schedules_router from .settings import router as settings_router from .spec_creation import router as spec_creation_router @@ -29,4 +30,5 @@ "assistant_chat_router", "settings_router", "terminal_router", + "scaffold_router", ] diff --git a/server/routers/scaffold.py b/server/routers/scaffold.py new file mode 100644 index 00000000..4452ab90 --- /dev/null +++ b/server/routers/scaffold.py @@ -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", + }, + ) diff --git a/ui/src/components/NewProjectModal.tsx b/ui/src/components/NewProjectModal.tsx index 4b460232..0770b184 100644 --- a/ui/src/components/NewProjectModal.tsx +++ b/ui/src/components/NewProjectModal.tsx @@ -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('idle') const [initializerError, setInitializerError] = useState(null) const [yoloModeSelected, setYoloModeSelected] = useState(false) + const [scaffoldStatus, setScaffoldStatus] = useState('idle') + const [scaffoldOutput, setScaffoldOutput] = useState([]) + const [scaffoldError, setScaffoldError] = useState(null) + const scaffoldLogRef = useRef(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({ {step === 'name' && 'Create New Project'} + {step === 'template' && 'Choose Project Template'} {step === 'method' && 'Choose Setup Method'} {step === 'complete' && 'Project Created!'} @@ -294,7 +380,127 @@ export function NewProjectModal({ )} - {/* Step 2: Spec Method */} + {/* Step 2: Project Template */} + {step === 'template' && ( +
+ {scaffoldStatus === 'idle' && ( + <> + + Start with a blank project or use a pre-configured template. + + +
+ handleTemplateSelect('blank')} + > + +
+
+ +
+
+ Blank Project +

+ Start from scratch. AutoForge will scaffold your app based on the spec you define. +

+
+
+
+
+ + handleTemplateSelect('agentic-starter')} + > + +
+
+ +
+
+
+ Agentic Starter + Next.js +
+

+ Pre-configured Next.js app with BetterAuth, Drizzle ORM, Postgres, and AI capabilities. +

+
+
+
+
+
+ + + + + + )} + + {scaffoldStatus === 'running' && ( +
+
+ + Setting up Agentic Starter... +
+
+ {scaffoldOutput.map((line, i) => ( +
{line}
+ ))} +
+
+ )} + + {scaffoldStatus === 'success' && ( +
+
+ +
+

Template ready!

+

Proceeding to setup method...

+
+ )} + + {scaffoldStatus === 'error' && ( +
+ + + + {scaffoldError || 'An unknown error occurred'} + + + + {scaffoldOutput.length > 0 && ( +
+ {scaffoldOutput.slice(-10).map((line, i) => ( +
{line}
+ ))} +
+ )} + + + + + +
+ )} +
+ )} + + {/* Step 3: Spec Method */} {step === 'method' && (