Skip to content

Commit fb87bb4

Browse files
committed
feat: add presets and ai chat module
1 parent 58a4e03 commit fb87bb4

30 files changed

Lines changed: 488 additions & 25 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,11 @@ Current local prototype:
112112

113113
```bash
114114
node apps/cli/src/cli.mjs list
115+
node apps/cli/src/cli.mjs presets
115116
node apps/cli/src/cli.mjs validate
116117
node apps/cli/src/cli.mjs build
117118
node apps/cli/src/cli.mjs add api-keys --target ../some-app --dry-run
119+
node apps/cli/src/cli.mjs add preset next-saas --target ../some-app --dry-run
118120
node apps/cli/src/cli.mjs diff api-keys --target ../some-app
119121
```
120122

ROADMAP.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
- [x] `stackfoundry add <module>` prototype
1010
- [x] file hash manifest
1111
- [x] `stackfoundry diff <module>` prototype
12+
- [x] preset manifests
13+
- [x] `stackfoundry add preset <name>` prototype
14+
- [x] `ai-chat` manifest and source template
1215
- [ ] overwrite conflict UI
1316
- [ ] generated install report
1417
- [ ] public demo app

apps/cli/src/cli.mjs

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,37 @@ const __filename = fileURLToPath(import.meta.url);
99
const repoRoot = path.resolve(path.dirname(__filename), "../../..");
1010
const registryRoot = path.join(repoRoot, "registry");
1111
const modulesRoot = path.join(registryRoot, "modules");
12+
const presetsRoot = path.join(registryRoot, "presets");
1213

1314
const requiredModuleFields = ["name", "type", "category", "title", "description", "files", "status"];
15+
const requiredPresetFields = ["name", "title", "description", "modules", "status"];
1416

1517
function usage() {
1618
console.log(`StackFoundry
1719
1820
Usage:
1921
stackfoundry list
22+
stackfoundry presets
2023
stackfoundry validate
2124
stackfoundry build
25+
stackfoundry add preset <name> [--target <dir>] [--dry-run] [--force]
2226
stackfoundry add <module> [--target <dir>] [--dry-run] [--force]
2327
stackfoundry diff <module> [--target <dir>]
2428
`);
2529
}
2630

2731
function parseArgs(argv) {
28-
const [command, moduleName, ...rest] = argv;
32+
const [command, firstArg, ...tail] = argv;
33+
let moduleName = firstArg;
34+
let presetName;
35+
let rest = tail;
2936
const flags = { target: process.cwd(), dryRun: false, force: false };
3037

38+
if (command === "add" && firstArg === "preset") {
39+
presetName = rest.shift();
40+
moduleName = undefined;
41+
}
42+
3143
for (let i = 0; i < rest.length; i += 1) {
3244
const arg = rest[i];
3345
if (arg === "--target") {
@@ -41,7 +53,7 @@ function parseArgs(argv) {
4153
}
4254
}
4355

44-
return { command, moduleName, flags };
56+
return { command, moduleName, presetName, flags };
4557
}
4658

4759
async function readJson(filePath) {
@@ -82,6 +94,14 @@ async function getModule(name) {
8294
};
8395
}
8496

97+
async function getPreset(name) {
98+
const presetPath = path.join(presetsRoot, `${name}.json`);
99+
if (!existsSync(presetPath)) {
100+
throw new Error(`Unknown preset: ${name}`);
101+
}
102+
return readJson(presetPath);
103+
}
104+
85105
async function validateModule(moduleDir) {
86106
const manifestPath = path.join(moduleDir, "module.json");
87107
const manifest = await readJson(manifestPath);
@@ -132,6 +152,22 @@ async function validateRegistry() {
132152
}
133153
}
134154

155+
if (existsSync(presetsRoot)) {
156+
for (const file of await readdir(presetsRoot)) {
157+
if (!file.endsWith(".json")) continue;
158+
const preset = await readJson(path.join(presetsRoot, file));
159+
for (const field of requiredPresetFields) {
160+
if (!(field in preset)) errors.push(`${file}: missing field: ${field}`);
161+
}
162+
if (!Array.isArray(preset.modules)) errors.push(`${file}: modules must be an array`);
163+
for (const moduleName of preset.modules ?? []) {
164+
if (!existsSync(path.join(modulesRoot, moduleName, "module.json"))) {
165+
errors.push(`${file}: unknown module ${moduleName}`);
166+
}
167+
}
168+
}
169+
}
170+
135171
return errors;
136172
}
137173

