Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,21 @@ packages/
Set these variables in `apps/api/.env`:

```bash
STACKFORGE_PROVIDER=openrouter
OPENROUTER_API_KEY=your_key_here
OPENROUTER_ENDPOINT=https://openrouter.ai/api/v1/chat/completions
OPENROUTER_APP_NAME=stackforge-api
OPENROUTER_APP_URL=http://localhost:3001
```

`STACKFORGE_PROVIDER` supports:
- `openrouter`: forces real LLM execution (requires `OPENROUTER_API_KEY`)
- `mock`: forces deterministic offline agent responses
- omitted/`auto`: uses `openrouter` when API key is present, otherwise `mock`
Comment on lines +110 to +120

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

STACKFORGE_PROVIDER is set to openrouter in the README example, but the text below says the default/auto mode will fall back to mock when no API key is present. Setting openrouter here will force 503 errors for anyone who hasn’t set OPENROUTER_API_KEY. Consider changing the example to STACKFORGE_PROVIDER=auto (or omitting it) and mentioning openrouter as an optional override.

Copilot uses AI. Check for mistakes.

Runtime/provider health is exposed at:
- `GET /api/runtime`

Comment on lines +122 to +124

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description mentions a GET /runtime/status endpoint, but the docs/implementation/tests here use GET /api/runtime. Please align the PR description and docs so there’s a single canonical runtime status endpoint name/path.

Copilot uses AI. Check for mistakes.
---

## 📉 Token Tuning Guide
Expand Down
1 change: 1 addition & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
PORT=3001
NODE_ENV=development
STACKFORGE_PROVIDER=auto
OPENROUTER_API_KEY=
OPENROUTER_ENDPOINT=https://openrouter.ai/api/v1/chat/completions
OPENROUTER_APP_NAME=stackforge-api
Expand Down
11 changes: 10 additions & 1 deletion apps/api/src/controllers/generate.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Request, Response, NextFunction } from "express";
import { GenerateRequestSchema } from "@stackforge/shared";
import { generateProject } from "../services/generate.service.js";
import { generateProject, getRuntimeStatus } from "../services/generate.service.js";

