create-lacspace-app 2.2.2 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -1
- package/dist/index.js +1631 -40
- package/dist/lib.cjs +1639 -39
- package/dist/lib.d.cts +65 -1
- package/dist/lib.d.ts +65 -1
- package/dist/lib.js +1638 -40
- package/package.json +1 -1
package/dist/lib.cjs
CHANGED
|
@@ -39,7 +39,25 @@ function resolveContext(options = {}) {
|
|
|
39
39
|
const raw = options.name ?? "my-app";
|
|
40
40
|
const seg = raw.split(/[\\/]/).filter(Boolean).pop() ?? "my-app";
|
|
41
41
|
const name = seg.toLowerCase().replace(/[^a-z0-9-_]/g, "-").replace(/^-+|-+$/g, "") || "my-app";
|
|
42
|
-
|
|
42
|
+
const features = normalizeFeatures(options.features);
|
|
43
|
+
const mode = options.mode === "dynamic" ? "dynamic" : "static";
|
|
44
|
+
return { name, template, features, mode };
|
|
45
|
+
}
|
|
46
|
+
function normalizeFeatures(requested) {
|
|
47
|
+
if (!requested || requested.length === 0) return [];
|
|
48
|
+
const seen = /* @__PURE__ */ new Set();
|
|
49
|
+
const out = [];
|
|
50
|
+
const unknown = [];
|
|
51
|
+
for (const raw of requested) {
|
|
52
|
+
const key = String(raw).toLowerCase().trim();
|
|
53
|
+
if (!key || seen.has(key)) continue;
|
|
54
|
+
seen.add(key);
|
|
55
|
+
const def = FEATURES.find((f) => f.key === key);
|
|
56
|
+
if (def) out.push(def);
|
|
57
|
+
else unknown.push(key);
|
|
58
|
+
}
|
|
59
|
+
if (unknown.length) console.warn(`create-lacspace-app: ignoring unknown feature(s): ${unknown.join(", ")}`);
|
|
60
|
+
return out;
|
|
43
61
|
}
|
|
44
62
|
var pkgJson = (ctx) => JSON.stringify({
|
|
45
63
|
name: ctx.name,
|
|
@@ -1896,6 +1914,10 @@ Because posts are just Markdown files, you can:
|
|
|
1896
1914
|
Check the [first post](/blog/welcome) for the full Markdown reference.
|
|
1897
1915
|
`;
|
|
1898
1916
|
function buildFiles(ctx) {
|
|
1917
|
+
if (ctx.mode === "dynamic") return buildFullStack(ctx);
|
|
1918
|
+
return buildApp(ctx);
|
|
1919
|
+
}
|
|
1920
|
+
function buildApp(ctx) {
|
|
1899
1921
|
const isBlog = ctx.template.key === "blog";
|
|
1900
1922
|
const isDocs = ctx.template.key === "docs";
|
|
1901
1923
|
const files = {
|
|
@@ -1966,8 +1988,529 @@ function buildFiles(ctx) {
|
|
|
1966
1988
|
files["content/docs/installation.md"] = sampleDocInstall();
|
|
1967
1989
|
files["content/docs/writing-content.md"] = sampleDocWriting();
|
|
1968
1990
|
}
|
|
1991
|
+
applyFeatures(files, ctx);
|
|
1969
1992
|
return files;
|
|
1970
1993
|
}
|
|
1994
|
+
function applyFeatures(files, ctx) {
|
|
1995
|
+
const features = ctx.features;
|
|
1996
|
+
if (!features.length) return;
|
|
1997
|
+
for (const feat of features) {
|
|
1998
|
+
for (const [rel, content] of Object.entries(feat.files(ctx))) {
|
|
1999
|
+
files[rel] = content;
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
const addDeps = {};
|
|
2003
|
+
const addScripts = {};
|
|
2004
|
+
for (const feat of features) {
|
|
2005
|
+
Object.assign(addDeps, feat.deps);
|
|
2006
|
+
if (feat.scripts) Object.assign(addScripts, feat.scripts);
|
|
2007
|
+
}
|
|
2008
|
+
if (files["package.json"]) {
|
|
2009
|
+
const pkg = JSON.parse(files["package.json"]);
|
|
2010
|
+
pkg.dependencies = { ...pkg.dependencies ?? {}, ...addDeps };
|
|
2011
|
+
if (Object.keys(addScripts).length) pkg.scripts = { ...pkg.scripts ?? {}, ...addScripts };
|
|
2012
|
+
files["package.json"] = JSON.stringify(pkg, null, 2) + "\n";
|
|
2013
|
+
}
|
|
2014
|
+
const existingEnv = new Set(
|
|
2015
|
+
(files[".env.example"] ?? "").split("\n").map((l) => l.match(/^\s*#?\s*([A-Z0-9_]+)=/)?.[1]).filter(Boolean)
|
|
2016
|
+
);
|
|
2017
|
+
const envLines = [];
|
|
2018
|
+
for (const feat of features) {
|
|
2019
|
+
if (!feat.env || Object.keys(feat.env).length === 0) continue;
|
|
2020
|
+
const fresh = Object.entries(feat.env).filter(([name]) => !existingEnv.has(name));
|
|
2021
|
+
if (fresh.length === 0) continue;
|
|
2022
|
+
envLines.push("", `# --- ${feat.label} (${feat.key}) ---`);
|
|
2023
|
+
for (const [name, comment] of fresh) {
|
|
2024
|
+
if (comment) envLines.push(`# ${comment}`);
|
|
2025
|
+
envLines.push(`${name}=`);
|
|
2026
|
+
existingEnv.add(name);
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
if (envLines.length) {
|
|
2030
|
+
const base = files[".env.example"] ?? "";
|
|
2031
|
+
files[".env.example"] = (base.endsWith("\n") || base === "" ? base : base + "\n") + envLines.join("\n") + "\n";
|
|
2032
|
+
}
|
|
2033
|
+
files["LEARN.md"] = learnMd(ctx, features);
|
|
2034
|
+
}
|
|
2035
|
+
function learnMd(ctx, features) {
|
|
2036
|
+
const blocks = features.map((f) => {
|
|
2037
|
+
const steps = f.nextSteps.map((s) => `- [ ] ${s}`).join("\n");
|
|
2038
|
+
const learn = f.learn ? `
|
|
2039
|
+
|
|
2040
|
+
Learn more \u2192 ${f.learn}` : "";
|
|
2041
|
+
return `## ${f.label} \`(${f.key})\`
|
|
2042
|
+
|
|
2043
|
+
${f.description}
|
|
2044
|
+
|
|
2045
|
+
${steps}${learn}`;
|
|
2046
|
+
}).join("\n\n");
|
|
2047
|
+
return `# \u{1F9ED} Learn ${ctx.name}
|
|
2048
|
+
|
|
2049
|
+
You scaffolded **${ctx.template.label}** with ${features.length} feature add-on${features.length === 1 ? "" : "s"}:
|
|
2050
|
+
${features.map((f) => `\`${f.key}\``).join(" \xB7 ")}.
|
|
2051
|
+
|
|
2052
|
+
Work through the checklist below \u2014 each item is a real next step.
|
|
2053
|
+
|
|
2054
|
+
${blocks}
|
|
2055
|
+
|
|
2056
|
+
---
|
|
2057
|
+
|
|
2058
|
+
Every add-on is built from zero-dependency \`@lacspace/*\` packages. Browse them all \u2192 https://lacspace.com/packages
|
|
2059
|
+
`;
|
|
2060
|
+
}
|
|
2061
|
+
var FEATURES = [
|
|
2062
|
+
{
|
|
2063
|
+
key: "ai-chat",
|
|
2064
|
+
label: "AI chat",
|
|
2065
|
+
description: "A streaming AI chat route + UI \u2014 free & local by default (Ollama), keyless.",
|
|
2066
|
+
deps: {
|
|
2067
|
+
"@lacspace/ai": "^1.1.0",
|
|
2068
|
+
"@lacspace/prompt": "^1.1.0",
|
|
2069
|
+
"@lacspace/stream": "^1.1.0",
|
|
2070
|
+
"@lacspace/providers": "^1.0.0",
|
|
2071
|
+
"@lacspace/memory": "^1.0.0",
|
|
2072
|
+
"@lacspace/moderation": "^1.0.0"
|
|
2073
|
+
},
|
|
2074
|
+
env: {
|
|
2075
|
+
LACSPACE_AI_PROVIDER: "Which provider preset to use (default: ollama \u2014 free & local, no key).",
|
|
2076
|
+
LACSPACE_AI_BASE_URL: "Override the provider base URL (default Ollama: http://localhost:11434).",
|
|
2077
|
+
LACSPACE_AI_MODEL: "Chat model id (default: llama3.2).",
|
|
2078
|
+
LACSPACE_AI_API_KEY: "Only needed for a hosted provider \u2014 leave blank for local Ollama."
|
|
2079
|
+
},
|
|
2080
|
+
files: (ctx) => ({
|
|
2081
|
+
"app/api/chat/route.ts": aiChatRoute(),
|
|
2082
|
+
"app/chat/page.tsx": aiChatPage(ctx)
|
|
2083
|
+
}),
|
|
2084
|
+
nextSteps: [
|
|
2085
|
+
"\u{1F193} Free local AI \u2014 install Ollama (https://ollama.com), then run `ollama pull llama3.2`.",
|
|
2086
|
+
"Run `npm run dev` and open http://localhost:3000/chat.",
|
|
2087
|
+
"Prefer a hosted model? Set LACSPACE_AI_* in .env (e.g. LACSPACE_AI_PROVIDER=groq + LACSPACE_AI_API_KEY=\u2026)."
|
|
2088
|
+
],
|
|
2089
|
+
learn: "https://developer.lacspace.com/packages/ai"
|
|
2090
|
+
},
|
|
2091
|
+
{
|
|
2092
|
+
key: "rag",
|
|
2093
|
+
label: "RAG \u2014 chat with your docs",
|
|
2094
|
+
description: "Index your markdown/text and answer grounded questions with cited sources. Keyless & local (Ollama).",
|
|
2095
|
+
deps: {
|
|
2096
|
+
"@lacspace/rag": "^1.0.0",
|
|
2097
|
+
"@lacspace/embeddings": "^1.0.0",
|
|
2098
|
+
"@lacspace/vector": "^1.0.0",
|
|
2099
|
+
"@lacspace/chunk": "^1.1.0",
|
|
2100
|
+
"@lacspace/rerank": "^1.0.0",
|
|
2101
|
+
"@lacspace/providers": "^1.0.0",
|
|
2102
|
+
"@lacspace/ai": "^1.1.0"
|
|
2103
|
+
},
|
|
2104
|
+
scripts: { "rag:index": "node scripts/index-content.mjs" },
|
|
2105
|
+
env: {
|
|
2106
|
+
LACSPACE_AI_PROVIDER: "Which provider preset to use (default: ollama \u2014 free & local, no key).",
|
|
2107
|
+
LACSPACE_AI_BASE_URL: "Override the provider base URL (default Ollama: http://localhost:11434).",
|
|
2108
|
+
LACSPACE_AI_MODEL: "Chat model id used to answer (default: llama3.2).",
|
|
2109
|
+
LACSPACE_AI_API_KEY: "Only needed for a hosted provider \u2014 leave blank for local Ollama.",
|
|
2110
|
+
LACSPACE_EMBED_MODEL: "Embedding model id (default: nomic-embed-text)."
|
|
2111
|
+
},
|
|
2112
|
+
files: (ctx) => ({
|
|
2113
|
+
"content/welcome.md": ragSampleDoc(ctx),
|
|
2114
|
+
"scripts/index-content.mjs": ragIndexScript(),
|
|
2115
|
+
"app/api/ask/route.ts": ragAskRoute(),
|
|
2116
|
+
"app/ask/page.tsx": ragAskPage(ctx)
|
|
2117
|
+
}),
|
|
2118
|
+
nextSteps: [
|
|
2119
|
+
"\u{1F193} Free & local \u2014 install Ollama (https://ollama.com), then `ollama pull nomic-embed-text` and `ollama pull llama3.2`.",
|
|
2120
|
+
"Add markdown/text files to content/, then run `npm run rag:index` to build .rag-index.json.",
|
|
2121
|
+
"Run `npm run dev` and open http://localhost:3000/ask."
|
|
2122
|
+
],
|
|
2123
|
+
learn: "https://developer.lacspace.com/packages/rag"
|
|
2124
|
+
}
|
|
2125
|
+
];
|
|
2126
|
+
var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
|
|
2127
|
+
import { stream } from "@lacspace/ai";
|
|
2128
|
+
import type { Message } from "@lacspace/ai";
|
|
2129
|
+
import { detectPromptInjection } from "@lacspace/moderation";
|
|
2130
|
+
import { toReadableStream } from "@lacspace/stream";
|
|
2131
|
+
|
|
2132
|
+
// This route runs on Node (it talks to your local Ollama / a hosted LLM).
|
|
2133
|
+
export const runtime = "nodejs";
|
|
2134
|
+
export const dynamic = "force-dynamic";
|
|
2135
|
+
|
|
2136
|
+
export async function POST(req: Request) {
|
|
2137
|
+
// 1. Read the conversation the <ChatUI/> sent us.
|
|
2138
|
+
const { messages } = (await req.json()) as { messages: Message[] };
|
|
2139
|
+
|
|
2140
|
+
// 2. Guard the newest user turn against prompt-injection / jailbreak attempts.
|
|
2141
|
+
// Learn more \u2192 https://developer.lacspace.com/packages/moderation
|
|
2142
|
+
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
|
2143
|
+
const userText = typeof lastUser?.content === "string" ? lastUser.content : "";
|
|
2144
|
+
const check = detectPromptInjection(userText);
|
|
2145
|
+
if (check.flagged) {
|
|
2146
|
+
return new Response(JSON.stringify({ error: "That message was blocked by the safety guard." }), {
|
|
2147
|
+
status: 400,
|
|
2148
|
+
headers: { "Content-Type": "application/json" },
|
|
2149
|
+
});
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// 3. Resolve provider config from env \u2014 KEYLESS by default (local Ollama).
|
|
2153
|
+
// Learn more \u2192 https://developer.lacspace.com/packages/providers
|
|
2154
|
+
const cfg = resolveConfig(process.env.LACSPACE_AI_PROVIDER ?? "ollama", {
|
|
2155
|
+
baseUrl: process.env.LACSPACE_AI_BASE_URL, // default: http://localhost:11434
|
|
2156
|
+
model: process.env.LACSPACE_AI_MODEL, // default: llama3.2
|
|
2157
|
+
apiKey: process.env.LACSPACE_AI_API_KEY, // only for hosted providers
|
|
2158
|
+
});
|
|
2159
|
+
|
|
2160
|
+
// Ollama speaks its own dialect but also exposes an OpenAI-compatible surface
|
|
2161
|
+
// at /v1 \u2014 so we always call it (and any hosted provider) as "openai-compatible".
|
|
2162
|
+
const baseUrl = cfg.apiStyle === "ollama" ? cfg.baseUrl.replace(/\\/+$/, "") + "/v1" : cfg.baseUrl;
|
|
2163
|
+
const model = cfg.model ?? "llama3.2";
|
|
2164
|
+
|
|
2165
|
+
// 4. Stream the reply. @lacspace/ai yields unified {type:"text", delta} chunks;
|
|
2166
|
+
// we encode each token and hand the stream to the browser via @lacspace/stream.
|
|
2167
|
+
// Learn more \u2192 https://developer.lacspace.com/packages/ai
|
|
2168
|
+
const encoder = new TextEncoder();
|
|
2169
|
+
async function* tokens() {
|
|
2170
|
+
for await (const chunk of stream({
|
|
2171
|
+
provider: "openai-compatible",
|
|
2172
|
+
baseUrl,
|
|
2173
|
+
apiKey: cfg.apiKey,
|
|
2174
|
+
model,
|
|
2175
|
+
headers: cfg.headers,
|
|
2176
|
+
messages,
|
|
2177
|
+
})) {
|
|
2178
|
+
if (chunk.type === "text") yield encoder.encode(chunk.delta);
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// Learn more \u2192 https://developer.lacspace.com/packages/stream
|
|
2183
|
+
return new Response(toReadableStream(tokens()), {
|
|
2184
|
+
headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-cache" },
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
2187
|
+
`;
|
|
2188
|
+
var aiChatPage = (ctx) => `"use client";
|
|
2189
|
+
|
|
2190
|
+
import { useState, useRef, useEffect } from "react";
|
|
2191
|
+
|
|
2192
|
+
interface Msg { role: "user" | "assistant"; content: string; }
|
|
2193
|
+
|
|
2194
|
+
export default function ChatPage() {
|
|
2195
|
+
const [messages, setMessages] = useState<Msg[]>([]);
|
|
2196
|
+
const [input, setInput] = useState("");
|
|
2197
|
+
const [busy, setBusy] = useState(false);
|
|
2198
|
+
const scroller = useRef<HTMLDivElement>(null);
|
|
2199
|
+
|
|
2200
|
+
useEffect(() => {
|
|
2201
|
+
scroller.current?.scrollTo({ top: scroller.current.scrollHeight, behavior: "smooth" });
|
|
2202
|
+
}, [messages]);
|
|
2203
|
+
|
|
2204
|
+
async function send(e: React.FormEvent) {
|
|
2205
|
+
e.preventDefault();
|
|
2206
|
+
const text = input.trim();
|
|
2207
|
+
if (!text || busy) return;
|
|
2208
|
+
setInput("");
|
|
2209
|
+
setBusy(true);
|
|
2210
|
+
|
|
2211
|
+
const next: Msg[] = [...messages, { role: "user", content: text }];
|
|
2212
|
+
setMessages([...next, { role: "assistant", content: "" }]);
|
|
2213
|
+
|
|
2214
|
+
try {
|
|
2215
|
+
const res = await fetch("/api/chat", {
|
|
2216
|
+
method: "POST",
|
|
2217
|
+
headers: { "Content-Type": "application/json" },
|
|
2218
|
+
body: JSON.stringify({ messages: next }),
|
|
2219
|
+
});
|
|
2220
|
+
if (!res.ok || !res.body) throw new Error("The chat endpoint returned an error.");
|
|
2221
|
+
|
|
2222
|
+
// Read the streamed reply token-by-token and append it live.
|
|
2223
|
+
const reader = res.body.getReader();
|
|
2224
|
+
const decoder = new TextDecoder();
|
|
2225
|
+
let acc = "";
|
|
2226
|
+
for (;;) {
|
|
2227
|
+
const { done, value } = await reader.read();
|
|
2228
|
+
if (done) break;
|
|
2229
|
+
acc += decoder.decode(value, { stream: true });
|
|
2230
|
+
setMessages([...next, { role: "assistant", content: acc }]);
|
|
2231
|
+
}
|
|
2232
|
+
} catch {
|
|
2233
|
+
setMessages([...next, { role: "assistant", content: "\u26A0\uFE0F Couldn't reach the model. Is Ollama running? (ollama.com)" }]);
|
|
2234
|
+
} finally {
|
|
2235
|
+
setBusy(false);
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
return (
|
|
2240
|
+
<section className="mx-auto flex min-h-[calc(100vh-4rem)] max-w-2xl flex-col px-6 py-10">
|
|
2241
|
+
<header className="mb-6">
|
|
2242
|
+
<p className="text-sm font-semibold uppercase tracking-widest gradient-text">AI \xB7 ${ctx.template.siteName}</p>
|
|
2243
|
+
<h1 className="mt-2 text-3xl font-bold sm:text-4xl">Chat</h1>
|
|
2244
|
+
<p className="mt-2 text-muted">Free & local by default \u2014 powered by Ollama and the Lacspace AI Kit. No key required.</p>
|
|
2245
|
+
</header>
|
|
2246
|
+
|
|
2247
|
+
<div ref={scroller} className="flex-1 space-y-4 overflow-y-auto rounded-2xl border border-hairline bg-surface p-4">
|
|
2248
|
+
{messages.length === 0 && (
|
|
2249
|
+
<p className="py-16 text-center text-muted">Ask me anything to get started.</p>
|
|
2250
|
+
)}
|
|
2251
|
+
{messages.map((m, i) => (
|
|
2252
|
+
<div key={i} className={m.role === "user" ? "flex justify-end" : "flex justify-start"}>
|
|
2253
|
+
<div className={
|
|
2254
|
+
"max-w-[85%] whitespace-pre-wrap rounded-2xl px-4 py-2.5 text-sm leading-relaxed " +
|
|
2255
|
+
(m.role === "user" ? "gradient-bg on-accent" : "border border-hairline bg-app")
|
|
2256
|
+
}>
|
|
2257
|
+
{m.content || (busy ? "\u2026" : "")}
|
|
2258
|
+
</div>
|
|
2259
|
+
</div>
|
|
2260
|
+
))}
|
|
2261
|
+
</div>
|
|
2262
|
+
|
|
2263
|
+
<form onSubmit={send} className="mt-4 flex gap-2">
|
|
2264
|
+
<input
|
|
2265
|
+
value={input}
|
|
2266
|
+
onChange={(e) => setInput(e.target.value)}
|
|
2267
|
+
placeholder="Type a message\u2026"
|
|
2268
|
+
className="w-full rounded-xl border border-hairline bg-surface px-4 py-3 outline-none focus:border-hairline"
|
|
2269
|
+
/>
|
|
2270
|
+
<button
|
|
2271
|
+
type="submit"
|
|
2272
|
+
disabled={busy || !input.trim()}
|
|
2273
|
+
className="rounded-xl gradient-bg on-accent px-5 py-3 text-sm font-semibold shimmer disabled:opacity-50"
|
|
2274
|
+
>
|
|
2275
|
+
{busy ? "\u2026" : "Send"}
|
|
2276
|
+
</button>
|
|
2277
|
+
</form>
|
|
2278
|
+
</section>
|
|
2279
|
+
);
|
|
2280
|
+
}
|
|
2281
|
+
`;
|
|
2282
|
+
var ragSampleDoc = (ctx) => `# Welcome to ${ctx.template.siteName}
|
|
2283
|
+
|
|
2284
|
+
This markdown file lives in \`content/\`. Run \`npm run rag:index\` to chunk and embed
|
|
2285
|
+
everything under \`content/\` into a local vector index (\`.rag-index.json\`), then open
|
|
2286
|
+
\`/ask\` to ask grounded questions about it.
|
|
2287
|
+
|
|
2288
|
+
## How this works
|
|
2289
|
+
|
|
2290
|
+
- **Chunking** \u2014 \`@lacspace/chunk\` splits each document into overlapping passages.
|
|
2291
|
+
- **Embeddings** \u2014 \`@lacspace/embeddings\` turns each passage into a vector using a
|
|
2292
|
+
local Ollama model (\`nomic-embed-text\`) \u2014 no API key needed.
|
|
2293
|
+
- **Retrieval** \u2014 \`@lacspace/vector\` + \`@lacspace/rag\` find the passages closest to
|
|
2294
|
+
your question, and \`@lacspace/rerank\` re-orders them for relevance.
|
|
2295
|
+
- **Answering** \u2014 \`@lacspace/ai\` writes a grounded answer and cites its sources.
|
|
2296
|
+
|
|
2297
|
+
## Tips
|
|
2298
|
+
|
|
2299
|
+
Add your own \`.md\` or \`.txt\` files here, re-run \`npm run rag:index\`, and ask away.
|
|
2300
|
+
Everything runs on your machine \u2014 free, keyless and private.
|
|
2301
|
+
`;
|
|
2302
|
+
var ragIndexScript = (_ctx) => `// Build a local vector index from everything under content/.
|
|
2303
|
+
// Run: npm run rag:index (needs Ollama: \`ollama pull nomic-embed-text\`)
|
|
2304
|
+
import { readdir, readFile, writeFile, stat } from "node:fs/promises";
|
|
2305
|
+
import { join, extname, relative } from "node:path";
|
|
2306
|
+
import { splitText } from "@lacspace/chunk";
|
|
2307
|
+
import { embed } from "@lacspace/embeddings";
|
|
2308
|
+
import { createVectorStore } from "@lacspace/vector";
|
|
2309
|
+
|
|
2310
|
+
const ROOT = process.cwd();
|
|
2311
|
+
const CONTENT_DIR = join(ROOT, "content");
|
|
2312
|
+
const OUT = join(ROOT, ".rag-index.json");
|
|
2313
|
+
|
|
2314
|
+
// Which files to index (add more extensions if you like).
|
|
2315
|
+
const EXTS = new Set([".md", ".markdown", ".txt", ".mdx"]);
|
|
2316
|
+
|
|
2317
|
+
// Recursively collect readable files under content/.
|
|
2318
|
+
async function walk(dir) {
|
|
2319
|
+
const out = [];
|
|
2320
|
+
let entries = [];
|
|
2321
|
+
try { entries = await readdir(dir, { withFileTypes: true }); }
|
|
2322
|
+
catch { return out; }
|
|
2323
|
+
for (const e of entries) {
|
|
2324
|
+
const full = join(dir, e.name);
|
|
2325
|
+
if (e.isDirectory()) out.push(...(await walk(full)));
|
|
2326
|
+
else if (EXTS.has(extname(e.name).toLowerCase())) out.push(full);
|
|
2327
|
+
}
|
|
2328
|
+
return out;
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
async function main() {
|
|
2332
|
+
const files = await walk(CONTENT_DIR);
|
|
2333
|
+
if (files.length === 0) {
|
|
2334
|
+
console.error("No content found. Add .md/.txt files under content/ and re-run.");
|
|
2335
|
+
process.exit(1);
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
// 1. Chunk every document into overlapping passages via @lacspace/chunk.
|
|
2339
|
+
const records = [];
|
|
2340
|
+
for (const file of files) {
|
|
2341
|
+
const raw = await readFile(file, "utf8");
|
|
2342
|
+
const source = relative(CONTENT_DIR, file);
|
|
2343
|
+
const chunks = splitText(raw, { chunkSize: 900, chunkOverlap: 150 });
|
|
2344
|
+
chunks.forEach((chunk, i) => {
|
|
2345
|
+
records.push({ id: \`\${source}#\${i}\`, text: chunk.text, source });
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
// 2. Embed all passages with a local Ollama model (keyless) via @lacspace/embeddings.
|
|
2350
|
+
const model = process.env.LACSPACE_EMBED_MODEL ?? "nomic-embed-text";
|
|
2351
|
+
const baseUrl = process.env.LACSPACE_AI_BASE_URL ?? "http://localhost:11434";
|
|
2352
|
+
console.log(\`Embedding \${records.length} chunks with \${model}\u2026\`);
|
|
2353
|
+
const vectors = await embed(records.map((r) => r.text), { provider: "ollama", model, baseUrl });
|
|
2354
|
+
|
|
2355
|
+
// 3. Store them in an in-memory vector store, then serialise to .rag-index.json.
|
|
2356
|
+
const store = createVectorStore({ metric: "cosine" });
|
|
2357
|
+
store.upsert(records.map((r, i) => ({ id: r.id, vector: vectors[i], text: r.text, metadata: { source: r.source } })));
|
|
2358
|
+
|
|
2359
|
+
await writeFile(OUT, JSON.stringify(store.toJSON()));
|
|
2360
|
+
console.log(\`\u2713 Wrote \${OUT} (\${records.length} chunks from \${files.length} files).\`);
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
main().catch((err) => { console.error(err); process.exit(1); });
|
|
2364
|
+
`;
|
|
2365
|
+
var ragAskRoute = (_ctx) => `import { readFile } from "node:fs/promises";
|
|
2366
|
+
import { join } from "node:path";
|
|
2367
|
+
import { fromJSON } from "@lacspace/vector";
|
|
2368
|
+
import { createEmbedder } from "@lacspace/embeddings";
|
|
2369
|
+
import { createRag } from "@lacspace/rag";
|
|
2370
|
+
import { rerank } from "@lacspace/rerank";
|
|
2371
|
+
import { resolveConfig } from "@lacspace/providers";
|
|
2372
|
+
import { chat } from "@lacspace/ai";
|
|
2373
|
+
import type { Message } from "@lacspace/ai";
|
|
2374
|
+
|
|
2375
|
+
export const runtime = "nodejs";
|
|
2376
|
+
export const dynamic = "force-dynamic";
|
|
2377
|
+
|
|
2378
|
+
export async function POST(req: Request) {
|
|
2379
|
+
const { question } = (await req.json()) as { question: string };
|
|
2380
|
+
if (!question || !question.trim()) {
|
|
2381
|
+
return Response.json({ error: "Ask a question." }, { status: 400 });
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
// 1. Load the local index built by \`npm run rag:index\`.
|
|
2385
|
+
let store;
|
|
2386
|
+
try {
|
|
2387
|
+
const raw = await readFile(join(process.cwd(), ".rag-index.json"), "utf8");
|
|
2388
|
+
store = fromJSON(JSON.parse(raw));
|
|
2389
|
+
} catch {
|
|
2390
|
+
return Response.json({ error: "No index found. Run \`npm run rag:index\` first." }, { status: 503 });
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
// 2. Config \u2014 keyless local Ollama by default.
|
|
2394
|
+
// Learn more \u2192 https://developer.lacspace.com/packages/providers
|
|
2395
|
+
const cfg = resolveConfig(process.env.LACSPACE_AI_PROVIDER ?? "ollama", {
|
|
2396
|
+
baseUrl: process.env.LACSPACE_AI_BASE_URL,
|
|
2397
|
+
model: process.env.LACSPACE_AI_MODEL,
|
|
2398
|
+
apiKey: process.env.LACSPACE_AI_API_KEY,
|
|
2399
|
+
});
|
|
2400
|
+
const embedModel = process.env.LACSPACE_EMBED_MODEL ?? "nomic-embed-text";
|
|
2401
|
+
const embedBaseUrl = cfg.apiStyle === "ollama" ? cfg.baseUrl : (process.env.LACSPACE_AI_BASE_URL ?? "http://localhost:11434");
|
|
2402
|
+
|
|
2403
|
+
// 3. Retrieve the closest passages via @lacspace/rag + @lacspace/embeddings.
|
|
2404
|
+
// Learn more \u2192 https://developer.lacspace.com/packages/rag
|
|
2405
|
+
const embedder = createEmbedder({ provider: "ollama", model: embedModel, baseUrl: embedBaseUrl });
|
|
2406
|
+
const rag = createRag({ embed: embedder, store });
|
|
2407
|
+
const hits = await rag.retrieve(question, { k: 8 });
|
|
2408
|
+
|
|
2409
|
+
// 4. Re-rank for relevance and keep the best few. @lacspace/rerank blends the
|
|
2410
|
+
// vector score with a lexical match \u2014 no second model call needed.
|
|
2411
|
+
// Learn more \u2192 https://developer.lacspace.com/packages/rerank
|
|
2412
|
+
const ranked = await rerank(question, hits.map((h) => ({ id: h.id, text: h.text, score: h.score })), { k: 4 });
|
|
2413
|
+
|
|
2414
|
+
// 5. Build a grounded prompt and answer with @lacspace/ai.
|
|
2415
|
+
// Learn more \u2192 https://developer.lacspace.com/packages/ai
|
|
2416
|
+
const context = ranked.map((d, i) => \`[\${i + 1}] \${d.text}\`).join("\\n\\n");
|
|
2417
|
+
const messages: Message[] = [
|
|
2418
|
+
{ role: "system", content: "You answer strictly from the provided context. If the answer isn't there, say you don't know. Cite sources as [n]." },
|
|
2419
|
+
{ role: "user", content: \`Context:\\n\${context}\\n\\nQuestion: \${question}\` },
|
|
2420
|
+
];
|
|
2421
|
+
|
|
2422
|
+
const baseUrl = cfg.apiStyle === "ollama" ? cfg.baseUrl.replace(/\\/+$/, "") + "/v1" : cfg.baseUrl;
|
|
2423
|
+
const res = await chat({
|
|
2424
|
+
provider: "openai-compatible",
|
|
2425
|
+
baseUrl,
|
|
2426
|
+
apiKey: cfg.apiKey,
|
|
2427
|
+
model: cfg.model ?? "llama3.2",
|
|
2428
|
+
headers: cfg.headers,
|
|
2429
|
+
messages,
|
|
2430
|
+
});
|
|
2431
|
+
|
|
2432
|
+
const sources = ranked.map((d, i) => ({ n: i + 1, id: d.id }));
|
|
2433
|
+
return Response.json({ answer: res.text, sources });
|
|
2434
|
+
}
|
|
2435
|
+
`;
|
|
2436
|
+
var ragAskPage = (ctx) => `"use client";
|
|
2437
|
+
|
|
2438
|
+
import { useState } from "react";
|
|
2439
|
+
|
|
2440
|
+
interface Source { n: number; id: string; }
|
|
2441
|
+
|
|
2442
|
+
export default function AskPage() {
|
|
2443
|
+
const [question, setQuestion] = useState("");
|
|
2444
|
+
const [answer, setAnswer] = useState("");
|
|
2445
|
+
const [sources, setSources] = useState<Source[]>([]);
|
|
2446
|
+
const [busy, setBusy] = useState(false);
|
|
2447
|
+
const [error, setError] = useState("");
|
|
2448
|
+
|
|
2449
|
+
async function ask(e: React.FormEvent) {
|
|
2450
|
+
e.preventDefault();
|
|
2451
|
+
const q = question.trim();
|
|
2452
|
+
if (!q || busy) return;
|
|
2453
|
+
setBusy(true); setError(""); setAnswer(""); setSources([]);
|
|
2454
|
+
try {
|
|
2455
|
+
const res = await fetch("/api/ask", {
|
|
2456
|
+
method: "POST",
|
|
2457
|
+
headers: { "Content-Type": "application/json" },
|
|
2458
|
+
body: JSON.stringify({ question: q }),
|
|
2459
|
+
});
|
|
2460
|
+
const data = await res.json();
|
|
2461
|
+
if (!res.ok) throw new Error(data.error || "Something went wrong.");
|
|
2462
|
+
setAnswer(data.answer);
|
|
2463
|
+
setSources(data.sources ?? []);
|
|
2464
|
+
} catch (err) {
|
|
2465
|
+
setError(err instanceof Error ? err.message : "Something went wrong.");
|
|
2466
|
+
} finally {
|
|
2467
|
+
setBusy(false);
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
return (
|
|
2472
|
+
<section className="mx-auto max-w-2xl px-6 py-16">
|
|
2473
|
+
<p className="text-sm font-semibold uppercase tracking-widest gradient-text">RAG \xB7 ${ctx.template.siteName}</p>
|
|
2474
|
+
<h1 className="mt-2 text-3xl font-bold sm:text-4xl">Ask your content</h1>
|
|
2475
|
+
<p className="mt-2 text-muted">Grounded answers from your own docs \u2014 indexed and answered locally. Run <code className="rounded bg-panel px-1.5 py-0.5 text-sm">npm run rag:index</code> first.</p>
|
|
2476
|
+
|
|
2477
|
+
<form onSubmit={ask} className="mt-8 flex gap-2">
|
|
2478
|
+
<input
|
|
2479
|
+
value={question}
|
|
2480
|
+
onChange={(e) => setQuestion(e.target.value)}
|
|
2481
|
+
placeholder="What is this project about?"
|
|
2482
|
+
className="w-full rounded-xl border border-hairline bg-surface px-4 py-3 outline-none focus:border-hairline"
|
|
2483
|
+
/>
|
|
2484
|
+
<button
|
|
2485
|
+
type="submit"
|
|
2486
|
+
disabled={busy || !question.trim()}
|
|
2487
|
+
className="rounded-xl gradient-bg on-accent px-5 py-3 text-sm font-semibold shimmer disabled:opacity-50"
|
|
2488
|
+
>
|
|
2489
|
+
{busy ? "\u2026" : "Ask"}
|
|
2490
|
+
</button>
|
|
2491
|
+
</form>
|
|
2492
|
+
|
|
2493
|
+
{error && <p className="mt-6 rounded-xl border border-red-400/40 bg-red-400/10 px-4 py-3 text-sm text-red-400">{error}</p>}
|
|
2494
|
+
|
|
2495
|
+
{answer && (
|
|
2496
|
+
<div className="mt-8 card p-6">
|
|
2497
|
+
<p className="whitespace-pre-wrap leading-relaxed">{answer}</p>
|
|
2498
|
+
{sources.length > 0 && (
|
|
2499
|
+
<div className="mt-5 border-t border-hairline pt-4">
|
|
2500
|
+
<p className="text-xs font-semibold uppercase tracking-widest text-muted">Sources</p>
|
|
2501
|
+
<ul className="mt-2 space-y-1 text-sm text-muted">
|
|
2502
|
+
{sources.map((s) => (
|
|
2503
|
+
<li key={s.n}>[{s.n}] <code className="text-fg">{s.id}</code></li>
|
|
2504
|
+
))}
|
|
2505
|
+
</ul>
|
|
2506
|
+
</div>
|
|
2507
|
+
)}
|
|
2508
|
+
</div>
|
|
2509
|
+
)}
|
|
2510
|
+
</section>
|
|
2511
|
+
);
|
|
2512
|
+
}
|
|
2513
|
+
`;
|
|
1971
2514
|
function pagesFor(ctx) {
|
|
1972
2515
|
const k = ctx.template.key;
|
|
1973
2516
|
if (k === "dashboard") {
|
|
@@ -4556,8 +5099,921 @@ var faqSection = (ctx) => {
|
|
|
4556
5099
|
</div>
|
|
4557
5100
|
</section>`;
|
|
4558
5101
|
};
|
|
5102
|
+
var scope = (ctx) => `@${ctx.name}`;
|
|
5103
|
+
var typesPkgJson = (ctx) => JSON.stringify({
|
|
5104
|
+
name: `${scope(ctx)}/types`,
|
|
5105
|
+
version: "0.1.0",
|
|
5106
|
+
private: true,
|
|
5107
|
+
// Type-only package: it ships a single .d.ts (no runtime code, nothing to build).
|
|
5108
|
+
types: "./index.d.ts",
|
|
5109
|
+
exports: { ".": { types: "./index.d.ts" } }
|
|
5110
|
+
}, null, 2) + "\n";
|
|
5111
|
+
var sharedTypes = () => `// Shared API contract \u2014 imported by BOTH the frontend and the backend, so the two
|
|
5112
|
+
// can never drift. These are TYPE-ONLY (no runtime code), so importing them adds
|
|
5113
|
+
// nothing to either bundle. Edit here once; both sides update. Use \`import type\`.
|
|
5114
|
+
|
|
5115
|
+
/** A user, as returned by the API (no password; dates are ISO strings). */
|
|
5116
|
+
export interface User {
|
|
5117
|
+
id: string;
|
|
5118
|
+
email: string;
|
|
5119
|
+
name: string;
|
|
5120
|
+
createdAt: string;
|
|
5121
|
+
}
|
|
5122
|
+
|
|
5123
|
+
/** The response from POST /auth/register and POST /auth/login. */
|
|
5124
|
+
export interface AuthResponse {
|
|
5125
|
+
token: string;
|
|
5126
|
+
user: User;
|
|
5127
|
+
}
|
|
5128
|
+
|
|
5129
|
+
/** The shape of an error response from the API. */
|
|
5130
|
+
export interface ApiError {
|
|
5131
|
+
error: string;
|
|
5132
|
+
fields?: Record<string, string>;
|
|
5133
|
+
}
|
|
5134
|
+
|
|
5135
|
+
/** The example CRUD resource. Rename "Note" to your real domain object. */
|
|
5136
|
+
export interface Note {
|
|
5137
|
+
id: string;
|
|
5138
|
+
title: string;
|
|
5139
|
+
body: string;
|
|
5140
|
+
userId: string;
|
|
5141
|
+
createdAt: string;
|
|
5142
|
+
updatedAt: string;
|
|
5143
|
+
}
|
|
5144
|
+
|
|
5145
|
+
export interface CreateNoteInput {
|
|
5146
|
+
title: string;
|
|
5147
|
+
body?: string;
|
|
5148
|
+
}
|
|
5149
|
+
|
|
5150
|
+
export interface UpdateNoteInput {
|
|
5151
|
+
title?: string;
|
|
5152
|
+
body?: string;
|
|
5153
|
+
}
|
|
5154
|
+
`;
|
|
5155
|
+
var rootPkgJson = (ctx) => JSON.stringify({
|
|
5156
|
+
name: ctx.name,
|
|
5157
|
+
version: "0.1.0",
|
|
5158
|
+
private: true,
|
|
5159
|
+
workspaces: ["types", "backend", "frontend"],
|
|
5160
|
+
scripts: {
|
|
5161
|
+
// Run the API and the frontend together (concurrently), colourised + kill-all.
|
|
5162
|
+
dev: 'concurrently -k -n api,web -c blue,magenta "npm:dev:api" "npm:dev:web"',
|
|
5163
|
+
"dev:api": `npm run dev --workspace ${scope(ctx)}/backend`,
|
|
5164
|
+
"dev:web": `npm run dev --workspace ${scope(ctx)}/frontend`,
|
|
5165
|
+
build: `npm run build --workspace ${scope(ctx)}/backend && npm run build --workspace ${scope(ctx)}/frontend`,
|
|
5166
|
+
start: 'concurrently -k -n api,web -c blue,magenta "npm:start:api" "npm:start:web"',
|
|
5167
|
+
"start:api": `npm run start --workspace ${scope(ctx)}/backend`,
|
|
5168
|
+
"start:web": `npm run start --workspace ${scope(ctx)}/frontend`,
|
|
5169
|
+
typecheck: `npm run typecheck --workspace ${scope(ctx)}/backend`
|
|
5170
|
+
},
|
|
5171
|
+
devDependencies: { concurrently: "^9.1.0" },
|
|
5172
|
+
engines: { node: ">=18" }
|
|
5173
|
+
}, null, 2) + "\n";
|
|
5174
|
+
var dockerCompose = (ctx) => `# Local infrastructure for ${ctx.name}. Start it with: docker compose up -d
|
|
5175
|
+
# Optional \u2014 the API also runs with NO Redis, and against any MongoDB (e.g. Atlas).
|
|
5176
|
+
services:
|
|
5177
|
+
mongo:
|
|
5178
|
+
image: mongo:7
|
|
5179
|
+
restart: unless-stopped
|
|
5180
|
+
ports:
|
|
5181
|
+
- "27017:27017"
|
|
5182
|
+
volumes:
|
|
5183
|
+
- mongo-data:/data/db
|
|
5184
|
+
redis:
|
|
5185
|
+
image: redis:7-alpine
|
|
5186
|
+
restart: unless-stopped
|
|
5187
|
+
ports:
|
|
5188
|
+
- "6379:6379"
|
|
5189
|
+
|
|
5190
|
+
volumes:
|
|
5191
|
+
mongo-data:
|
|
5192
|
+
`;
|
|
5193
|
+
var rootGitignore = () => `node_modules
|
|
5194
|
+
.env
|
|
5195
|
+
.env.local
|
|
5196
|
+
*.log
|
|
5197
|
+
.DS_Store
|
|
5198
|
+
dist
|
|
5199
|
+
.next
|
|
5200
|
+
`;
|
|
5201
|
+
var rootEnvExample = (ctx) => `# ${ctx.name} \u2014 ONE .env for the whole monorepo. Copy it: cp .env.example .env
|
|
5202
|
+
# The defaults below let the app run locally with zero changes.
|
|
5203
|
+
|
|
5204
|
+
# --- Backend ---
|
|
5205
|
+
NODE_ENV=development
|
|
5206
|
+
PORT=4000
|
|
5207
|
+
|
|
5208
|
+
# MongoDB. With Docker (docker compose up -d) the default just works. Or paste a
|
|
5209
|
+
# free MongoDB Atlas connection string here.
|
|
5210
|
+
MONGODB_URI=mongodb://localhost:27017/${ctx.name}
|
|
5211
|
+
|
|
5212
|
+
# Signs JWTs. In production generate a strong value: openssl rand -hex 32
|
|
5213
|
+
JWT_SECRET=dev-secret-change-me
|
|
5214
|
+
|
|
5215
|
+
# Redis is OPTIONAL \u2014 leave empty to use the in-memory cache fallback.
|
|
5216
|
+
# With Docker, set it to: redis://localhost:6379
|
|
5217
|
+
REDIS_URL=
|
|
5218
|
+
|
|
5219
|
+
# Who may call the API (the frontend origin). Comma-separate for multiple.
|
|
5220
|
+
CORS_ORIGIN=http://localhost:3000
|
|
5221
|
+
|
|
5222
|
+
# --- Frontend ---
|
|
5223
|
+
# Where the browser calls the API. Unset = http://localhost:4000 (local default).
|
|
5224
|
+
# NEXT_PUBLIC_API_URL=http://localhost:4000
|
|
5225
|
+
`;
|
|
5226
|
+
var rootReadme = (ctx) => `# ${ctx.name}
|
|
5227
|
+
|
|
5228
|
+
A full-stack app scaffolded with [create-lacspace-app](https://www.npmjs.com/package/create-lacspace-app) \u2014 a **${ctx.template.label}** frontend + a real backend, wired together.
|
|
5229
|
+
|
|
5230
|
+
\`\`\`
|
|
5231
|
+
${ctx.name}/
|
|
5232
|
+
\u251C\u2500 frontend/ Next.js 15 app (your UI) \u2014 talks to the API
|
|
5233
|
+
\u251C\u2500 backend/ Node \xB7 Express \xB7 MongoDB \xB7 Redis \xB7 TypeScript API
|
|
5234
|
+
\u251C\u2500 types/ Shared API types \u2014 imported by BOTH sides, so they can't drift
|
|
5235
|
+
\u2514\u2500 docker-compose.yml Mongo + Redis for local dev (optional)
|
|
5236
|
+
\`\`\`
|
|
5237
|
+
|
|
5238
|
+
## Quick start
|
|
5239
|
+
|
|
5240
|
+
\`\`\`bash
|
|
5241
|
+
cp .env.example .env # one env file for everything (sensible defaults)
|
|
5242
|
+
docker compose up -d # optional: starts MongoDB + Redis locally
|
|
5243
|
+
npm install # installs all three workspaces at once
|
|
5244
|
+
npm run dev # runs the API (:4000) and the frontend (:3000) together
|
|
5245
|
+
\`\`\`
|
|
5246
|
+
|
|
5247
|
+
Then open **http://localhost:3000** \u2192 visit **/register**, create an account, and you'll land on **/account** \u2014 a protected page that reads/writes the example "notes" resource through the API.
|
|
5248
|
+
|
|
5249
|
+
**No Docker?** Point \`MONGODB_URI\` at any MongoDB (a free [Atlas](https://www.mongodb.com/atlas) cluster works). Redis is optional \u2014 leave \`REDIS_URL\` empty and the API uses an in-memory cache instead.
|
|
5250
|
+
|
|
5251
|
+
## What's already built
|
|
5252
|
+
|
|
5253
|
+
- **Auth** \u2014 \`POST /auth/register\`, \`POST /auth/login\` (JWT), \`GET /auth/me\`. Passwords hashed with \`@lacspace/password\`, tokens signed/verified with \`@lacspace/jwt\`.
|
|
5254
|
+
- **Example CRUD** \u2014 \`/notes\` (list \xB7 create \xB7 read \xB7 update \xB7 delete), protected, per-user, with a per-user cache (Redis or in-memory) that busts on writes.
|
|
5255
|
+
- **Validation** \u2014 request bodies validated with \`@lacspace/validate\`; failures return a clean 400.
|
|
5256
|
+
- **Rate limiting** \u2014 auth endpoints throttled with \`@lacspace/rate-limit\`.
|
|
5257
|
+
- **Typed env** \u2014 \`@lacspace/env\` fails fast at boot if config is wrong.
|
|
5258
|
+
|
|
5259
|
+
Everything on the backend is built from zero-dependency \`@lacspace/*\` packages \u2192 https://lacspace.com/packages
|
|
5260
|
+
|
|
5261
|
+
## Make it yours
|
|
5262
|
+
|
|
5263
|
+
Rename the \`Note\` resource (in \`types/index.d.ts\`, \`backend/src/models/note.ts\`, \`backend/src/routes/notes.ts\`) to your real domain object, then follow the same pattern for more resources. The shared \`types/\` package keeps the frontend and backend in lock-step.
|
|
5264
|
+
`;
|
|
5265
|
+
var frontendApiClient = (ctx) => `import type { AuthResponse, User, Note, CreateNoteInput, ApiError } from "${scope(ctx)}/types";
|
|
5266
|
+
|
|
5267
|
+
// how this works: a tiny typed fetch wrapper around the backend API. The response
|
|
5268
|
+
// shapes come from the shared "${scope(ctx)}/types" package \u2014 the SAME types the
|
|
5269
|
+
// backend uses \u2014 so the client and server can never disagree about the contract.
|
|
5270
|
+
const BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
5271
|
+
const TOKEN_KEY = "${ctx.name}_token";
|
|
5272
|
+
|
|
5273
|
+
export function getToken(): string | null {
|
|
5274
|
+
if (typeof window === "undefined") return null;
|
|
5275
|
+
try { return localStorage.getItem(TOKEN_KEY); } catch { return null; }
|
|
5276
|
+
}
|
|
5277
|
+
export function setToken(token: string | null): void {
|
|
5278
|
+
if (typeof window === "undefined") return;
|
|
5279
|
+
try { token ? localStorage.setItem(TOKEN_KEY, token) : localStorage.removeItem(TOKEN_KEY); } catch {}
|
|
5280
|
+
}
|
|
5281
|
+
|
|
5282
|
+
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
5283
|
+
const token = getToken();
|
|
5284
|
+
const res = await fetch(\`\${BASE}\${path}\`, {
|
|
5285
|
+
...init,
|
|
5286
|
+
headers: {
|
|
5287
|
+
"Content-Type": "application/json",
|
|
5288
|
+
...(token ? { Authorization: \`Bearer \${token}\` } : {}),
|
|
5289
|
+
...(init?.headers ?? {}),
|
|
5290
|
+
},
|
|
5291
|
+
});
|
|
5292
|
+
if (!res.ok) {
|
|
5293
|
+
let message = "Request failed";
|
|
5294
|
+
try { message = ((await res.json()) as ApiError).error ?? message; } catch {}
|
|
5295
|
+
throw new Error(message);
|
|
5296
|
+
}
|
|
5297
|
+
if (res.status === 204) return undefined as T;
|
|
5298
|
+
return (await res.json()) as T;
|
|
5299
|
+
}
|
|
5300
|
+
|
|
5301
|
+
export const api = {
|
|
5302
|
+
register: (input: { name: string; email: string; password: string }) =>
|
|
5303
|
+
request<AuthResponse>("/auth/register", { method: "POST", body: JSON.stringify(input) }),
|
|
5304
|
+
login: (input: { email: string; password: string }) =>
|
|
5305
|
+
request<AuthResponse>("/auth/login", { method: "POST", body: JSON.stringify(input) }),
|
|
5306
|
+
me: () => request<User>("/auth/me"),
|
|
5307
|
+
listNotes: () => request<Note[]>("/notes"),
|
|
5308
|
+
createNote: (input: CreateNoteInput) =>
|
|
5309
|
+
request<Note>("/notes", { method: "POST", body: JSON.stringify(input) }),
|
|
5310
|
+
deleteNote: (id: string) => request<void>(\`/notes/\${id}\`, { method: "DELETE" }),
|
|
5311
|
+
};
|
|
5312
|
+
`;
|
|
5313
|
+
var frontendLoginPage = () => `"use client";
|
|
5314
|
+
import { useState } from "react";
|
|
5315
|
+
import { useRouter } from "next/navigation";
|
|
5316
|
+
import Link from "next/link";
|
|
5317
|
+
import { api, setToken } from "@/lib/api";
|
|
5318
|
+
|
|
5319
|
+
// how this works: posts to the backend /auth/login, stores the returned JWT, then
|
|
5320
|
+
// redirects to /account. Every request after this sends the token automatically.
|
|
5321
|
+
export default function LoginPage() {
|
|
5322
|
+
const router = useRouter();
|
|
5323
|
+
const [email, setEmail] = useState("");
|
|
5324
|
+
const [password, setPassword] = useState("");
|
|
5325
|
+
const [error, setError] = useState<string | null>(null);
|
|
5326
|
+
const [busy, setBusy] = useState(false);
|
|
5327
|
+
|
|
5328
|
+
async function onSubmit(e: React.FormEvent) {
|
|
5329
|
+
e.preventDefault();
|
|
5330
|
+
setBusy(true); setError(null);
|
|
5331
|
+
try {
|
|
5332
|
+
const { token } = await api.login({ email, password });
|
|
5333
|
+
setToken(token);
|
|
5334
|
+
router.push("/account");
|
|
5335
|
+
} catch (err) {
|
|
5336
|
+
setError(err instanceof Error ? err.message : "Something went wrong");
|
|
5337
|
+
} finally { setBusy(false); }
|
|
5338
|
+
}
|
|
5339
|
+
|
|
5340
|
+
return (
|
|
5341
|
+
<main className="mx-auto flex min-h-[70vh] max-w-sm flex-col justify-center px-6">
|
|
5342
|
+
<h1 className="text-2xl font-bold">Welcome back</h1>
|
|
5343
|
+
<p className="mt-1 text-muted">Sign in to your account.</p>
|
|
5344
|
+
<form onSubmit={onSubmit} className="mt-6 space-y-4">
|
|
5345
|
+
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" required placeholder="you@example.com" className="w-full rounded-xl border border-hairline bg-surface px-4 py-3 outline-none" />
|
|
5346
|
+
<input value={password} onChange={(e) => setPassword(e.target.value)} type="password" required placeholder="Password" className="w-full rounded-xl border border-hairline bg-surface px-4 py-3 outline-none" />
|
|
5347
|
+
{error && <p className="text-sm text-red-400">{error}</p>}
|
|
5348
|
+
<button disabled={busy} className="w-full rounded-full gradient-bg px-4 py-3 font-semibold on-accent transition hover:opacity-90 disabled:opacity-60">{busy ? "Signing in\u2026" : "Sign in"}</button>
|
|
5349
|
+
</form>
|
|
5350
|
+
<p className="mt-4 text-sm text-muted">No account? <Link href="/register" className="underline">Create one</Link></p>
|
|
5351
|
+
</main>
|
|
5352
|
+
);
|
|
5353
|
+
}
|
|
5354
|
+
`;
|
|
5355
|
+
var frontendRegisterPage = () => `"use client";
|
|
5356
|
+
import { useState } from "react";
|
|
5357
|
+
import { useRouter } from "next/navigation";
|
|
5358
|
+
import Link from "next/link";
|
|
5359
|
+
import { api, setToken } from "@/lib/api";
|
|
5360
|
+
|
|
5361
|
+
// how this works: posts to /auth/register, stores the JWT, redirects to /account.
|
|
5362
|
+
export default function RegisterPage() {
|
|
5363
|
+
const router = useRouter();
|
|
5364
|
+
const [name, setName] = useState("");
|
|
5365
|
+
const [email, setEmail] = useState("");
|
|
5366
|
+
const [password, setPassword] = useState("");
|
|
5367
|
+
const [error, setError] = useState<string | null>(null);
|
|
5368
|
+
const [busy, setBusy] = useState(false);
|
|
5369
|
+
|
|
5370
|
+
async function onSubmit(e: React.FormEvent) {
|
|
5371
|
+
e.preventDefault();
|
|
5372
|
+
setBusy(true); setError(null);
|
|
5373
|
+
try {
|
|
5374
|
+
const { token } = await api.register({ name, email, password });
|
|
5375
|
+
setToken(token);
|
|
5376
|
+
router.push("/account");
|
|
5377
|
+
} catch (err) {
|
|
5378
|
+
setError(err instanceof Error ? err.message : "Something went wrong");
|
|
5379
|
+
} finally { setBusy(false); }
|
|
5380
|
+
}
|
|
5381
|
+
|
|
5382
|
+
return (
|
|
5383
|
+
<main className="mx-auto flex min-h-[70vh] max-w-sm flex-col justify-center px-6">
|
|
5384
|
+
<h1 className="text-2xl font-bold">Create your account</h1>
|
|
5385
|
+
<p className="mt-1 text-muted">It takes a few seconds.</p>
|
|
5386
|
+
<form onSubmit={onSubmit} className="mt-6 space-y-4">
|
|
5387
|
+
<input value={name} onChange={(e) => setName(e.target.value)} required placeholder="Your name" className="w-full rounded-xl border border-hairline bg-surface px-4 py-3 outline-none" />
|
|
5388
|
+
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" required placeholder="you@example.com" className="w-full rounded-xl border border-hairline bg-surface px-4 py-3 outline-none" />
|
|
5389
|
+
<input value={password} onChange={(e) => setPassword(e.target.value)} type="password" required minLength={8} placeholder="Password (min 8 chars)" className="w-full rounded-xl border border-hairline bg-surface px-4 py-3 outline-none" />
|
|
5390
|
+
{error && <p className="text-sm text-red-400">{error}</p>}
|
|
5391
|
+
<button disabled={busy} className="w-full rounded-full gradient-bg px-4 py-3 font-semibold on-accent transition hover:opacity-90 disabled:opacity-60">{busy ? "Creating\u2026" : "Create account"}</button>
|
|
5392
|
+
</form>
|
|
5393
|
+
<p className="mt-4 text-sm text-muted">Already have one? <Link href="/login" className="underline">Sign in</Link></p>
|
|
5394
|
+
</main>
|
|
5395
|
+
);
|
|
5396
|
+
}
|
|
5397
|
+
`;
|
|
5398
|
+
var frontendAccountPage = (ctx) => `"use client";
|
|
5399
|
+
import { useEffect, useState } from "react";
|
|
5400
|
+
import { useRouter } from "next/navigation";
|
|
5401
|
+
import { api, getToken, setToken } from "@/lib/api";
|
|
5402
|
+
import type { User, Note } from "${scope(ctx)}/types";
|
|
5403
|
+
|
|
5404
|
+
// how this works: a PROTECTED page. On mount it calls /auth/me with the stored
|
|
5405
|
+
// token; if that fails it bounces to /login. Notes are the example CRUD resource.
|
|
5406
|
+
export default function AccountPage() {
|
|
5407
|
+
const router = useRouter();
|
|
5408
|
+
const [user, setUser] = useState<User | null>(null);
|
|
5409
|
+
const [notes, setNotes] = useState<Note[]>([]);
|
|
5410
|
+
const [title, setTitle] = useState("");
|
|
5411
|
+
const [body, setBody] = useState("");
|
|
5412
|
+
const [error, setError] = useState<string | null>(null);
|
|
5413
|
+
|
|
5414
|
+
useEffect(() => {
|
|
5415
|
+
if (!getToken()) { router.push("/login"); return; }
|
|
5416
|
+
(async () => {
|
|
5417
|
+
try {
|
|
5418
|
+
setUser(await api.me());
|
|
5419
|
+
setNotes(await api.listNotes());
|
|
5420
|
+
} catch { setToken(null); router.push("/login"); }
|
|
5421
|
+
})();
|
|
5422
|
+
}, [router]);
|
|
5423
|
+
|
|
5424
|
+
async function addNote(e: React.FormEvent) {
|
|
5425
|
+
e.preventDefault();
|
|
5426
|
+
setError(null);
|
|
5427
|
+
try {
|
|
5428
|
+
const note = await api.createNote({ title, body });
|
|
5429
|
+
setNotes((prev) => [note, ...prev]);
|
|
5430
|
+
setTitle(""); setBody("");
|
|
5431
|
+
} catch (err) { setError(err instanceof Error ? err.message : "Failed to save"); }
|
|
5432
|
+
}
|
|
5433
|
+
async function remove(id: string) {
|
|
5434
|
+
await api.deleteNote(id);
|
|
5435
|
+
setNotes((prev) => prev.filter((n) => n.id !== id));
|
|
5436
|
+
}
|
|
5437
|
+
function signOut() { setToken(null); router.push("/login"); }
|
|
5438
|
+
|
|
5439
|
+
if (!user) return <main className="mx-auto max-w-2xl px-6 py-16 text-muted">Loading\u2026</main>;
|
|
5440
|
+
|
|
5441
|
+
return (
|
|
5442
|
+
<main className="mx-auto max-w-2xl px-6 py-16">
|
|
5443
|
+
<div className="flex items-center justify-between">
|
|
5444
|
+
<div>
|
|
5445
|
+
<h1 className="text-2xl font-bold">Hi, {user.name}</h1>
|
|
5446
|
+
<p className="text-muted">{user.email}</p>
|
|
5447
|
+
</div>
|
|
5448
|
+
<button onClick={signOut} className="rounded-full border border-hairline px-4 py-2 text-sm transition hover:bg-surface">Sign out</button>
|
|
5449
|
+
</div>
|
|
5450
|
+
|
|
5451
|
+
<form onSubmit={addNote} className="mt-8 space-y-3 rounded-2xl border border-hairline bg-surface p-5">
|
|
5452
|
+
<h2 className="font-semibold">Add a note</h2>
|
|
5453
|
+
<input value={title} onChange={(e) => setTitle(e.target.value)} required placeholder="Title" className="w-full rounded-xl border border-hairline bg-app px-4 py-2 outline-none" />
|
|
5454
|
+
<textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write something\u2026" rows={3} className="w-full rounded-xl border border-hairline bg-app px-4 py-2 outline-none" />
|
|
5455
|
+
{error && <p className="text-sm text-red-400">{error}</p>}
|
|
5456
|
+
<button className="rounded-full gradient-bg px-4 py-2 text-sm font-semibold on-accent">Save note</button>
|
|
5457
|
+
</form>
|
|
5458
|
+
|
|
5459
|
+
<ul className="mt-6 space-y-3">
|
|
5460
|
+
{notes.length === 0 && <li className="text-muted">No notes yet \u2014 add your first above.</li>}
|
|
5461
|
+
{notes.map((n) => (
|
|
5462
|
+
<li key={n.id} className="flex items-start justify-between rounded-2xl border border-hairline p-4">
|
|
5463
|
+
<div>
|
|
5464
|
+
<p className="font-medium">{n.title}</p>
|
|
5465
|
+
{n.body && <p className="mt-1 text-sm text-muted">{n.body}</p>}
|
|
5466
|
+
</div>
|
|
5467
|
+
<button onClick={() => remove(n.id)} className="shrink-0 text-sm text-muted transition hover:text-red-400">Delete</button>
|
|
5468
|
+
</li>
|
|
5469
|
+
))}
|
|
5470
|
+
</ul>
|
|
5471
|
+
</main>
|
|
5472
|
+
);
|
|
5473
|
+
}
|
|
5474
|
+
`;
|
|
5475
|
+
function buildFullStack(ctx) {
|
|
5476
|
+
const out = {};
|
|
5477
|
+
const s = scope(ctx);
|
|
5478
|
+
const app = buildApp(ctx);
|
|
5479
|
+
for (const [rel, content] of Object.entries(app)) out[`frontend/${rel}`] = content;
|
|
5480
|
+
const fpkg = JSON.parse(out["frontend/package.json"]);
|
|
5481
|
+
fpkg.name = `${s}/frontend`;
|
|
5482
|
+
fpkg.dependencies[`${s}/types`] = "*";
|
|
5483
|
+
out["frontend/package.json"] = JSON.stringify(fpkg, null, 2) + "\n";
|
|
5484
|
+
const fts = JSON.parse(out["frontend/tsconfig.json"]);
|
|
5485
|
+
fts.compilerOptions.paths[`${s}/types`] = ["../types/index.d.ts"];
|
|
5486
|
+
out["frontend/tsconfig.json"] = JSON.stringify(fts, null, 2) + "\n";
|
|
5487
|
+
const fenv = out["frontend/.env.example"] ?? "";
|
|
5488
|
+
out["frontend/.env.example"] = (fenv.endsWith("\n") || fenv === "" ? fenv : fenv + "\n") + "\n# The backend API base URL. Unset = http://localhost:4000 (local default).\n# NEXT_PUBLIC_API_URL=http://localhost:4000\n";
|
|
5489
|
+
out["frontend/lib/api.ts"] = frontendApiClient(ctx);
|
|
5490
|
+
out["frontend/app/login/page.tsx"] = frontendLoginPage();
|
|
5491
|
+
out["frontend/app/register/page.tsx"] = frontendRegisterPage();
|
|
5492
|
+
out["frontend/app/account/page.tsx"] = frontendAccountPage(ctx);
|
|
5493
|
+
Object.assign(out, backendFiles(ctx));
|
|
5494
|
+
out["types/package.json"] = typesPkgJson(ctx);
|
|
5495
|
+
out["types/index.d.ts"] = sharedTypes();
|
|
5496
|
+
out["package.json"] = rootPkgJson(ctx);
|
|
5497
|
+
out["docker-compose.yml"] = dockerCompose(ctx);
|
|
5498
|
+
out[".gitignore"] = rootGitignore();
|
|
5499
|
+
out[".env.example"] = rootEnvExample(ctx);
|
|
5500
|
+
out["README.md"] = rootReadme(ctx);
|
|
5501
|
+
return out;
|
|
5502
|
+
}
|
|
5503
|
+
var backendPkgJson = (ctx) => JSON.stringify({
|
|
5504
|
+
name: `${scope(ctx)}/backend`,
|
|
5505
|
+
version: "0.1.0",
|
|
5506
|
+
private: true,
|
|
5507
|
+
type: "module",
|
|
5508
|
+
main: "dist/index.js",
|
|
5509
|
+
scripts: {
|
|
5510
|
+
dev: "tsx watch src/index.ts",
|
|
5511
|
+
build: "tsc -p tsconfig.json",
|
|
5512
|
+
start: "node dist/index.js",
|
|
5513
|
+
typecheck: "tsc -p tsconfig.json --noEmit"
|
|
5514
|
+
},
|
|
5515
|
+
dependencies: {
|
|
5516
|
+
express: "^4.21.2",
|
|
5517
|
+
cors: "^2.8.5",
|
|
5518
|
+
mongoose: "^8.9.0",
|
|
5519
|
+
ioredis: "^5.4.2",
|
|
5520
|
+
dotenv: "^16.4.7",
|
|
5521
|
+
// ✨ Backend built on zero-dep @lacspace/* packages instead of the usual grab-bag.
|
|
5522
|
+
"@lacspace/env": "^1.1.0",
|
|
5523
|
+
"@lacspace/jwt": "^1.4.0",
|
|
5524
|
+
"@lacspace/password": "^1.1.0",
|
|
5525
|
+
"@lacspace/validate": "^1.1.0",
|
|
5526
|
+
"@lacspace/id": "^1.1.0",
|
|
5527
|
+
"@lacspace/rate-limit": "^1.2.0",
|
|
5528
|
+
"@lacspace/cache": "^1.1.0",
|
|
5529
|
+
[`${scope(ctx)}/types`]: "*"
|
|
5530
|
+
},
|
|
5531
|
+
devDependencies: {
|
|
5532
|
+
typescript: "^5.7.0",
|
|
5533
|
+
tsx: "^4.19.2",
|
|
5534
|
+
"@types/node": "^22.10.0",
|
|
5535
|
+
"@types/express": "^4.17.21",
|
|
5536
|
+
"@types/cors": "^2.8.17"
|
|
5537
|
+
}
|
|
5538
|
+
}, null, 2) + "\n";
|
|
5539
|
+
var backendTsconfig = (ctx) => JSON.stringify({
|
|
5540
|
+
compilerOptions: {
|
|
5541
|
+
target: "ES2022",
|
|
5542
|
+
module: "NodeNext",
|
|
5543
|
+
moduleResolution: "NodeNext",
|
|
5544
|
+
lib: ["ES2022"],
|
|
5545
|
+
esModuleInterop: true,
|
|
5546
|
+
allowSyntheticDefaultImports: true,
|
|
5547
|
+
strict: true,
|
|
5548
|
+
skipLibCheck: true,
|
|
5549
|
+
resolveJsonModule: true,
|
|
5550
|
+
outDir: "dist",
|
|
5551
|
+
rootDir: "src",
|
|
5552
|
+
sourceMap: true,
|
|
5553
|
+
types: ["node"],
|
|
5554
|
+
baseUrl: ".",
|
|
5555
|
+
paths: { [`${scope(ctx)}/types`]: ["../types/index.d.ts"] }
|
|
5556
|
+
},
|
|
5557
|
+
include: ["src/**/*"],
|
|
5558
|
+
exclude: ["node_modules", "dist"]
|
|
5559
|
+
}, null, 2) + "\n";
|
|
5560
|
+
var backendGitignore = () => `node_modules
|
|
5561
|
+
dist
|
|
5562
|
+
.env
|
|
5563
|
+
*.log
|
|
5564
|
+
`;
|
|
5565
|
+
var backendReadme = (ctx) => `# ${ctx.name} \u2014 API
|
|
5566
|
+
|
|
5567
|
+
Node \xB7 Express \xB7 MongoDB \xB7 Redis (optional) \xB7 TypeScript. Built on zero-dependency \`@lacspace/*\` packages.
|
|
5568
|
+
|
|
5569
|
+
Run it from the repo root with \`npm run dev\` (starts this API + the frontend). Standalone: \`npm run dev\` inside this folder.
|
|
5570
|
+
|
|
5571
|
+
## Endpoints
|
|
5572
|
+
|
|
5573
|
+
| Method | Path | Auth | Description |
|
|
5574
|
+
|--------|------------------|------|--------------------------------|
|
|
5575
|
+
| GET | \`/health\` | \u2014 | Liveness check |
|
|
5576
|
+
| POST | \`/auth/register\` | \u2014 | Create an account \u2192 JWT + user |
|
|
5577
|
+
| POST | \`/auth/login\` | \u2014 | Log in \u2192 JWT + user |
|
|
5578
|
+
| GET | \`/auth/me\` | \u2713 | The current user |
|
|
5579
|
+
| GET | \`/notes\` | \u2713 | List your notes (cached) |
|
|
5580
|
+
| POST | \`/notes\` | \u2713 | Create a note |
|
|
5581
|
+
| GET | \`/notes/:id\` | \u2713 | Read one note |
|
|
5582
|
+
| PATCH | \`/notes/:id\` | \u2713 | Update a note |
|
|
5583
|
+
| DELETE | \`/notes/:id\` | \u2713 | Delete a note |
|
|
5584
|
+
|
|
5585
|
+
Send \`Authorization: Bearer <token>\` for the \u2713 routes (you get the token from register/login).
|
|
5586
|
+
|
|
5587
|
+
## Layout
|
|
5588
|
+
|
|
5589
|
+
\`\`\`
|
|
5590
|
+
src/
|
|
5591
|
+
\u251C\u2500 index.ts boot: load env \u2192 connect Mongo \u2192 listen
|
|
5592
|
+
\u251C\u2500 load-env.ts loads the root .env (imported first)
|
|
5593
|
+
\u251C\u2500 env.ts typed, validated env (@lacspace/env)
|
|
5594
|
+
\u251C\u2500 app.ts the Express app (CORS, routes, error handler)
|
|
5595
|
+
\u251C\u2500 db.ts Mongoose connection
|
|
5596
|
+
\u251C\u2500 cache.ts Redis, or an in-memory fallback (@lacspace/cache)
|
|
5597
|
+
\u251C\u2500 http.ts HttpError + asyncHandler
|
|
5598
|
+
\u251C\u2500 validation.ts request schemas (@lacspace/validate)
|
|
5599
|
+
\u251C\u2500 middleware/ auth (@lacspace/jwt) + error handling
|
|
5600
|
+
\u251C\u2500 models/ Mongoose models (User, Note)
|
|
5601
|
+
\u2514\u2500 routes/ auth + notes (the example CRUD resource)
|
|
5602
|
+
\`\`\`
|
|
5603
|
+
`;
|
|
5604
|
+
var backendLoadEnv = () => `import { config } from "dotenv";
|
|
5605
|
+
import { resolve } from "node:path";
|
|
5606
|
+
|
|
5607
|
+
// how this works: the whole monorepo shares ONE .env at the repo root. When this
|
|
5608
|
+
// API runs via \`npm run dev\` its working directory is backend/, so the root .env
|
|
5609
|
+
// is one level up. This module is imported FIRST (before env.ts) so the variables
|
|
5610
|
+
// exist by the time anything reads them.
|
|
5611
|
+
config({ path: resolve(process.cwd(), "../.env") });
|
|
5612
|
+
config(); // also load backend/.env if you keep one (root values already set win)
|
|
5613
|
+
`;
|
|
5614
|
+
var backendEnv = (ctx) => `import { createEnv, str, port, oneOf } from "@lacspace/env";
|
|
5615
|
+
|
|
5616
|
+
// how this works: fail-fast, TYPED environment variables (@lacspace/env). Bad or
|
|
5617
|
+
// missing values throw at boot with a clear message. Sensible dev defaults mean it
|
|
5618
|
+
// runs with zero config against a local MongoDB; set real values in the root .env.
|
|
5619
|
+
export const env = createEnv({
|
|
5620
|
+
NODE_ENV: oneOf(["development", "production", "test"], { default: "development" }),
|
|
5621
|
+
PORT: port({ default: 4000 }),
|
|
5622
|
+
MONGODB_URI: str({ default: "mongodb://localhost:27017/${ctx.name}" }),
|
|
5623
|
+
JWT_SECRET: str({ default: "dev-secret-change-me" }),
|
|
5624
|
+
REDIS_URL: str({ optional: true }),
|
|
5625
|
+
CORS_ORIGIN: str({ default: "http://localhost:3000" }),
|
|
5626
|
+
});
|
|
5627
|
+
|
|
5628
|
+
if (env.NODE_ENV === "production" && env.JWT_SECRET === "dev-secret-change-me") {
|
|
5629
|
+
throw new Error("Set a strong JWT_SECRET in production (see .env.example).");
|
|
5630
|
+
}
|
|
5631
|
+
`;
|
|
5632
|
+
var backendIndex = (ctx) => `import "./load-env.js"; // MUST be first: fills process.env before anything reads it
|
|
5633
|
+
import { env } from "./env.js";
|
|
5634
|
+
import { connectDb } from "./db.js";
|
|
5635
|
+
import { createApp } from "./app.js";
|
|
5636
|
+
|
|
5637
|
+
// how this works: connect to MongoDB, build the Express app, then listen.
|
|
5638
|
+
async function main(): Promise<void> {
|
|
5639
|
+
await connectDb(env.MONGODB_URI);
|
|
5640
|
+
const app = createApp();
|
|
5641
|
+
app.listen(env.PORT, () => {
|
|
5642
|
+
console.log(\`\u{1F680} ${ctx.name} API ready on http://localhost:\${env.PORT}\`);
|
|
5643
|
+
});
|
|
5644
|
+
}
|
|
5645
|
+
|
|
5646
|
+
main().catch((err) => {
|
|
5647
|
+
console.error("Failed to start the API:", err);
|
|
5648
|
+
process.exit(1);
|
|
5649
|
+
});
|
|
5650
|
+
`;
|
|
5651
|
+
var backendDb = () => `import mongoose from "mongoose";
|
|
5652
|
+
|
|
5653
|
+
// how this works: opens the Mongoose connection. Call once at boot.
|
|
5654
|
+
export async function connectDb(uri: string): Promise<void> {
|
|
5655
|
+
mongoose.set("strictQuery", true);
|
|
5656
|
+
await mongoose.connect(uri);
|
|
5657
|
+
console.log("\u2714 MongoDB connected");
|
|
5658
|
+
}
|
|
5659
|
+
`;
|
|
5660
|
+
var backendCache = () => `import { createCache } from "@lacspace/cache";
|
|
5661
|
+
import { Redis } from "ioredis";
|
|
5662
|
+
import { env } from "./env.js";
|
|
5663
|
+
|
|
5664
|
+
// how this works: one cache interface, TWO backends. If REDIS_URL is set we use
|
|
5665
|
+
// Redis; otherwise we fall back to @lacspace/cache (zero-dep, in-memory) so the app
|
|
5666
|
+
// runs with NO Redis at all. The rest of the code doesn't care which is active.
|
|
5667
|
+
export interface Cache {
|
|
5668
|
+
get(key: string): Promise<string | null>;
|
|
5669
|
+
set(key: string, value: string, ttlSeconds?: number): Promise<void>;
|
|
5670
|
+
del(key: string): Promise<void>;
|
|
5671
|
+
readonly kind: "redis" | "memory";
|
|
5672
|
+
}
|
|
5673
|
+
|
|
5674
|
+
function redisCache(url: string): Cache {
|
|
5675
|
+
const client = new Redis(url, { maxRetriesPerRequest: 2 });
|
|
5676
|
+
client.on("error", (e: Error) => console.warn("\u26A0 Redis error:", e.message));
|
|
5677
|
+
return {
|
|
5678
|
+
kind: "redis",
|
|
5679
|
+
async get(key) { return client.get(key); },
|
|
5680
|
+
async set(key, value, ttl) { if (ttl) await client.set(key, value, "EX", ttl); else await client.set(key, value); },
|
|
5681
|
+
async del(key) { await client.del(key); },
|
|
5682
|
+
};
|
|
5683
|
+
}
|
|
5684
|
+
|
|
5685
|
+
function memoryCache(): Cache {
|
|
5686
|
+
const mem = createCache<string>({ max: 5000 });
|
|
5687
|
+
return {
|
|
5688
|
+
kind: "memory",
|
|
5689
|
+
async get(key) { return mem.get(key) ?? null; },
|
|
5690
|
+
async set(key, value, ttl) { mem.set(key, value, ttl ? ttl * 1000 : undefined); },
|
|
5691
|
+
async del(key) { mem.delete(key); },
|
|
5692
|
+
};
|
|
5693
|
+
}
|
|
5694
|
+
|
|
5695
|
+
export const cache: Cache = env.REDIS_URL ? redisCache(env.REDIS_URL) : memoryCache();
|
|
5696
|
+
console.log(\`\u2714 Cache: \${cache.kind}\${cache.kind === "memory" ? " (no REDIS_URL \u2014 in-memory fallback)" : ""}\`);
|
|
5697
|
+
`;
|
|
5698
|
+
var backendHttp = () => `import type { Request, Response, NextFunction, RequestHandler } from "express";
|
|
5699
|
+
|
|
5700
|
+
/** A simple HTTP error with a status code \u2014 throw it from any handler. */
|
|
5701
|
+
export class HttpError extends Error {
|
|
5702
|
+
status: number;
|
|
5703
|
+
constructor(status: number, message: string) {
|
|
5704
|
+
super(message);
|
|
5705
|
+
this.status = status;
|
|
5706
|
+
this.name = "HttpError";
|
|
5707
|
+
}
|
|
5708
|
+
}
|
|
5709
|
+
|
|
5710
|
+
// how this works: Express v4 doesn't catch rejected Promises from async handlers,
|
|
5711
|
+
// so wrap them \u2014 any thrown error is forwarded to the error middleware.
|
|
5712
|
+
export const asyncHandler =
|
|
5713
|
+
(fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>): RequestHandler =>
|
|
5714
|
+
(req, res, next) => { fn(req, res, next).catch(next); };
|
|
5715
|
+
`;
|
|
5716
|
+
var backendExpressTypes = () => `// Adds req.user (populated by requireAuth) to Express's Request type.
|
|
5717
|
+
import "express";
|
|
5718
|
+
|
|
5719
|
+
declare global {
|
|
5720
|
+
namespace Express {
|
|
5721
|
+
interface Request {
|
|
5722
|
+
user?: { sub: string; email: string; iat?: number; exp?: number; iss?: string };
|
|
5723
|
+
}
|
|
5724
|
+
}
|
|
5725
|
+
}
|
|
5726
|
+
|
|
5727
|
+
export {};
|
|
5728
|
+
`;
|
|
5729
|
+
var backendErrorMw = () => `import type { Request, Response, NextFunction } from "express";
|
|
5730
|
+
import { ValidationError } from "@lacspace/validate";
|
|
5731
|
+
import { HttpError } from "../http.js";
|
|
5732
|
+
|
|
5733
|
+
// how this works: ONE place that turns thrown errors into clean JSON responses.
|
|
5734
|
+
// Register it LAST (after all routes). Express identifies it by its 4 arguments.
|
|
5735
|
+
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction): void {
|
|
5736
|
+
if (err instanceof ValidationError) {
|
|
5737
|
+
res.status(400).json({ error: "Validation failed", fields: err.flatten() });
|
|
5738
|
+
return;
|
|
5739
|
+
}
|
|
5740
|
+
if (err instanceof HttpError) {
|
|
5741
|
+
res.status(err.status).json({ error: err.message });
|
|
5742
|
+
return;
|
|
5743
|
+
}
|
|
5744
|
+
console.error(err);
|
|
5745
|
+
res.status(500).json({ error: "Internal server error" });
|
|
5746
|
+
}
|
|
5747
|
+
`;
|
|
5748
|
+
var backendAuthMw = (ctx) => `import type { RequestHandler } from "express";
|
|
5749
|
+
import { expressJwt } from "@lacspace/jwt";
|
|
5750
|
+
import { env } from "../env.js";
|
|
5751
|
+
|
|
5752
|
+
// how this works: verifies the "Authorization: Bearer <token>" header with
|
|
5753
|
+
// @lacspace/jwt and attaches the decoded payload to req.user (see express.d.ts).
|
|
5754
|
+
// Missing/invalid token \u2192 it responds 401 automatically. Put it on any route you
|
|
5755
|
+
// want protected: router.get("/secret", requireAuth, handler)
|
|
5756
|
+
// (@lacspace/jwt's middleware is framework-agnostic, so we cast it to Express's
|
|
5757
|
+
// RequestHandler \u2014 it's a standard (req, res, next) function underneath.)
|
|
5758
|
+
export const requireAuth = expressJwt(env.JWT_SECRET, { issuer: "${ctx.name}-api" }) as unknown as RequestHandler;
|
|
5759
|
+
`;
|
|
5760
|
+
var backendUserModel = () => `import mongoose from "mongoose";
|
|
5761
|
+
import { uuidv7 } from "@lacspace/id";
|
|
5762
|
+
|
|
5763
|
+
// how this works: the User fields. _id is a time-sortable UUID (@lacspace/id)
|
|
5764
|
+
// instead of an ObjectId, so ids are readable and orderable. We store only a
|
|
5765
|
+
// password HASH, never the raw password. (We DON'T extend mongoose.Document \u2014 that
|
|
5766
|
+
// would force _id to be an ObjectId; a plain interface lets _id be our string.)
|
|
5767
|
+
export interface UserDoc {
|
|
5768
|
+
_id: string;
|
|
5769
|
+
email: string;
|
|
5770
|
+
name: string;
|
|
5771
|
+
passwordHash: string;
|
|
5772
|
+
createdAt: Date;
|
|
5773
|
+
updatedAt: Date;
|
|
5774
|
+
}
|
|
5775
|
+
|
|
5776
|
+
const userSchema = new mongoose.Schema<UserDoc>(
|
|
5777
|
+
{
|
|
5778
|
+
_id: { type: String, default: () => uuidv7() },
|
|
5779
|
+
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
|
|
5780
|
+
name: { type: String, required: true, trim: true },
|
|
5781
|
+
passwordHash: { type: String, required: true },
|
|
5782
|
+
},
|
|
5783
|
+
{ timestamps: true },
|
|
5784
|
+
);
|
|
5785
|
+
|
|
5786
|
+
export const User =
|
|
5787
|
+
(mongoose.models.User as mongoose.Model<UserDoc>) ?? mongoose.model<UserDoc>("User", userSchema);
|
|
5788
|
+
`;
|
|
5789
|
+
var backendNoteModel = () => `import mongoose from "mongoose";
|
|
5790
|
+
import { uuidv7 } from "@lacspace/id";
|
|
5791
|
+
|
|
5792
|
+
// how this works: the example CRUD resource. Rename "Note" to your real domain
|
|
5793
|
+
// object (Task, Post, Product\u2026) and add fields \u2014 the routes follow the same shape.
|
|
5794
|
+
// (Plain interface, not mongoose.Document, so _id can be our string UUID.)
|
|
5795
|
+
export interface NoteDoc {
|
|
5796
|
+
_id: string;
|
|
5797
|
+
title: string;
|
|
5798
|
+
body: string;
|
|
5799
|
+
userId: string;
|
|
5800
|
+
createdAt: Date;
|
|
5801
|
+
updatedAt: Date;
|
|
5802
|
+
}
|
|
5803
|
+
|
|
5804
|
+
const noteSchema = new mongoose.Schema<NoteDoc>(
|
|
5805
|
+
{
|
|
5806
|
+
_id: { type: String, default: () => uuidv7() },
|
|
5807
|
+
title: { type: String, required: true, trim: true },
|
|
5808
|
+
body: { type: String, default: "" },
|
|
5809
|
+
userId: { type: String, required: true, index: true },
|
|
5810
|
+
},
|
|
5811
|
+
{ timestamps: true },
|
|
5812
|
+
);
|
|
5813
|
+
|
|
5814
|
+
export const Note =
|
|
5815
|
+
(mongoose.models.Note as mongoose.Model<NoteDoc>) ?? mongoose.model<NoteDoc>("Note", noteSchema);
|
|
5816
|
+
`;
|
|
5817
|
+
var backendValidation = () => `import { v, type Infer } from "@lacspace/validate";
|
|
5818
|
+
|
|
5819
|
+
// how this works: Zod-like schemas (@lacspace/validate). .parse(body) throws a
|
|
5820
|
+
// ValidationError on bad input, which the error middleware turns into a clean 400
|
|
5821
|
+
// with per-field messages.
|
|
5822
|
+
export const RegisterInput = v.object({
|
|
5823
|
+
name: v.string().min(2).max(80),
|
|
5824
|
+
email: v.string().email(),
|
|
5825
|
+
password: v.string().min(8).max(200),
|
|
5826
|
+
});
|
|
5827
|
+
|
|
5828
|
+
export const LoginInput = v.object({
|
|
5829
|
+
email: v.string().email(),
|
|
5830
|
+
password: v.string().min(1),
|
|
5831
|
+
});
|
|
5832
|
+
|
|
5833
|
+
export const NoteInput = v.object({
|
|
5834
|
+
title: v.string().min(1).max(200),
|
|
5835
|
+
body: v.string().max(10_000).default(""),
|
|
5836
|
+
});
|
|
5837
|
+
|
|
5838
|
+
export type RegisterBody = Infer<typeof RegisterInput>;
|
|
5839
|
+
export type LoginBody = Infer<typeof LoginInput>;
|
|
5840
|
+
export type NoteBody = Infer<typeof NoteInput>;
|
|
5841
|
+
`;
|
|
5842
|
+
var backendAuthRoutes = (ctx) => `import express from "express";
|
|
5843
|
+
import { hash, verify as verifyPassword } from "@lacspace/password";
|
|
5844
|
+
import { sign } from "@lacspace/jwt";
|
|
5845
|
+
import { asyncHandler, HttpError } from "../http.js";
|
|
5846
|
+
import { requireAuth } from "../middleware/auth.js";
|
|
5847
|
+
import { User, type UserDoc } from "../models/user.js";
|
|
5848
|
+
import { env } from "../env.js";
|
|
5849
|
+
import { RegisterInput, LoginInput } from "../validation.js";
|
|
5850
|
+
import type { AuthResponse, User as UserDTO } from "${scope(ctx)}/types";
|
|
5851
|
+
|
|
5852
|
+
const ISSUER = "${ctx.name}-api";
|
|
5853
|
+
const WEEK = 60 * 60 * 24 * 7; // token lifetime, in seconds
|
|
5854
|
+
const router = express.Router();
|
|
5855
|
+
|
|
5856
|
+
function toDTO(u: UserDoc): UserDTO {
|
|
5857
|
+
return { id: String(u._id), email: u.email, name: u.name, createdAt: u.createdAt.toISOString() };
|
|
5858
|
+
}
|
|
5859
|
+
function tokenFor(u: UserDoc): Promise<string> {
|
|
5860
|
+
return sign({ sub: String(u._id), email: u.email }, env.JWT_SECRET, { expiresIn: WEEK, issuer: ISSUER });
|
|
5861
|
+
}
|
|
5862
|
+
|
|
5863
|
+
// POST /auth/register \u2014 create an account, return a JWT + the user.
|
|
5864
|
+
router.post("/register", asyncHandler(async (req, res) => {
|
|
5865
|
+
const { name, email, password } = RegisterInput.parse(req.body);
|
|
5866
|
+
if (await User.findOne({ email })) throw new HttpError(409, "That email is already registered");
|
|
5867
|
+
const passwordHash = await hash(password); // PBKDF2 via @lacspace/password
|
|
5868
|
+
const user = await User.create({ name, email, passwordHash });
|
|
5869
|
+
const out: AuthResponse = { token: await tokenFor(user), user: toDTO(user) };
|
|
5870
|
+
res.status(201).json(out);
|
|
5871
|
+
}));
|
|
5872
|
+
|
|
5873
|
+
// POST /auth/login \u2014 verify credentials, return a JWT + the user.
|
|
5874
|
+
router.post("/login", asyncHandler(async (req, res) => {
|
|
5875
|
+
const { email, password } = LoginInput.parse(req.body);
|
|
5876
|
+
const user = await User.findOne({ email });
|
|
5877
|
+
if (!user || !(await verifyPassword(password, user.passwordHash))) {
|
|
5878
|
+
throw new HttpError(401, "Invalid email or password");
|
|
5879
|
+
}
|
|
5880
|
+
const out: AuthResponse = { token: await tokenFor(user), user: toDTO(user) };
|
|
5881
|
+
res.json(out);
|
|
5882
|
+
}));
|
|
5883
|
+
|
|
5884
|
+
// GET /auth/me \u2014 the current user (requires a valid token).
|
|
5885
|
+
router.get("/me", requireAuth, asyncHandler(async (req, res) => {
|
|
5886
|
+
const user = await User.findById(req.user!.sub);
|
|
5887
|
+
if (!user) throw new HttpError(404, "User not found");
|
|
5888
|
+
res.json(toDTO(user));
|
|
5889
|
+
}));
|
|
5890
|
+
|
|
5891
|
+
export default router;
|
|
5892
|
+
`;
|
|
5893
|
+
var backendNoteRoutes = (ctx) => `import express from "express";
|
|
5894
|
+
import { asyncHandler, HttpError } from "../http.js";
|
|
5895
|
+
import { requireAuth } from "../middleware/auth.js";
|
|
5896
|
+
import { Note, type NoteDoc } from "../models/note.js";
|
|
5897
|
+
import { cache } from "../cache.js";
|
|
5898
|
+
import { NoteInput } from "../validation.js";
|
|
5899
|
+
import type { Note as NoteDTO } from "${scope(ctx)}/types";
|
|
5900
|
+
|
|
5901
|
+
const router = express.Router();
|
|
5902
|
+
router.use(requireAuth); // every /notes route requires a valid token
|
|
5903
|
+
|
|
5904
|
+
function toDTO(n: NoteDoc): NoteDTO {
|
|
5905
|
+
return {
|
|
5906
|
+
id: String(n._id),
|
|
5907
|
+
title: n.title,
|
|
5908
|
+
body: n.body,
|
|
5909
|
+
userId: String(n.userId),
|
|
5910
|
+
createdAt: n.createdAt.toISOString(),
|
|
5911
|
+
updatedAt: n.updatedAt.toISOString(),
|
|
5912
|
+
};
|
|
5913
|
+
}
|
|
5914
|
+
const listKey = (userId: string): string => \`notes:\${userId}\`;
|
|
5915
|
+
|
|
5916
|
+
// GET /notes \u2014 this user's notes. Cached for 30s (Redis or in-memory); every write
|
|
5917
|
+
// below busts the cache, so reads are fast but never stale after a change.
|
|
5918
|
+
router.get("/", asyncHandler(async (req, res) => {
|
|
5919
|
+
const userId = req.user!.sub;
|
|
5920
|
+
const cached = await cache.get(listKey(userId));
|
|
5921
|
+
if (cached) { res.json(JSON.parse(cached) as NoteDTO[]); return; }
|
|
5922
|
+
const notes = await Note.find({ userId }).sort({ createdAt: -1 });
|
|
5923
|
+
const dto = notes.map(toDTO);
|
|
5924
|
+
await cache.set(listKey(userId), JSON.stringify(dto), 30);
|
|
5925
|
+
res.json(dto);
|
|
5926
|
+
}));
|
|
5927
|
+
|
|
5928
|
+
// POST /notes \u2014 create a note.
|
|
5929
|
+
router.post("/", asyncHandler(async (req, res) => {
|
|
5930
|
+
const userId = req.user!.sub;
|
|
5931
|
+
const { title, body } = NoteInput.parse(req.body);
|
|
5932
|
+
const note = await Note.create({ title, body, userId });
|
|
5933
|
+
await cache.del(listKey(userId));
|
|
5934
|
+
res.status(201).json(toDTO(note));
|
|
5935
|
+
}));
|
|
5936
|
+
|
|
5937
|
+
// GET /notes/:id \u2014 one note (only if it's yours).
|
|
5938
|
+
router.get("/:id", asyncHandler(async (req, res) => {
|
|
5939
|
+
const note = await Note.findOne({ _id: req.params.id, userId: req.user!.sub });
|
|
5940
|
+
if (!note) throw new HttpError(404, "Note not found");
|
|
5941
|
+
res.json(toDTO(note));
|
|
5942
|
+
}));
|
|
5943
|
+
|
|
5944
|
+
// PATCH /notes/:id \u2014 update a note.
|
|
5945
|
+
router.patch("/:id", asyncHandler(async (req, res) => {
|
|
5946
|
+
const patch = NoteInput.partial().parse(req.body);
|
|
5947
|
+
const note = await Note.findOneAndUpdate({ _id: req.params.id, userId: req.user!.sub }, { $set: patch }, { new: true });
|
|
5948
|
+
if (!note) throw new HttpError(404, "Note not found");
|
|
5949
|
+
await cache.del(listKey(req.user!.sub));
|
|
5950
|
+
res.json(toDTO(note));
|
|
5951
|
+
}));
|
|
5952
|
+
|
|
5953
|
+
// DELETE /notes/:id \u2014 delete a note.
|
|
5954
|
+
router.delete("/:id", asyncHandler(async (req, res) => {
|
|
5955
|
+
const note = await Note.findOneAndDelete({ _id: req.params.id, userId: req.user!.sub });
|
|
5956
|
+
if (!note) throw new HttpError(404, "Note not found");
|
|
5957
|
+
await cache.del(listKey(req.user!.sub));
|
|
5958
|
+
res.status(204).end();
|
|
5959
|
+
}));
|
|
5960
|
+
|
|
5961
|
+
export default router;
|
|
5962
|
+
`;
|
|
5963
|
+
var backendApp = (ctx) => `import express from "express";
|
|
5964
|
+
import type { RequestHandler } from "express";
|
|
5965
|
+
import cors from "cors";
|
|
5966
|
+
import { rateLimit, expressRateLimit } from "@lacspace/rate-limit";
|
|
5967
|
+
import { env } from "./env.js";
|
|
5968
|
+
import { errorHandler } from "./middleware/error.js";
|
|
5969
|
+
import authRoutes from "./routes/auth.js";
|
|
5970
|
+
import noteRoutes from "./routes/notes.js";
|
|
5971
|
+
|
|
5972
|
+
// how this works: assembles the Express app \u2014 CORS for the frontend, JSON parsing,
|
|
5973
|
+
// a health check, the auth + notes routers, then the error handler LAST.
|
|
5974
|
+
export function createApp(): express.Express {
|
|
5975
|
+
const app = express();
|
|
5976
|
+
|
|
5977
|
+
app.use(cors({ origin: env.CORS_ORIGIN.split(",").map((o) => o.trim()), credentials: true }));
|
|
5978
|
+
app.use(express.json());
|
|
5979
|
+
|
|
5980
|
+
// Brute-force protection on auth: 20 requests / minute / IP (@lacspace/rate-limit).
|
|
5981
|
+
const authLimiter = expressRateLimit(rateLimit({ limit: 20, windowMs: 60_000 })) as unknown as RequestHandler;
|
|
5982
|
+
|
|
5983
|
+
app.get("/health", (_req, res) => { res.json({ ok: true, service: "${ctx.name}-api" }); });
|
|
5984
|
+
app.use("/auth", authLimiter, authRoutes);
|
|
5985
|
+
app.use("/notes", noteRoutes);
|
|
5986
|
+
|
|
5987
|
+
app.use(errorHandler);
|
|
5988
|
+
return app;
|
|
5989
|
+
}
|
|
5990
|
+
`;
|
|
5991
|
+
function backendFiles(ctx) {
|
|
5992
|
+
return {
|
|
5993
|
+
"backend/package.json": backendPkgJson(ctx),
|
|
5994
|
+
"backend/tsconfig.json": backendTsconfig(ctx),
|
|
5995
|
+
"backend/.gitignore": backendGitignore(),
|
|
5996
|
+
"backend/README.md": backendReadme(ctx),
|
|
5997
|
+
"backend/src/index.ts": backendIndex(ctx),
|
|
5998
|
+
"backend/src/load-env.ts": backendLoadEnv(),
|
|
5999
|
+
"backend/src/env.ts": backendEnv(ctx),
|
|
6000
|
+
"backend/src/db.ts": backendDb(),
|
|
6001
|
+
"backend/src/cache.ts": backendCache(),
|
|
6002
|
+
"backend/src/app.ts": backendApp(ctx),
|
|
6003
|
+
"backend/src/http.ts": backendHttp(),
|
|
6004
|
+
"backend/src/express.d.ts": backendExpressTypes(),
|
|
6005
|
+
"backend/src/validation.ts": backendValidation(),
|
|
6006
|
+
"backend/src/middleware/auth.ts": backendAuthMw(ctx),
|
|
6007
|
+
"backend/src/middleware/error.ts": backendErrorMw(),
|
|
6008
|
+
"backend/src/models/user.ts": backendUserModel(),
|
|
6009
|
+
"backend/src/models/note.ts": backendNoteModel(),
|
|
6010
|
+
"backend/src/routes/auth.ts": backendAuthRoutes(ctx),
|
|
6011
|
+
"backend/src/routes/notes.ts": backendNoteRoutes(ctx)
|
|
6012
|
+
};
|
|
6013
|
+
}
|
|
6014
|
+
var splitList = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
|
|
4559
6015
|
function parseArgs(list) {
|
|
4560
|
-
const a = { yes: false, install: true, git: true, pm: "npm", help: false };
|
|
6016
|
+
const a = { features: [], yes: false, install: true, git: true, pm: "npm", help: false };
|
|
4561
6017
|
for (let i = 0; i < list.length; i++) {
|
|
4562
6018
|
const arg = list[i];
|
|
4563
6019
|
const next = () => list[++i] ?? "";
|
|
@@ -4567,10 +6023,21 @@ function parseArgs(list) {
|
|
|
4567
6023
|
else if (arg === "--no-git") a.git = false;
|
|
4568
6024
|
else if (arg === "--pm") a.pm = next();
|
|
4569
6025
|
else if (arg === "--theme" || arg === "--accent") a.theme = next();
|
|
4570
|
-
else if (arg === "
|
|
6026
|
+
else if (arg === "--with" || arg === "--features") a.features.push(...splitList(next()));
|
|
6027
|
+
else if (arg === "--fullstack" || arg === "--full-stack" || arg === "--dynamic") a.mode = "dynamic";
|
|
6028
|
+
else if (arg === "--static") a.mode = "static";
|
|
6029
|
+
else if (arg === "--mode") {
|
|
6030
|
+
const m = next().toLowerCase();
|
|
6031
|
+
a.mode = m === "dynamic" ? "dynamic" : "static";
|
|
6032
|
+
} else if (arg.startsWith("--mode=")) {
|
|
6033
|
+
const m = arg.slice(7).toLowerCase();
|
|
6034
|
+
a.mode = m === "dynamic" ? "dynamic" : "static";
|
|
6035
|
+
} else if (arg === "-h" || arg === "--help") a.help = true;
|
|
4571
6036
|
else if (arg.startsWith("--template=")) a.template = arg.slice(11);
|
|
4572
6037
|
else if (arg.startsWith("--theme=")) a.theme = arg.slice(8);
|
|
4573
6038
|
else if (arg.startsWith("--accent=")) a.theme = arg.slice(9);
|
|
6039
|
+
else if (arg.startsWith("--with=")) a.features.push(...splitList(arg.slice(7)));
|
|
6040
|
+
else if (arg.startsWith("--features=")) a.features.push(...splitList(arg.slice(11)));
|
|
4574
6041
|
else if (!arg.startsWith("-") && !a.name) a.name = arg;
|
|
4575
6042
|
}
|
|
4576
6043
|
return a;
|
|
@@ -4665,6 +6132,10 @@ ${TEMPLATES.map((t) => ` ${t.key.padEnd(10)} ${t.description}`).join("\n")}
|
|
|
4665
6132
|
|
|
4666
6133
|
${c("bold", "Options")}
|
|
4667
6134
|
-t, --template <key> Template (${TEMPLATES.map((t) => t.key).join(" | ")})
|
|
6135
|
+
--fullstack Full-stack monorepo: Next.js frontend + Node/Express/
|
|
6136
|
+
MongoDB/Redis backend (JWT auth + CRUD) + shared types
|
|
6137
|
+
${c("dim", "(alias --dynamic; default is --static, a single Next.js app)")}
|
|
6138
|
+
--with <a,b> Feature add-ons, comma-separated (alias --features)
|
|
4668
6139
|
--theme <name|hex> Accent: a preset, a "#hex", or "from,to" (e.g. --theme lacspace)
|
|
4669
6140
|
--pm <npm|pnpm|yarn|bun> Package manager (default npm)
|
|
4670
6141
|
--no-install Skip installing dependencies
|
|
@@ -4672,13 +6143,18 @@ ${c("bold", "Options")}
|
|
|
4672
6143
|
-y, --yes Accept defaults (needs <name>)
|
|
4673
6144
|
-h, --help Show this help
|
|
4674
6145
|
|
|
6146
|
+
${c("bold", "Feature add-ons")} ${c("dim", "(--with) \u2014 free & keyless, local by default")}
|
|
6147
|
+
${FEATURES.map((f) => ` ${f.key.padEnd(10)} ${f.description}`).join("\n")}
|
|
6148
|
+
${c("dim", "e.g. npx create-lacspace-app my-app --template saas --with ai-chat,rag")}
|
|
6149
|
+
|
|
4675
6150
|
${c("bold", "Themes")} ${c("dim", "(--theme)")}
|
|
4676
6151
|
${Object.keys(THEMES).join(" \xB7 ")}
|
|
4677
6152
|
${c("dim", 'or a custom colour: --theme "#ff6a00" \xB7 --theme "#0bb9d9,#7c3aed"')}
|
|
4678
6153
|
|
|
4679
|
-
${c("bold", "
|
|
4680
|
-
npx create-lacspace-app add pricing faq testimonials
|
|
4681
|
-
${c("dim", "
|
|
6154
|
+
${c("bold", "Grow an existing app")}
|
|
6155
|
+
npx create-lacspace-app add pricing faq testimonials ${c("dim", "# prebuilt sections")}
|
|
6156
|
+
npx create-lacspace-app add ai-chat ${c("dim", "# a feature add-on")}
|
|
6157
|
+
${c("dim", "Drops themed sections / feature files into your project (and the UI kit if missing).")}
|
|
4682
6158
|
`;
|
|
4683
6159
|
var SECTIONS = {
|
|
4684
6160
|
hero: `import Link from "next/link";
|
|
@@ -4918,7 +6394,7 @@ function ensureUiKit(root) {
|
|
|
4918
6394
|
var pascal = (s) => s.split(/[-_]/).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
|
|
4919
6395
|
function runAdd(rawNames) {
|
|
4920
6396
|
process$1.stdout.write(`
|
|
4921
|
-
${c("bold", c("magenta", "\u25C6 create-lacspace-app add"))} ${c("dim", "\u2014 drop prebuilt sections into your app")}
|
|
6397
|
+
${c("bold", c("magenta", "\u25C6 create-lacspace-app add"))} ${c("dim", "\u2014 drop prebuilt sections or a feature into your app")}
|
|
4922
6398
|
|
|
4923
6399
|
`);
|
|
4924
6400
|
const root = process$1.cwd();
|
|
@@ -4929,56 +6405,108 @@ ${c("bold", c("magenta", "\u25C6 create-lacspace-app add"))} ${c("dim", "\u2014
|
|
|
4929
6405
|
}
|
|
4930
6406
|
const wanted = rawNames.filter((n) => !n.startsWith("-")).map((n) => n.toLowerCase());
|
|
4931
6407
|
if (wanted.length === 0 || rawNames.includes("--help") || rawNames.includes("-h")) {
|
|
4932
|
-
process$1.stdout.write(`Usage: ${c("cyan", "npx create-lacspace-app add <section...>")}
|
|
6408
|
+
process$1.stdout.write(`Usage: ${c("cyan", "npx create-lacspace-app add <section|feature...>")}
|
|
4933
6409
|
|
|
4934
6410
|
${c("bold", "Available sections")}
|
|
4935
6411
|
`);
|
|
4936
6412
|
process$1.stdout.write(" " + Object.keys(SECTIONS).map((k) => c("cyan", k)).join(" ") + "\n");
|
|
4937
6413
|
process$1.stdout.write(`
|
|
4938
|
-
|
|
6414
|
+
${c("bold", "Available features")}
|
|
6415
|
+
`);
|
|
6416
|
+
process$1.stdout.write(" " + FEATURES.map((f) => c("cyan", f.key)).join(" ") + "\n");
|
|
6417
|
+
process$1.stdout.write(`
|
|
6418
|
+
Example: ${c("cyan", "npx create-lacspace-app add pricing faq")} ${c("dim", "or")} ${c("cyan", "add ai-chat")}
|
|
4939
6419
|
|
|
4940
6420
|
`);
|
|
4941
6421
|
return;
|
|
4942
6422
|
}
|
|
4943
|
-
const
|
|
6423
|
+
const featKeys = wanted.filter((n) => FEATURES.some((f) => f.key === n));
|
|
6424
|
+
const sectionNames = wanted.filter((n) => n in SECTIONS);
|
|
6425
|
+
const unknown = wanted.filter((n) => !featKeys.includes(n) && !(n in SECTIONS));
|
|
4944
6426
|
if (unknown.length) process$1.stdout.write(c("yellow", `! Unknown: ${unknown.join(", ")} ${c("dim", "(run with no args to list)")}
|
|
4945
6427
|
`));
|
|
4946
|
-
const
|
|
4947
|
-
if (
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
|
|
4952
|
-
if (kitAdded.length) process$1.stdout.write(` ${c("green", "\u2714")} Added the UI kit ${c("dim", "(" + kitAdded.length + " components)")}
|
|
6428
|
+
const addedFeatures = featKeys.map((k) => FEATURES.find((f) => f.key === k));
|
|
6429
|
+
if (addedFeatures.length) {
|
|
6430
|
+
const ctx = resolveContext({ name: path.basename(root), features: featKeys });
|
|
6431
|
+
for (const feat of addedFeatures) {
|
|
6432
|
+
process$1.stdout.write(`
|
|
6433
|
+
${c("bold", `+ ${feat.label}`)} ${c("dim", `(${feat.key})`)}
|
|
4953
6434
|
`);
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
if (fs.existsSync(full)) {
|
|
4959
|
-
process$1.stdout.write(c("yellow", ` ~ ${rel} exists \u2014 skipped
|
|
6435
|
+
for (const [rel, content] of Object.entries(feat.files(ctx))) {
|
|
6436
|
+
const full = path.join(root, rel);
|
|
6437
|
+
if (fs.existsSync(full)) {
|
|
6438
|
+
process$1.stdout.write(c("yellow", ` ~ ${rel} exists \u2014 skipped
|
|
4960
6439
|
`));
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
6440
|
+
continue;
|
|
6441
|
+
}
|
|
6442
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
6443
|
+
fs.writeFileSync(full, content);
|
|
6444
|
+
process$1.stdout.write(` ${c("green", "\u2714")} ${c("dim", "+ " + rel)}
|
|
6445
|
+
`);
|
|
6446
|
+
}
|
|
6447
|
+
process$1.stdout.write(`
|
|
6448
|
+
${c("bold", "Add these dependencies")}:
|
|
6449
|
+
${c("cyan", "npm i " + Object.entries(feat.deps).map(([n, v]) => `${n}@${v}`).join(" "))}
|
|
6450
|
+
`);
|
|
6451
|
+
if (feat.scripts && Object.keys(feat.scripts).length) {
|
|
6452
|
+
process$1.stdout.write(`
|
|
6453
|
+
${c("bold", "Add to package.json scripts")}:
|
|
6454
|
+
`);
|
|
6455
|
+
for (const [n, v] of Object.entries(feat.scripts)) process$1.stdout.write(` ${c("cyan", `"${n}": "${v}"`)}
|
|
4967
6456
|
`);
|
|
6457
|
+
}
|
|
6458
|
+
if (feat.env && Object.keys(feat.env).length) {
|
|
6459
|
+
process$1.stdout.write(`
|
|
6460
|
+
${c("bold", "Add to .env")}:
|
|
6461
|
+
`);
|
|
6462
|
+
for (const [n, comment] of Object.entries(feat.env)) process$1.stdout.write(` ${c("cyan", n + "=")} ${c("dim", "# " + comment)}
|
|
6463
|
+
`);
|
|
6464
|
+
}
|
|
6465
|
+
process$1.stdout.write(`
|
|
6466
|
+
${c("bold", "Next steps")}:
|
|
6467
|
+
`);
|
|
6468
|
+
for (const s of feat.nextSteps) process$1.stdout.write(` ${c("dim", "\u2022")} ${s}
|
|
6469
|
+
`);
|
|
6470
|
+
}
|
|
6471
|
+
process$1.stdout.write("\n");
|
|
4968
6472
|
}
|
|
4969
|
-
if (
|
|
4970
|
-
|
|
6473
|
+
if (sectionNames.length) {
|
|
6474
|
+
const kitAdded = ensureUiKit(root);
|
|
6475
|
+
if (kitAdded.length) process$1.stdout.write(` ${c("green", "\u2714")} Added the UI kit ${c("dim", "(" + kitAdded.length + " components)")}
|
|
6476
|
+
`);
|
|
6477
|
+
const created = [];
|
|
6478
|
+
for (const name of sectionNames) {
|
|
6479
|
+
const rel = `components/sections/${name}.tsx`;
|
|
6480
|
+
const full = path.join(root, rel);
|
|
6481
|
+
if (fs.existsSync(full)) {
|
|
6482
|
+
process$1.stdout.write(c("yellow", ` ~ ${rel} exists \u2014 skipped
|
|
6483
|
+
`));
|
|
6484
|
+
continue;
|
|
6485
|
+
}
|
|
6486
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
6487
|
+
fs.writeFileSync(full, SECTIONS[name]);
|
|
6488
|
+
created.push(name);
|
|
6489
|
+
process$1.stdout.write(` ${c("green", "\u2714")} ${c("dim", "+ " + rel)}
|
|
6490
|
+
`);
|
|
6491
|
+
}
|
|
6492
|
+
if (created.length) {
|
|
6493
|
+
process$1.stdout.write(`
|
|
4971
6494
|
${c("bold", "Use them")} \u2014 import into any page:
|
|
4972
6495
|
`);
|
|
4973
|
-
|
|
6496
|
+
for (const n of created) process$1.stdout.write(` ${c("cyan", `import { ${pascal(n)}Section } from "@/components/sections/${n}";`)}
|
|
4974
6497
|
`);
|
|
4975
|
-
|
|
6498
|
+
process$1.stdout.write(`
|
|
4976
6499
|
Then drop ${c("cyan", "<" + pascal(created[0]) + "Section />")} into your JSX.
|
|
4977
6500
|
`);
|
|
4978
|
-
|
|
6501
|
+
process$1.stdout.write(`
|
|
4979
6502
|
${c("dim", "Sections use the @lacspace UI kit + design tokens \u2014 best inside a create-lacspace-app project.")}
|
|
4980
6503
|
|
|
4981
6504
|
`);
|
|
6505
|
+
}
|
|
6506
|
+
}
|
|
6507
|
+
if (!addedFeatures.length && !sectionNames.length) {
|
|
6508
|
+
process$1.exit(1);
|
|
6509
|
+
return;
|
|
4982
6510
|
}
|
|
4983
6511
|
}
|
|
4984
6512
|
async function main() {
|
|
@@ -4998,7 +6526,9 @@ ${c("bold", c("magenta", "\u25C6 create-lacspace-app"))} ${c("dim", "\u2014 a go
|
|
|
4998
6526
|
`);
|
|
4999
6527
|
let name = args.name;
|
|
5000
6528
|
let templateKey = args.template;
|
|
5001
|
-
|
|
6529
|
+
let mode = args.mode ?? "static";
|
|
6530
|
+
const featureKeys = [...args.features];
|
|
6531
|
+
if (!args.yes && process$1.stdin.isTTY) {
|
|
5002
6532
|
const rl = promises.createInterface({ input: process$1.stdin, output: process$1.stdout });
|
|
5003
6533
|
try {
|
|
5004
6534
|
if (!name) name = (await rl.question(`${c("green", "?")} Project name ${c("dim", "(my-app)")}: `)).trim() || "my-app";
|
|
@@ -5013,6 +6543,34 @@ ${c("green", "?")} Template ${c("dim", "(1)")}: `)).trim() || "1";
|
|
|
5013
6543
|
const idx = /^\d+$/.test(ans) ? parseInt(ans, 10) - 1 : TEMPLATES.findIndex((t) => t.key === ans);
|
|
5014
6544
|
templateKey = TEMPLATES[idx]?.key ?? "personal";
|
|
5015
6545
|
}
|
|
6546
|
+
if (args.mode === void 0) {
|
|
6547
|
+
process$1.stdout.write(`
|
|
6548
|
+
What kind of app?
|
|
6549
|
+
`);
|
|
6550
|
+
process$1.stdout.write(` ${c("cyan", "1")}. ${c("bold", "Static / frontend only")} ${c("dim", "\u2014 a single Next.js app (SEO site, marketing, blog, docs). Fast, deploy anywhere.")}
|
|
6551
|
+
`);
|
|
6552
|
+
process$1.stdout.write(` ${c("cyan", "2")}. ${c("bold", "Dynamic / full-stack")} ${c("dim", "\u2014 frontend + a Node\xB7Express\xB7MongoDB\xB7Redis API with working auth & CRUD, wired together.")}
|
|
6553
|
+
`);
|
|
6554
|
+
const ans = (await rl.question(`
|
|
6555
|
+
${c("green", "?")} Type ${c("dim", "(1)")}: `)).trim() || "1";
|
|
6556
|
+
mode = ans === "2" || ans.toLowerCase().startsWith("dyn") || ans.toLowerCase().startsWith("full") ? "dynamic" : "static";
|
|
6557
|
+
}
|
|
6558
|
+
if (featureKeys.length === 0) {
|
|
6559
|
+
process$1.stdout.write(`
|
|
6560
|
+
Add feature add-ons? ${c("dim", "(free & keyless \u2014 local AI by default)")}
|
|
6561
|
+
`);
|
|
6562
|
+
FEATURES.forEach((f, i) => process$1.stdout.write(` ${c("cyan", String(i + 1))}. ${c("bold", f.label)} ${c("dim", "\u2014 " + f.description)}
|
|
6563
|
+
`));
|
|
6564
|
+
const ans = (await rl.question(`
|
|
6565
|
+
${c("green", "?")} Add features? ${c("dim", "(comma-separated numbers, or Enter for none)")}: `)).trim();
|
|
6566
|
+
if (ans) {
|
|
6567
|
+
for (const tok of splitList(ans)) {
|
|
6568
|
+
const idx = /^\d+$/.test(tok) ? parseInt(tok, 10) - 1 : FEATURES.findIndex((f) => f.key === tok);
|
|
6569
|
+
const feat = FEATURES[idx];
|
|
6570
|
+
if (feat && !featureKeys.includes(feat.key)) featureKeys.push(feat.key);
|
|
6571
|
+
}
|
|
6572
|
+
}
|
|
6573
|
+
}
|
|
5016
6574
|
} finally {
|
|
5017
6575
|
rl.close();
|
|
5018
6576
|
}
|
|
@@ -5037,14 +6595,18 @@ ${c("green", "?")} Template ${c("dim", "(1)")}: `)).trim() || "1";
|
|
|
5037
6595
|
process$1.exit(1);
|
|
5038
6596
|
return;
|
|
5039
6597
|
}
|
|
5040
|
-
const
|
|
6598
|
+
const features = normalizeFeatures(featureKeys);
|
|
6599
|
+
const files = buildFiles({ name: projectName, template, features, mode });
|
|
5041
6600
|
for (const [rel, content] of Object.entries(files)) {
|
|
5042
6601
|
const full = path.join(dir, rel);
|
|
5043
6602
|
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
5044
6603
|
fs.writeFileSync(full, content);
|
|
5045
6604
|
}
|
|
6605
|
+
const shape = mode === "dynamic" ? " \xB7 full-stack" : "";
|
|
5046
6606
|
process$1.stdout.write(`
|
|
5047
|
-
${c("green", "\u2714")} Created ${c("bold", name)} ${c("dim", `(${template.label})`)}
|
|
6607
|
+
${c("green", "\u2714")} Created ${c("bold", name)} ${c("dim", `(${template.label}${shape})`)}
|
|
6608
|
+
`);
|
|
6609
|
+
if (features.length) process$1.stdout.write(` ${c("green", "\u2714")} Feature add-ons: ${features.map((f) => c("cyan", f.key)).join(", ")}
|
|
5048
6610
|
`);
|
|
5049
6611
|
for (const rel of Object.keys(files)) process$1.stdout.write(` ${c("dim", "+ " + rel)}
|
|
5050
6612
|
`);
|
|
@@ -5072,11 +6634,40 @@ ${c("bold", "Done! Next steps")}
|
|
|
5072
6634
|
`);
|
|
5073
6635
|
if (!args.install) process$1.stdout.write(` ${c("cyan", `${args.pm} install`)}
|
|
5074
6636
|
`);
|
|
5075
|
-
|
|
6637
|
+
if (mode === "dynamic") {
|
|
6638
|
+
process$1.stdout.write(` ${c("cyan", "cp .env.example .env")} ${c("dim", "\u2014 then, optionally:")} ${c("cyan", "docker compose up -d")} ${c("dim", "(Mongo + Redis)")}
|
|
5076
6639
|
`);
|
|
5077
|
-
|
|
6640
|
+
process$1.stdout.write(` ${c("cyan", run)} ${c("dim", "\u2014 runs the API (:4000) and the frontend (:3000) together")}
|
|
6641
|
+
`);
|
|
6642
|
+
process$1.stdout.write(`
|
|
6643
|
+
${c("dim", "backend \u2192")} ${c("cyan", "backend/src/")} ${c("dim", "(Express \xB7 MongoDB \xB7 Redis \xB7 JWT auth \xB7 example CRUD)")}
|
|
6644
|
+
`);
|
|
6645
|
+
process$1.stdout.write(` ${c("dim", "frontend \u2192")} ${c("cyan", "frontend/app/")} ${c("dim", "\xB7 shared API types \u2192")} ${c("cyan", "types/src/")}
|
|
6646
|
+
`);
|
|
6647
|
+
process$1.stdout.write(` ${c("dim", "No Docker? Point")} ${c("cyan", "MONGODB_URI")} ${c("dim", "at any MongoDB (e.g. free Atlas); Redis is optional.")}
|
|
6648
|
+
`);
|
|
6649
|
+
} else {
|
|
6650
|
+
process$1.stdout.write(` ${c("cyan", run)}
|
|
6651
|
+
`);
|
|
6652
|
+
process$1.stdout.write(`
|
|
5078
6653
|
Edit ${c("cyan", "lib/site.ts")} (your SEO config) and ${c("cyan", "app/page.tsx")}.
|
|
5079
6654
|
`);
|
|
6655
|
+
}
|
|
6656
|
+
if (features.length) {
|
|
6657
|
+
const hasAi = features.some((f) => f.key === "ai-chat" || f.key === "rag");
|
|
6658
|
+
process$1.stdout.write(`
|
|
6659
|
+
${c("bold", "Your feature add-ons")} ${c("dim", "\u2014 details in LEARN.md")}
|
|
6660
|
+
`);
|
|
6661
|
+
if (hasAi) process$1.stdout.write(` ${c("green", "\u{1F193} Free & local AI")} ${c("dim", "\u2014 install Ollama (https://ollama.com); no API key required.")}
|
|
6662
|
+
`);
|
|
6663
|
+
for (const f of features) {
|
|
6664
|
+
process$1.stdout.write(`
|
|
6665
|
+
${c("bold", f.label)} ${c("dim", `(${f.key})`)}
|
|
6666
|
+
`);
|
|
6667
|
+
for (const s of f.nextSteps) process$1.stdout.write(` ${c("dim", "\u2022")} ${s}
|
|
6668
|
+
`);
|
|
6669
|
+
}
|
|
6670
|
+
}
|
|
5080
6671
|
process$1.stdout.write(`
|
|
5081
6672
|
${c("green", "Happy building!")} ${c("dim", "https://lacspace.com/packages")}
|
|
5082
6673
|
|
|
@@ -5109,6 +6700,13 @@ function listSections() {
|
|
|
5109
6700
|
function getSection(name) {
|
|
5110
6701
|
return SECTIONS[name];
|
|
5111
6702
|
}
|
|
6703
|
+
function listFeatures() {
|
|
6704
|
+
return FEATURES.map((f) => ({ ...f }));
|
|
6705
|
+
}
|
|
6706
|
+
function getFeature(key) {
|
|
6707
|
+
const f = FEATURES.find((x) => x.key === key);
|
|
6708
|
+
return f ? { ...f } : void 0;
|
|
6709
|
+
}
|
|
5112
6710
|
function generateProject(options = {}) {
|
|
5113
6711
|
return buildFiles(resolveContext(options));
|
|
5114
6712
|
}
|
|
@@ -5125,8 +6723,10 @@ async function scaffold(options = {}) {
|
|
|
5125
6723
|
}
|
|
5126
6724
|
|
|
5127
6725
|
exports.generateProject = generateProject;
|
|
6726
|
+
exports.getFeature = getFeature;
|
|
5128
6727
|
exports.getSection = getSection;
|
|
5129
6728
|
exports.getTemplate = getTemplate;
|
|
6729
|
+
exports.listFeatures = listFeatures;
|
|
5130
6730
|
exports.listSections = listSections;
|
|
5131
6731
|
exports.listTemplates = listTemplates;
|
|
5132
6732
|
exports.scaffold = scaffold;
|