@@ -143,6 +179,15 @@ async function listModules() {
143179
}
144180
}
145181

182+
async function listPresets() {
183+
if (!existsSync(presetsRoot)) return;
184+
const files = (await readdir(presetsRoot)).filter((file) => file.endsWith(".json")).sort();
185+
for (const file of files) {
186+
const preset = await readJson(path.join(presetsRoot, file));
187+
console.log(`${preset.name.padEnd(18)} ${preset.status.padEnd(12)} ${preset.description}`);
188+
}
189+
}
190+
146191
async function loadInstallManifest(target) {
147192
const filePath = path.join(target, ".stackfoundry", "installed.json");
148193
if (!existsSync(filePath)) return { modules: {} };
@@ -155,8 +200,15 @@ async function saveInstallManifest(target, manifest) {
155200
await writeFile(path.join(dir, "installed.json"), `${JSON.stringify(manifest, null, 2)}\n`);
156201
}
157202

158-
async function addModule(name, flags) {
203+
async function addModule(name, flags, visited = new Set()) {
204+
if (visited.has(name)) return;
205+
visited.add(name);
206+
159207
const { dir, manifest } = await getModule(name);
208+
for (const dependency of manifest.registryDependencies) {
209+
await addModule(dependency, flags, visited);
210+
}
211+
160212
const filesDir = path.join(dir, "files");
161213
const sourceFiles = await listFiles(filesDir);
162214
const installed = await loadInstallManifest(flags.target);
@@ -217,6 +269,16 @@ async function addModule(name, flags) {
217269
}
218270
}
219271

272+
async function addPreset(name, flags) {
273+
if (!name) throw new Error("add preset requires a preset name");
274+
const preset = await getPreset(name);
275+
const visited = new Set();
276+
console.log(`${flags.dryRun ? "would install" : "installing"} preset ${preset.name}`);
277+
for (const moduleName of preset.modules) {
278+
await addModule(moduleName, flags, visited);
279+
}
280+
}
281+
220282
async function diffModule(name, flags) {
221283
const { dir } = await getModule(name);
222284
const filesDir = path.join(dir, "files");
@@ -259,6 +321,15 @@ async function buildRegistry() {
259321

260322
await writeFile(path.join(outputDir, "registry.json"), `${JSON.stringify(builtRegistry, null, 2)}\n`);
261323

324+
const presetsOutputDir = path.join(outputDir, "presets");
325+
await mkdir(presetsOutputDir, { recursive: true });
326+
if (existsSync(presetsRoot)) {
327+
for (const file of (await readdir(presetsRoot)).filter((entry) => entry.endsWith(".json")).sort()) {
328+
await copyFile(path.join(presetsRoot, file), path.join(presetsOutputDir, file));
329+
console.log(`built public/r/presets/${file}`);
330+
}
331+
}
332+
262333
for (const item of registry.items) {
263334
const { dir, manifest } = await getModule(item.name);
264335
const filesDir = path.join(dir, "files");
@@ -300,14 +371,15 @@ async function buildRegistry() {
300371
}
301372

302373
async function main() {
303-
const { command, moduleName, flags } = parseArgs(process.argv.slice(2));
374+
const { command, moduleName, presetName, flags } = parseArgs(process.argv.slice(2));
304375

305376
if (!command || command === "help" || command === "--help") {
306377
usage();
307378
return;
308379
}
309380

310381
if (command === "list") return listModules();
382+
if (command === "presets") return listPresets();
311383
if (command === "build") return buildRegistry();
312384
if (command === "validate") {
313385
const errors = await validateRegistry();
@@ -320,6 +392,7 @@ async function main() {
320392
return;
321393
}
322394

395+
if (command === "add" && presetName) return addPreset(presetName, flags);
323396
if (!moduleName) throw new Error(`${command} requires a module name`);
324397
if (command === "add") return addModule(moduleName, flags);
325398
if (command === "diff") return diffModule(moduleName, flags);

docs/registry.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,11 @@ Installers must not overwrite modified user files silently. The installer should
4040

4141
```bash
4242
node apps/cli/src/cli.mjs list
43+
node apps/cli/src/cli.mjs presets
4344
node apps/cli/src/cli.mjs validate
4445
node apps/cli/src/cli.mjs build
4546
node apps/cli/src/cli.mjs add drizzle-postgres --target /path/to/app
47+
node apps/cli/src/cli.mjs add preset next-saas --target /path/to/app
4648
node apps/cli/src/cli.mjs diff drizzle-postgres --target /path/to/app
4749
```
4850

@@ -56,6 +58,8 @@ public/r/
5658
drizzle-postgres.json
5759
api-keys.json
5860
stripe-billing.json
61+
presets/
62+
next-saas.json
5963
```
6064

6165
This mirrors the source-registry pattern: the registry item JSON embeds file contents while module metadata stays in `registry/modules/<module>/module.json`.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"validate": "node apps/cli/src/cli.mjs validate",
1818
"registry:build": "node apps/cli/src/cli.mjs build",
1919
"lint": "node --check apps/cli/src/cli.mjs",
20-
"test": "rm -rf /tmp/stackfoundry-test && mkdir -p /tmp/stackfoundry-test && node apps/cli/src/cli.mjs list >/dev/null && node apps/cli/src/cli.mjs add api-keys --target /tmp/stackfoundry-test >/dev/null && node apps/cli/src/cli.mjs diff api-keys --target /tmp/stackfoundry-test >/dev/null",
20+
"test": "rm -rf /tmp/stackfoundry-test && mkdir -p /tmp/stackfoundry-test && node apps/cli/src/cli.mjs list >/dev/null && node apps/cli/src/cli.mjs presets >/dev/null && node apps/cli/src/cli.mjs add api-keys --target /tmp/stackfoundry-test >/dev/null && node apps/cli/src/cli.mjs diff api-keys --target /tmp/stackfoundry-test >/dev/null && node apps/cli/src/cli.mjs add preset next-saas --target /tmp/stackfoundry-test --dry-run >/dev/null",
2121
"build": "pnpm validate"
2222
},
2323
"keywords": [

public/r/ai-chat.json

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,42 @@
44
"type": "registry:item",
55
"title": "AI Chat",
66
"description": "AI SDK chat route, model selector, message persistence, and prompt input.",
7-
"dependencies": [],
7+
"dependencies": [
8+
"ai",
9+
"@ai-sdk/react",
10+
"@ai-sdk/gateway"
11+
],
812
"devDependencies": [],
913
"registryDependencies": [],
10-
"files": [],
14+
"files": [
15+
{
16+
"path": "apps/web/src/app/api/ai/chat/route.ts",
17+
"type": "registry:file",
18+
"content": "import { convertToModelMessages, streamText, type UIMessage } from \"ai\";\n\nimport { getSelectedModel } from \"@/lib/ai/models\";\nimport { chatSystemPrompt } from \"@/lib/ai/prompts\";\n\nexport const maxDuration = 60;\n\ntype ChatRequest = {\n messages?: UIMessage[];\n message?: UIMessage;\n model?: string;\n};\n\nexport async function POST(request: Request) {\n const body = (await request.json()) as ChatRequest;\n const messages = body.messages ?? (body.message ? [body.message] : []);\n\n try {\n const result = streamText({\n model: getSelectedModel(body.model),\n system: chatSystemPrompt,\n messages: convertToModelMessages(messages),\n });\n\n return result.toUIMessageStreamResponse();\n } catch (error) {\n return Response.json(\n { error: error instanceof Error ? error.message : \"Unable to start chat stream.\" },\n { status: 400 },\n );\n }\n}\n"
19+
},
20+
{
21+
"path": "apps/web/src/components/ai/chat-box.tsx",
22+
"type": "registry:component",
23+
"content": "\"use client\";\n\nimport { useChat } from \"@ai-sdk/react\";\nimport { DefaultChatTransport } from \"ai\";\nimport { ArrowUpIcon, SquareIcon } from \"lucide-react\";\nimport { useMemo, useState } from \"react\";\n\nexport function ChatBox() {\n const [input, setInput] = useState(\"\");\n const transport = useMemo(() => new DefaultChatTransport({ api: \"/api/ai/chat\" }), []);\n const { messages, sendMessage, status, stop } = useChat({ transport });\n const running = status === \"submitted\" || status === \"streaming\";\n\n function submit() {\n const text = input.trim();\n if (!text || running) return;\n sendMessage({ text });\n setInput(\"\");\n }\n\n return (\n <div className=\"flex flex-col gap-4 rounded-lg border p-4\">\n <div className=\"flex flex-col gap-3\">\n {messages.map((message) => (\n <div key={message.id} className=\"rounded-md bg-muted p-3 text-sm\">\n <div className=\"mb-1 font-medium\">{message.role}</div>\n {message.parts.map((part, index) =>\n part.type === \"text\" ? <p key={index}>{part.text}</p> : null,\n )}\n </div>\n ))}\n </div>\n <div className=\"flex gap-2\">\n <textarea\n className=\"min-h-24 flex-1 rounded-md border bg-background p-3 text-sm\"\n value={input}\n onChange={(event) => setInput(event.target.value)}\n placeholder=\"Ask a question...\"\n />\n {running ? (\n <button className=\"rounded-md border px-3\" type=\"button\" onClick={stop}>\n <SquareIcon />\n </button>\n ) : (\n <button className=\"rounded-md border px-3\" type=\"button\" onClick={submit}>\n <ArrowUpIcon />\n </button>\n )}\n </div>\n </div>\n );\n}\n"
24+
},
25+
{
26+
"path": "apps/web/src/lib/ai/prompts.ts",
27+
"type": "registry:file",
28+
"content": "export const chatSystemPrompt = `You are a concise assistant inside a production SaaS application.\n\nAnswer directly, ask for missing critical context only when necessary, and avoid exposing internal implementation details.`;\n"
29+
},
30+
{
31+
"path": "apps/web/src/lib/ai/models.ts",
32+
"type": "registry:file",
33+
"content": "export const DEFAULT_AI_MODEL = process.env.AI_GATEWAY_MODEL;\n\nexport function getSelectedModel(model?: string) {\n const selected = model || DEFAULT_AI_MODEL;\n if (!selected) {\n throw new Error(\"AI_GATEWAY_MODEL is not set.\");\n }\n return selected;\n}\n"
34+
}
35+
],
1136
"meta": {
1237
"category": "ai",
13-
"env": [],
14-
"status": "planned",
38+
"env": [
39+
"AI_GATEWAY_API_KEY",
40+
"AI_GATEWAY_MODEL"
41+
],
42+
"status": "experimental",
1543
"agents": {
1644
"skills": [
1745
"ai-chat"

public/r/presets/ai-saas.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "ai-saas",
3+
"title": "AI SaaS",
4+
"description": "AI chat, artifacts, model routing, prompt library, and eval foundations.",
5+
"modules": [
6+
"drizzle-postgres",
7+
"api-keys",
8+
"ai-chat",
9+
"ai-artifacts",
10+
"model-router",
11+
"prompt-library",
12+
"evals"
13+
],
14+
"status": "planned"
15+
}

public/r/presets/b2b-saas.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "b2b-saas",
3+
"title": "B2B SaaS",
4+
"description": "Organizations, RBAC, billing, audit log, transactional email, and analytics.",
5+
"modules": [
6+
"drizzle-postgres",
7+
"api-keys",
8+
"clerk-auth",
9+
"orgs-rbac",
10+
"stripe-billing",
11+
"audit-log",
12+
"resend-email",
13+
"posthog-analytics"
14+
],
15+
"status": "planned"
16+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "cloudflare-native",
3+
"title": "Cloudflare Native",
4+
"description": "Workers, Pages, D1, R2, KV, Queues, Turnstile, and Workers AI modules.",
5+
"modules": [
6+
"cloudflare-pages",
7+
"cloudflare-workers",
8+
"cloudflare-d1",
9+
"cloudflare-r2",
10+
"cloudflare-kv",
11+
"cloudflare-queues",
12+
"cloudflare-turnstile",
13+
"cloudflare-workers-ai"
14+
],
15+
"status": "planned"
16+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "developer-platform",
3+
"title": "Developer Platform",
4+
"description": "API keys, public API foundations, webhook delivery, and developer portal modules.",
5+
"modules": [
6+
"drizzle-postgres",
7+
"api-keys",
8+
"public-api-orpc",
9+
"webhook-delivery",
10+
"developer-portal"
11+
],
12+
"status": "planned"
13+
}

0 commit comments

Comments
 (0)