export function generateController(req: Request, res: Response, next: NextFunction): void {
const parsed = GenerateRequestSchema.safeParse(req.body);
Expand All @@ -12,6 +12,15 @@ export function generateController(req: Request, res: Response, next: NextFuncti

const { prompt, projectName } = parsed.data;
const resolvedName = projectName ?? prompt.slice(0, 40).replace(/\s+/g, "-").toLowerCase();
const runtime = getRuntimeStatus();

if (!runtime.ready) {
res.status(503).json({
error: runtime.reason ?? "LLM provider is not configured",
provider: runtime.provider,
});
return;
}

try {
const job = generateProject(prompt, resolvedName);
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/controllers/jobs.controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import type { Request, Response, NextFunction } from "express";
import { JobIdParamSchema, JOB_STATUS } from "@stackforge/shared";
import { getJob, listJobs, summarizeJobTokenUsage } from "../store/job.store.js";
import { getRuntimeStatus } from "../services/generate.service.js";
import { subscribe, unsubscribe } from "../services/sse.service.js";

export function runtimeController(_req: Request, res: Response): void {
const runtime = getRuntimeStatus();
res.status(runtime.ready ? 200 : 503).json(runtime);
}

export function listJobsController(_req: Request, res: Response): void {
const jobs = listJobs().map((job) => ({
id: job.id,
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ const PORT = process.env["PORT"] ?? "3001";

app.use(express.json());

// CORS for Vite dev server
app.use((_req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (_req.method === "OPTIONS") {
res.sendStatus(204);
return;
}
Comment on lines 8 to +19

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wildcard CORS (Access-Control-Allow-Origin: *) is applied unconditionally to the entire API. If this service is deployed with real LLM credentials, any website can call the generation endpoints from a browser and read responses, increasing abuse risk. Consider enabling CORS only in development, or restricting origins via an allowlist/env var (or using the cors middleware).

Suggested change
app.use(express.json());
// CORS for Vite dev server
app.use((_req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (_req.method === "OPTIONS") {
res.sendStatus(204);
return;
}
const allowedOrigins =
(process.env["CORS_ORIGINS"] ?? "")
.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);
app.use(express.json());
// CORS for Vite dev server (permissive in development, restricted by allowlist otherwise)
app.use((_req, res, next) => {
const origin = _req.headers.origin as string | undefined;
if (process.env["NODE_ENV"] === "development") {
res.setHeader("Access-Control-Allow-Origin", "*");
} else if (origin && allowedOrigins.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
}
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (_req.method === "OPTIONS") {
res.sendStatus(204);
return;
}

Copilot uses AI. Check for mistakes.
next();
});

app.get("/healthz", (_req, res) => {
res.json({ status: "ok", service: "stackforge-api", ts: new Date().toISOString() });
});
Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { Router, type IRouter } from "express";
import { generateController } from "../controllers/generate.controller.js";
import { listJobsController, getJobController, streamController } from "../controllers/jobs.controller.js";
import {
runtimeController,
listJobsController,
getJobController,
streamController,
Comment on lines +4 to +7

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This import block uses tab indentation, while the surrounding codebase uses spaces. This can cause noisy diffs and potentially fail formatting checks. Please reformat this block to match the existing spacing style.

Suggested change
runtimeController,
listJobsController,
getJobController,
streamController,
runtimeController,
listJobsController,
getJobController,
streamController,

Copilot uses AI. Check for mistakes.
} from "../controllers/jobs.controller.js";

const router: IRouter = Router();

router.post("/generate", generateController);
router.get("/runtime", runtimeController);
router.get("/jobs", listJobsController);
router.get("/jobs/:jobId", getJobController);
router.get("/stream/:jobId", streamController);
Expand Down
61 changes: 56 additions & 5 deletions apps/api/src/services/generate.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import type { SSEEvent, AgentName } from "@stackforge/shared";

import { JOB_STATUS } from "@stackforge/shared";
import { OpenRouterProvider, AgentCache, runOrchestrator } from "@stackforge/agents";
import {
OpenRouterProvider,
MockProvider,
AgentCache,
runOrchestrator,
type LLMProvider,
} from "@stackforge/agents";
import {
createJob,
getJob,
Expand All @@ -11,6 +17,29 @@ import {
} from "../store/job.store.js";
import { broadcast, closeJobClients } from "./sse.service.js";

type ProviderMode = "openrouter" | "mock";

export type RuntimeStatus = {
provider: ProviderMode;
ready: boolean;
reason?: string;
};

function resolveProviderMode(): ProviderMode {
const configured = (process.env["STACKFORGE_PROVIDER"] ?? "auto").trim().toLowerCase();

if (configured === "openrouter") {
return "openrouter";
}

if (configured === "mock") {
return "mock";
}

const hasOpenRouterKey = (process.env["OPENROUTER_API_KEY"] ?? "").trim().length > 0;
return hasOpenRouterKey ? "openrouter" : "mock";
}

function readEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value.trim().length === 0) {
Expand All @@ -19,7 +48,7 @@ function readEnv(name: string): string {
return value;
}

function buildProvider(): OpenRouterProvider {
function buildOpenRouterProvider(): OpenRouterProvider {
const endpoint = process.env["OPENROUTER_ENDPOINT"];
const options = {
apiKey: readEnv("OPENROUTER_API_KEY"),
Expand All @@ -31,16 +60,38 @@ function buildProvider(): OpenRouterProvider {
return new OpenRouterProvider(options);
}

let provider: OpenRouterProvider | undefined;
function buildProvider(mode: ProviderMode): LLMProvider {
if (mode === "mock") {
return new MockProvider();
}

return buildOpenRouterProvider();
}

function getProvider(): OpenRouterProvider {
let provider: LLMProvider | undefined;
let providerMode: ProviderMode | undefined;

function getProvider(): LLMProvider {
if (provider === undefined) {
provider = buildProvider();
providerMode = resolveProviderMode();
provider = buildProvider(providerMode);
}

return provider;
}

export function getRuntimeStatus(): RuntimeStatus {
const mode = providerMode ?? resolveProviderMode();

try {
void getProvider();
return { provider: mode, ready: true };
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return { provider: mode, ready: false, reason };
}
}

const cache = new AgentCache();

function buildEmitter(jobId: string): (event: SSEEvent) => void {
Expand Down
9 changes: 9 additions & 0 deletions apps/api/test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ describe("StackForge API Integration", () => {
expect(json.error).toBe("Validation failed");
});

it("should return runtime provider status", async () => {
const res = await fetch(`${baseUrl}/api/runtime`);
expect([200, 503]).toContain(res.status);

const data = await res.json();
expect(["openrouter", "mock"]).toContain(data.provider);
expect(typeof data.ready).toBe("boolean");
});

it("should create a job and return 202 accepted", async () => {
const res = await fetch(`${baseUrl}/api/generate`, {
method: "POST",
Expand Down
16 changes: 16 additions & 0 deletions apps/web/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="StackForge — AI-powered full-stack project scaffolding. Describe your idea, pick your stack, and watch agents build your blueprint in real time." />
<title>StackForge — AI Project Scaffolding</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
21 changes: 17 additions & 4 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,24 @@
"private": true,
"type": "module",
"scripts": {
"dev": "echo 'web: not implemented yet'",
"build": "echo 'web: not implemented yet'",
"lint": "echo 'web: not implemented yet'"
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@stackforge/shared": "workspace:*"
"@stackforge/ui": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.7.3",
"vite": "^6.2.0"
}
}
21 changes: 21 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { ToastProvider } from "@stackforge/ui";
import { Layout } from "./components/Layout";
import { Home } from "./pages/Home";
import { JobPage } from "./pages/JobPage";

export function App() {
return (
<BrowserRouter>
<ToastProvider>
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/jobs/:jobId" element={<JobPage />} />
</Routes>
</Layout>
</ToastProvider>
</BrowserRouter>
);
}
88 changes: 88 additions & 0 deletions apps/web/src/components/AgentCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React from "react";
import { Badge, Card, Spinner } from "@stackforge/ui";
import type { AgentState } from "../hooks/useJobStream";

const AGENT_META: Record<string, { icon: string; label: string; description: string }> = {
planner: { icon: "📋", label: "Planner", description: "Defines project structure and tech stack" },
schema: { icon: "🗄️", label: "Schema", description: "Designs database entities and relationships" },
api: { icon: "⚡", label: "API", description: "Plans API routes and endpoints" },
frontend: { icon: "🎨", label: "Frontend", description: "Creates frontend pages and components" },
devops: { icon: "🚀", label: "DevOps", description: "Sets up CI/CD, Docker, and deployment" },
reviewer: { icon: "🔍", label: "Reviewer", description: "Reviews the entire blueprint for quality" },
};

export function AgentCard({ agent }: { agent: AgentState }) {
const meta = AGENT_META[agent.name] ?? { icon: "🤖", label: agent.name, description: "" };

const borderColor =
agent.status === "running"
? "rgba(56, 189, 248, 0.3)"
: agent.status === "completed"
? "rgba(52, 211, 153, 0.2)"
: agent.status === "failed"
? "rgba(244, 63, 94, 0.2)"
: "#23232f";

return (
<Card
hover={false}
style={{
padding: "18px 20px",
borderColor,
borderRadius: "14px",
animation: agent.status === "running" ? "pulse-glow 2s ease-in-out infinite" : undefined,
transition: "all 350ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "14px" }}>
{/* Icon */}
<div
style={{
width: "42px",
height: "42px",
borderRadius: "12px",
background:
agent.status === "running"
? "rgba(56, 189, 248, 0.1)"
: agent.status === "completed"
? "rgba(52, 211, 153, 0.1)"
: "rgba(92, 92, 111, 0.1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "20px",
flexShrink: 0,
}}
>
{agent.status === "running" ? <Spinner size={18} color="#38bdf8" /> : meta.icon}
</div>

{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px", marginBottom: "4px" }}>
<span style={{ fontSize: "15px", fontWeight: 600 }}>{meta.label}</span>
<Badge status={agent.status} />
</div>
<p style={{ fontSize: "13px", color: "#9898a8", margin: 0 }}>{meta.description}</p>
</div>

{/* Duration / details */}
<div style={{ textAlign: "right", flexShrink: 0 }}>
{agent.durationMs != null && (
<span style={{ fontSize: "13px", color: "#9898a8", fontVariantNumeric: "tabular-nums" }}>
{(agent.durationMs / 1000).toFixed(1)}s
</span>
)}
{agent.totalTokens != null && (
<div style={{ fontSize: "11px", color: "#5c5c6f", marginTop: "2px" }}>
{agent.totalTokens.toLocaleString()} tokens
</div>
)}
{agent.error && (
<span style={{ fontSize: "12px", color: "#f43f5e" }}>{agent.error}</span>
)}
</div>
</div>
</Card>
);
}
Loading
Loading