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

136
server/routers/scaffold.py Normal file
View 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",
},
)