create-leo 0.1.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/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/dist/index.js +316 -0
- package/package.json +30 -0
- package/templates/_shared/README.md +11 -0
- package/templates/_shared/_gitignore +8 -0
- package/templates/express-mongodb/.env.example +2 -0
- package/templates/express-mongodb/README.md +13 -0
- package/templates/express-mongodb/package.json +18 -0
- package/templates/express-mongodb/src/app.js +11 -0
- package/templates/express-mongodb/src/config/db.js +9 -0
- package/templates/express-mongodb/src/middleware/error.js +4 -0
- package/templates/express-mongodb/src/models/example.js +2 -0
- package/templates/express-mongodb/src/routes/examples.js +6 -0
- package/templates/express-mongodb/src/routes/health.js +5 -0
- package/templates/express-mongodb/src/server.js +5 -0
- package/templates/express-postgres/.env.example +2 -0
- package/templates/express-postgres/README.md +6 -0
- package/templates/express-postgres/package.json +18 -0
- package/templates/express-postgres/src/app.js +7 -0
- package/templates/express-postgres/src/config/db.js +4 -0
- package/templates/express-postgres/src/middleware/error.js +1 -0
- package/templates/express-postgres/src/routes/examples.js +5 -0
- package/templates/express-postgres/src/routes/health.js +4 -0
- package/templates/express-postgres/src/server.js +5 -0
- package/templates/fastapi-mongodb/.env.example +4 -0
- package/templates/fastapi-mongodb/README.md +12 -0
- package/templates/fastapi-mongodb/_gitignore +6 -0
- package/templates/fastapi-mongodb/app/api/routes/examples.py +5 -0
- package/templates/fastapi-mongodb/app/api/routes/health.py +4 -0
- package/templates/fastapi-mongodb/app/core/config.py +6 -0
- package/templates/fastapi-mongodb/app/database/mongo.py +4 -0
- package/templates/fastapi-mongodb/app/main.py +6 -0
- package/templates/fastapi-mongodb/requirements.txt +4 -0
- package/templates/fastapi-postgres/.env.example +3 -0
- package/templates/fastapi-postgres/README.md +12 -0
- package/templates/fastapi-postgres/_gitignore +6 -0
- package/templates/fastapi-postgres/app/api/routes/examples.py +7 -0
- package/templates/fastapi-postgres/app/api/routes/health.py +4 -0
- package/templates/fastapi-postgres/app/core/config.py +7 -0
- package/templates/fastapi-postgres/app/database/session.py +3 -0
- package/templates/fastapi-postgres/app/main.py +6 -0
- package/templates/fastapi-postgres/requirements.txt +5 -0
- package/templates/fastify-postgres/.env.example +2 -0
- package/templates/fastify-postgres/README.md +5 -0
- package/templates/fastify-postgres/package.json +18 -0
- package/templates/fastify-postgres/src/app.js +14 -0
- package/templates/fastify-postgres/src/plugins/db.js +3 -0
- package/templates/fastify-postgres/src/routes/health.js +1 -0
- package/templates/fastify-postgres/src/server.js +4 -0
- package/templates/hybrid/.env.example +8 -0
- package/templates/hybrid/README.md +27 -0
- package/templates/hybrid/_gitignore +6 -0
- package/templates/hybrid/app/api/routes/ingest.py +4 -0
- package/templates/hybrid/app/api/routes/query.py +4 -0
- package/templates/hybrid/app/core/config.py +4 -0
- package/templates/hybrid/app/main.py +4 -0
- package/templates/hybrid/app/retrieval/bm25.py +4 -0
- package/templates/hybrid/app/retrieval/fusion.py +4 -0
- package/templates/hybrid/app/retrieval/reranker.py +4 -0
- package/templates/hybrid/requirements.txt +9 -0
- package/templates/semantic/.env.example +7 -0
- package/templates/semantic/README.md +31 -0
- package/templates/semantic/_gitignore +6 -0
- package/templates/semantic/app/api/routes/health.py +10 -0
- package/templates/semantic/app/api/routes/ingest.py +4 -0
- package/templates/semantic/app/api/routes/query.py +4 -0
- package/templates/semantic/app/core/config.py +4 -0
- package/templates/semantic/app/main.py +8 -0
- package/templates/semantic/app/services/chunker.py +4 -0
- package/templates/semantic/app/services/llm.py +4 -0
- package/templates/semantic/app/vectorstore/qdrant.py +4 -0
- package/templates/semantic/requirements.txt +8 -0
- package/versions.json +24 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Leo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# create-leo
|
|
2
|
+
|
|
3
|
+
`create-leo` is a small, production-oriented backend generator for Node.js, FastAPI, databases, and RAG.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js 22+
|
|
8
|
+
- Python 3.11+ for Python templates
|
|
9
|
+
- `uv` is recommended for Python dependency installation, but Leo falls back to `venv` + `pip`
|
|
10
|
+
|
|
11
|
+
## Use
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx create-leo my-api
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Choose one of seven templates:
|
|
18
|
+
|
|
19
|
+
| ID | Stack |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `express-mongodb` | Express 5 + Mongoose + MongoDB |
|
|
22
|
+
| `express-postgres` | Express 5 + pg + PostgreSQL |
|
|
23
|
+
| `fastify-postgres` | Fastify + pg + PostgreSQL |
|
|
24
|
+
| `fastapi-postgres` | FastAPI + SQLAlchemy + PostgreSQL |
|
|
25
|
+
| `fastapi-mongodb` | FastAPI + native async PyMongo |
|
|
26
|
+
| `semantic` | FastAPI + FastEmbed + local Qdrant + OpenAI-compatible LLM |
|
|
27
|
+
| `hybrid` | Semantic + BM25 + RRF + optional cross-encoder |
|
|
28
|
+
|
|
29
|
+
### Non-interactive
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx create-leo my-api --template express-mongodb
|
|
33
|
+
npx create-leo my-rag --template hybrid --no-install
|
|
34
|
+
npx create-leo --list
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`--no-install` is useful for CI, smoke tests, or when you want to install dependencies yourself.
|
|
38
|
+
|
|
39
|
+
## Design choices
|
|
40
|
+
|
|
41
|
+
- Node 22 native `--watch` and `--env-file`; no nodemon or dotenv.
|
|
42
|
+
- ESM everywhere in Node templates.
|
|
43
|
+
- Express 5 async handlers; no custom async wrapper.
|
|
44
|
+
- FastAPI configuration uses `pydantic-settings`.
|
|
45
|
+
- MongoDB Python uses PyMongo's async client rather than Motor.
|
|
46
|
+
- RAG uses FastEmbed/ONNX rather than sentence-transformers + torch.
|
|
47
|
+
- Qdrant runs in local file mode; no Qdrant server is required for the starter.
|
|
48
|
+
- LLM calls use the OpenAI client with configurable `LLM_BASE_URL`.
|
|
49
|
+
- No LangChain dependency.
|
|
50
|
+
- Hybrid RAG overlays the semantic pipeline instead of duplicating ingestion.
|
|
51
|
+
- Every template has a DB-independent `/health` endpoint.
|
|
52
|
+
|
|
53
|
+
## Free-tier development
|
|
54
|
+
|
|
55
|
+
The generated projects do not require a paid service. For hosted free tiers, use a provider appropriate to the database you choose, or run the database locally. RAG's local Qdrant storage is file-based. An LLM key is only needed when you want generated answers rather than retrieved context.
|
|
56
|
+
|
|
57
|
+
The first FastEmbed query downloads its embedding model. This is expected.
|
|
58
|
+
|
|
59
|
+
## Publishing
|
|
60
|
+
|
|
61
|
+
Before publishing, verify the package name is available:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm view create-leo
|
|
65
|
+
node scripts/check-name.mjs
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
If it is unavailable, use `create-leo-cli` or a scoped package.
|
|
69
|
+
|
|
70
|
+
Then:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npm login
|
|
74
|
+
npm publish
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The `prepublishOnly` script resolves current Node dependency versions, builds `dist`, and runs tests.
|
|
78
|
+
|
|
79
|
+
## Testing
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npm install
|
|
83
|
+
npm test
|
|
84
|
+
npm run build
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
CI generates all templates with `--no-install` and checks their basic filesystem output.
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { parseArgs } from "util";
|
|
5
|
+
import * as p3 from "@clack/prompts";
|
|
6
|
+
import pc3 from "picocolors";
|
|
7
|
+
|
|
8
|
+
// src/registry.ts
|
|
9
|
+
var templates = [
|
|
10
|
+
{ id: "express-mongodb", label: "Express + MongoDB", group: "Node.js", language: "node", description: "Express 5 API with Mongoose and MongoDB", postInstall: ["npm install"] },
|
|
11
|
+
{ id: "express-postgres", label: "Express + PostgreSQL", group: "Node.js", language: "node", description: "Express 5 API with pg Pool and PostgreSQL", postInstall: ["npm install"] },
|
|
12
|
+
{ id: "fastify-postgres", label: "Fastify + PostgreSQL", group: "Node.js", language: "node", description: "Fastify API with pg Pool and PostgreSQL", postInstall: ["npm install"] },
|
|
13
|
+
{ id: "fastapi-postgres", label: "FastAPI + PostgreSQL", group: "Python", language: "python", description: "FastAPI with SQLAlchemy and PostgreSQL", postInstall: ["python -m pip install -r requirements.txt"] },
|
|
14
|
+
{ id: "fastapi-mongodb", label: "FastAPI + MongoDB", group: "Python", language: "python", description: "FastAPI with native async PyMongo", postInstall: ["python -m pip install -r requirements.txt"] },
|
|
15
|
+
{ id: "semantic", label: "Semantic RAG", group: "AI / RAG", language: "python", description: "FastAPI + FastEmbed + local Qdrant + configurable LLM", postInstall: ["python -m pip install -r requirements.txt"] },
|
|
16
|
+
{ id: "hybrid", label: "Hybrid RAG", group: "AI / RAG", language: "python", description: "Semantic RAG plus BM25, RRF fusion and optional reranking", postInstall: ["python -m pip install -r requirements.txt"], overlay: "semantic" }
|
|
17
|
+
];
|
|
18
|
+
function getTemplate(id) {
|
|
19
|
+
return templates.find((t) => t.id === id);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/prompts.ts
|
|
23
|
+
import * as p from "@clack/prompts";
|
|
24
|
+
import pc from "picocolors";
|
|
25
|
+
async function interactive(templateArg, nameArg) {
|
|
26
|
+
p.intro(pc.bold("\u25C7 Leo \u2014 backend project generator"));
|
|
27
|
+
let templateId = templateArg;
|
|
28
|
+
if (!templateId) {
|
|
29
|
+
const selected = await p.select({
|
|
30
|
+
message: "Choose a backend template",
|
|
31
|
+
options: templates.map((t) => ({ value: t.id, label: t.label, hint: `${t.group} \xB7 ${t.description}` }))
|
|
32
|
+
});
|
|
33
|
+
if (p.isCancel(selected)) return null;
|
|
34
|
+
templateId = selected;
|
|
35
|
+
}
|
|
36
|
+
let name = nameArg;
|
|
37
|
+
if (!name) {
|
|
38
|
+
const value = await p.text({ message: "Project name", placeholder: "my-api", validate: (v) => /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(v) ? void 0 : "Use letters, numbers, dots, dashes or underscores." });
|
|
39
|
+
if (p.isCancel(value)) return null;
|
|
40
|
+
name = value;
|
|
41
|
+
}
|
|
42
|
+
return { templateId, name };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/generate.ts
|
|
46
|
+
import path3 from "path";
|
|
47
|
+
import { mkdir as mkdir2, cp as cp2, readFile as readFile2, writeFile as writeFile2, rm } from "fs/promises";
|
|
48
|
+
import { fileURLToPath } from "url";
|
|
49
|
+
import * as p2 from "@clack/prompts";
|
|
50
|
+
import pc2 from "picocolors";
|
|
51
|
+
|
|
52
|
+
// src/utils/fs.ts
|
|
53
|
+
import { cp, mkdir, readFile, writeFile } from "fs/promises";
|
|
54
|
+
import { existsSync } from "fs";
|
|
55
|
+
import path from "path";
|
|
56
|
+
async function copyDir(src, dest) {
|
|
57
|
+
await mkdir(dest, { recursive: true });
|
|
58
|
+
await cp(src, dest, { recursive: true, force: true, errorOnExist: false });
|
|
59
|
+
}
|
|
60
|
+
async function replaceInTree(root, replacements) {
|
|
61
|
+
const entries = await walk(root);
|
|
62
|
+
for (const file of entries) {
|
|
63
|
+
if (!file.endsWith(".json") && !file.endsWith(".md") && !file.endsWith(".txt") && !file.endsWith(".env.example") && !file.endsWith(".py") && !file.endsWith(".js") && !file.endsWith(".ts")) continue;
|
|
64
|
+
const buf = await readFile(file);
|
|
65
|
+
if (buf.includes(0)) continue;
|
|
66
|
+
let text2 = buf.toString("utf8");
|
|
67
|
+
for (const [from, to] of Object.entries(replacements)) text2 = text2.split(from).join(to);
|
|
68
|
+
await writeFile(file, text2);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function walk(dir) {
|
|
72
|
+
const { readdir, stat } = await import("fs/promises");
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const entry of await readdir(dir)) {
|
|
75
|
+
const full = path.join(dir, entry);
|
|
76
|
+
const s = await stat(full);
|
|
77
|
+
if (s.isDirectory()) out.push(...await walk(full));
|
|
78
|
+
else out.push(full);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
function pathExists(p4) {
|
|
83
|
+
return existsSync(p4);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/utils/process.ts
|
|
87
|
+
import { spawn } from "child_process";
|
|
88
|
+
function executable(command) {
|
|
89
|
+
if (process.platform !== "win32") return command;
|
|
90
|
+
if (command === "npm") return "npm.cmd";
|
|
91
|
+
if (command === "pnpm") return "pnpm.cmd";
|
|
92
|
+
if (command === "yarn") return "yarn.cmd";
|
|
93
|
+
if (command === "bun") return "bun.exe";
|
|
94
|
+
return command;
|
|
95
|
+
}
|
|
96
|
+
function run(command, args, cwd, env, shell = false) {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const child = spawn(executable(command), args, {
|
|
99
|
+
cwd,
|
|
100
|
+
env: { ...process.env, ...env },
|
|
101
|
+
stdio: "inherit",
|
|
102
|
+
// Windows cannot launch .cmd shims (npm.cmd, uv.cmd, etc.) directly.
|
|
103
|
+
// Callers opt into the shell only for trusted, fixed package-manager args.
|
|
104
|
+
shell
|
|
105
|
+
});
|
|
106
|
+
child.on("error", reject);
|
|
107
|
+
child.on("exit", (code, signal) => {
|
|
108
|
+
if (code === 0) resolve();
|
|
109
|
+
else reject(new Error(`${command} exited with ${code ?? signal}`));
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
async function commandExists(command) {
|
|
114
|
+
const checker = process.platform === "win32" ? "where" : "which";
|
|
115
|
+
return new Promise((resolve) => {
|
|
116
|
+
const child = spawn(checker, [command], { stdio: "ignore", shell: false });
|
|
117
|
+
child.on("exit", (code) => resolve(code === 0));
|
|
118
|
+
child.on("error", () => resolve(false));
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/install/node.ts
|
|
123
|
+
function packageManager() {
|
|
124
|
+
const ua = process.env.npm_config_user_agent ?? "";
|
|
125
|
+
if (ua.startsWith("pnpm/")) return "pnpm";
|
|
126
|
+
if (ua.startsWith("yarn/")) return "yarn";
|
|
127
|
+
if (ua.startsWith("bun/")) return "bun";
|
|
128
|
+
return "npm";
|
|
129
|
+
}
|
|
130
|
+
async function installNode(projectDir) {
|
|
131
|
+
const manager = packageManager();
|
|
132
|
+
if (!await commandExists(manager)) throw new Error(`${manager} was not found. Install it or use npm.`);
|
|
133
|
+
const args = manager === "yarn" ? ["install"] : ["install"];
|
|
134
|
+
await run(manager, args, projectDir, void 0, process.platform === "win32");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// src/install/python.ts
|
|
138
|
+
import path2 from "path";
|
|
139
|
+
async function pythonCommand() {
|
|
140
|
+
if (process.platform === "win32" && await commandExists("py")) return "py";
|
|
141
|
+
if (await commandExists("python3")) return "python3";
|
|
142
|
+
if (await commandExists("python")) return "python";
|
|
143
|
+
throw new Error("Python 3.11+ was not found. Install Python or uv first.");
|
|
144
|
+
}
|
|
145
|
+
async function checkPython311(py) {
|
|
146
|
+
const { execFileSync } = await import("child_process");
|
|
147
|
+
try {
|
|
148
|
+
const args = process.platform === "win32" && py === "py" ? ["-3", "--version"] : ["--version"];
|
|
149
|
+
const output = execFileSync(py, args, { encoding: "utf8" }).trim();
|
|
150
|
+
const match = output.match(/Python (\d+)\.(\d+)/);
|
|
151
|
+
if (!match || Number(match[1]) < 3 || Number(match[1]) === 3 && Number(match[2]) < 11) {
|
|
152
|
+
throw new Error(`Python 3.11+ required; found ${output}`);
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (error instanceof Error && error.message.startsWith("Python 3.11+ required")) throw error;
|
|
156
|
+
throw new Error("Unable to verify Python 3.11+. Install a supported Python runtime.");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function installPython(projectDir) {
|
|
160
|
+
const py = await pythonCommand();
|
|
161
|
+
await checkPython311(py);
|
|
162
|
+
if (await commandExists("uv")) {
|
|
163
|
+
await run("uv", ["venv", ".venv", "--python", "3.11"], projectDir);
|
|
164
|
+
const uvPython = path2.join(
|
|
165
|
+
projectDir,
|
|
166
|
+
".venv",
|
|
167
|
+
process.platform === "win32" ? "Scripts" : "bin",
|
|
168
|
+
process.platform === "win32" ? "python.exe" : "python"
|
|
169
|
+
);
|
|
170
|
+
await run(uvPython, ["-m", "pip", "install", "-r", "requirements.txt"], projectDir);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const pyArgs = process.platform === "win32" && py === "py" ? ["-3"] : [];
|
|
174
|
+
await run(py, [...pyArgs, "-m", "venv", ".venv"], projectDir);
|
|
175
|
+
const venvPython = path2.join(
|
|
176
|
+
projectDir,
|
|
177
|
+
process.platform === "win32" ? ".venv" : ".venv",
|
|
178
|
+
process.platform === "win32" ? "Scripts" : "bin",
|
|
179
|
+
process.platform === "win32" ? "python.exe" : "python"
|
|
180
|
+
);
|
|
181
|
+
await run(venvPython, ["-m", "pip", "install", "-r", "requirements.txt"], projectDir);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/generate.ts
|
|
185
|
+
var here = path3.dirname(fileURLToPath(import.meta.url));
|
|
186
|
+
var packageRoot = path3.resolve(here, "..");
|
|
187
|
+
var templateRoot = path3.join(packageRoot, "templates");
|
|
188
|
+
async function renameGitignore(root) {
|
|
189
|
+
const entries = await walk(root);
|
|
190
|
+
for (const file of entries) {
|
|
191
|
+
if (path3.basename(file) === "_gitignore") {
|
|
192
|
+
await cp2(file, path3.join(path3.dirname(file), ".gitignore"));
|
|
193
|
+
await rm(file);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async function patchPackageJson(root, name) {
|
|
198
|
+
const pkg = path3.join(root, "package.json");
|
|
199
|
+
if (!pathExists(pkg)) return;
|
|
200
|
+
const json = JSON.parse(await readFile2(pkg, "utf8"));
|
|
201
|
+
json.name = name;
|
|
202
|
+
await writeFile2(pkg, JSON.stringify(json, null, 2) + "\n");
|
|
203
|
+
}
|
|
204
|
+
async function generate(templateId, projectName, noInstall = false) {
|
|
205
|
+
const template = getTemplate(templateId);
|
|
206
|
+
if (!template) throw new Error(`Unknown template: ${templateId}`);
|
|
207
|
+
const target = path3.resolve(process.cwd(), projectName);
|
|
208
|
+
if (pathExists(target)) throw new Error(`Target folder already exists: ${target}`);
|
|
209
|
+
let committed = false;
|
|
210
|
+
const onInterrupt = async () => {
|
|
211
|
+
await rm(target, { recursive: true, force: true });
|
|
212
|
+
process.exit(130);
|
|
213
|
+
};
|
|
214
|
+
process.once("SIGINT", onInterrupt);
|
|
215
|
+
try {
|
|
216
|
+
await mkdir2(target, { recursive: true });
|
|
217
|
+
await copyDir(path3.join(templateRoot, "_shared"), target);
|
|
218
|
+
if (template.overlay) await copyDir(path3.join(templateRoot, template.overlay), target);
|
|
219
|
+
await copyDir(path3.join(templateRoot, template.id), target);
|
|
220
|
+
await replaceInTree(target, { "{{name}}": projectName });
|
|
221
|
+
await patchPackageJson(target, projectName);
|
|
222
|
+
const envExample = path3.join(target, ".env.example");
|
|
223
|
+
if (pathExists(envExample)) await cp2(envExample, path3.join(target, ".env"));
|
|
224
|
+
await renameGitignore(target);
|
|
225
|
+
committed = true;
|
|
226
|
+
if (!noInstall) {
|
|
227
|
+
const spin = p2.spinner();
|
|
228
|
+
spin.start(`Installing ${template.language} dependencies`);
|
|
229
|
+
try {
|
|
230
|
+
if (template.language === "node") await installNode(target);
|
|
231
|
+
else await installPython(target);
|
|
232
|
+
spin.stop("Dependencies installed");
|
|
233
|
+
} catch (error) {
|
|
234
|
+
spin.stop("Dependency installation failed");
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
printNextSteps(target, projectName, template);
|
|
239
|
+
return target;
|
|
240
|
+
} catch (error) {
|
|
241
|
+
await rm(target, { recursive: true, force: true });
|
|
242
|
+
throw error;
|
|
243
|
+
} finally {
|
|
244
|
+
process.removeListener("SIGINT", onInterrupt);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function printNextSteps(target, name, template) {
|
|
248
|
+
const rel = path3.relative(process.cwd(), target) || name;
|
|
249
|
+
console.log(`
|
|
250
|
+
${pc2.green("\u2714")} Created ${pc2.bold(name)} using ${template.label}`);
|
|
251
|
+
console.log(`
|
|
252
|
+
${pc2.dim("Next steps")}`);
|
|
253
|
+
console.log(` ${pc2.cyan(`cd ${rel}`)}`);
|
|
254
|
+
if (template.language === "node") {
|
|
255
|
+
console.log(` ${pc2.cyan("cp .env.example .env")}`);
|
|
256
|
+
console.log(` ${pc2.cyan("npm run dev")}`);
|
|
257
|
+
} else {
|
|
258
|
+
console.log(` ${pc2.cyan("cp .env.example .env")}`);
|
|
259
|
+
console.log(` ${pc2.cyan("source .venv/bin/activate")} ${pc2.dim("(macOS/Linux)")}`);
|
|
260
|
+
console.log(` ${pc2.cyan("uvicorn app.main:app --reload")} ${pc2.dim("(or use the Windows venv equivalent)")}`);
|
|
261
|
+
}
|
|
262
|
+
console.log(`
|
|
263
|
+
${pc2.dim("Health:")} ${pc2.cyan("GET /health")}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// src/index.ts
|
|
267
|
+
var { values, positionals } = parseArgs({
|
|
268
|
+
options: {
|
|
269
|
+
template: { type: "string", short: "t" },
|
|
270
|
+
"no-install": { type: "boolean", default: false },
|
|
271
|
+
help: { type: "boolean", short: "h" },
|
|
272
|
+
version: { type: "boolean", short: "v" },
|
|
273
|
+
list: { type: "boolean" }
|
|
274
|
+
},
|
|
275
|
+
allowPositionals: true,
|
|
276
|
+
strict: true
|
|
277
|
+
});
|
|
278
|
+
if (values.version) {
|
|
279
|
+
console.log("0.1.0");
|
|
280
|
+
process.exit(0);
|
|
281
|
+
}
|
|
282
|
+
if (values.help) {
|
|
283
|
+
console.log(`${pc3.bold("create-leo")} \u2014 lightweight backend project generator
|
|
284
|
+
|
|
285
|
+
Usage:
|
|
286
|
+
npx create-leo [project-name]
|
|
287
|
+
npx create-leo my-api --template express-mongodb
|
|
288
|
+
npx create-leo my-rag --template semantic --no-install
|
|
289
|
+
|
|
290
|
+
Options:
|
|
291
|
+
-t, --template <id> Generate directly from a template
|
|
292
|
+
--no-install Skip dependency installation
|
|
293
|
+
--list List templates
|
|
294
|
+
-h, --help Show help
|
|
295
|
+
-v, --version Show version
|
|
296
|
+
|
|
297
|
+
Templates:
|
|
298
|
+
${templates.map((t) => ` ${t.id.padEnd(20)} ${t.description}`).join("\n")}`);
|
|
299
|
+
process.exit(0);
|
|
300
|
+
}
|
|
301
|
+
if (values.list) {
|
|
302
|
+
for (const t of templates) console.log(`${t.id} ${t.description}`);
|
|
303
|
+
process.exit(0);
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
if (values.template && !getTemplate(values.template)) throw new Error(`Unknown template '${values.template}'. Run --list to see valid IDs.`);
|
|
307
|
+
const chosen = await interactive(values.template, positionals[0]);
|
|
308
|
+
if (!chosen) process.exit(0);
|
|
309
|
+
await generate(chosen.templateId, chosen.name, values["no-install"]);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
if (error.code === "ERR_USE_AFTER_CLOSE") process.exit(130);
|
|
312
|
+
console.error(`
|
|
313
|
+
${pc3.red("\u2716")} ${error instanceof Error ? error.message : String(error)}`);
|
|
314
|
+
p3.outro(pc3.dim("No partial project was left behind."));
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-leo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A lightweight project generator for production-oriented Node.js, FastAPI, database and RAG backends.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "create-leo": "dist/index.js", "leo": "dist/index.js" },
|
|
7
|
+
"files": ["dist", "templates", "README.md", "LICENSE", "CHANGELOG.md", "versions.json"],
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsup src/index.ts --format esm --target node22 --clean",
|
|
10
|
+
"dev": "tsx src/index.ts",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"test:watch": "vitest",
|
|
13
|
+
"resolve-versions": "node scripts/resolve-versions.mjs",
|
|
14
|
+
"prepublishOnly": "npm run resolve-versions && npm run build && npm test"
|
|
15
|
+
},
|
|
16
|
+
"engines": { "node": ">=22.0.0" },
|
|
17
|
+
"packageManager": "npm@11",
|
|
18
|
+
"publishConfig": { "access": "public" },
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@clack/prompts": "^0.11.0",
|
|
21
|
+
"picocolors": "^1.1.1"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^24.0.0",
|
|
25
|
+
"tsup": "^8.5.0",
|
|
26
|
+
"tsx": "^4.20.0",
|
|
27
|
+
"typescript": "^5.9.0",
|
|
28
|
+
"vitest": "^3.2.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
Generated by Leo.
|
|
4
|
+
|
|
5
|
+
## Configuration
|
|
6
|
+
|
|
7
|
+
Copy `.env.example` to `.env` and fill in the values for your environment.
|
|
8
|
+
|
|
9
|
+
## Health
|
|
10
|
+
|
|
11
|
+
`GET /health` is intentionally independent of the database so the service can boot before a database is available.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
Express 5 + MongoDB starter using Node 22 native `--watch` and `--env-file`.
|
|
4
|
+
|
|
5
|
+
## Run
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cp .env.example .env
|
|
9
|
+
npm run dev
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
MongoDB is connected lazily after startup. `GET /health` works without MongoDB.
|
|
13
|
+
For a free hosted database, use MongoDB Atlas and put its URI in `MONGODB_URI`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.0.0"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "node --watch --env-file=.env src/server.js",
|
|
11
|
+
"start": "node --env-file=.env src/server.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"cors": "^2.8.5",
|
|
15
|
+
"express": "^5.1.0",
|
|
16
|
+
"mongoose": "^8.18.0"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import health from './routes/health.js';
|
|
4
|
+
import examples from './routes/examples.js';
|
|
5
|
+
import { errorHandler } from './middleware/error.js';
|
|
6
|
+
export const app = express();
|
|
7
|
+
app.use(cors());
|
|
8
|
+
app.use(express.json());
|
|
9
|
+
app.use(health);
|
|
10
|
+
app.use('/api/examples', examples);
|
|
11
|
+
app.use(errorHandler);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import mongoose from 'mongoose';
|
|
2
|
+
|
|
3
|
+
let connected = false;
|
|
4
|
+
export async function connectDb() {
|
|
5
|
+
if (connected) return;
|
|
6
|
+
await mongoose.connect(process.env.MONGODB_URI);
|
|
7
|
+
connected = true;
|
|
8
|
+
}
|
|
9
|
+
export function dbState() { return mongoose.connection.readyState; }
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { Example } from '../models/example.js';
|
|
3
|
+
const router = Router();
|
|
4
|
+
router.get('/', async (_req, res) => res.json(await Example.find().lean()));
|
|
5
|
+
router.post('/', async (req, res) => res.status(201).json(await Example.create(req.body)));
|
|
6
|
+
export default router;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { app } from './app.js';
|
|
2
|
+
import { connectDb } from './config/db.js';
|
|
3
|
+
const port = Number(process.env.PORT ?? 3000);
|
|
4
|
+
app.listen(port, () => console.log(`API listening on http://localhost:${port}`));
|
|
5
|
+
connectDb().catch((err) => console.warn('MongoDB unavailable; /health remains available:', err.message));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.0.0"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "node --watch --env-file=.env src/server.js",
|
|
11
|
+
"start": "node --env-file=.env src/server.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"cors": "^2.8.5",
|
|
15
|
+
"express": "^5.1.0",
|
|
16
|
+
"pg": "^8.16.3"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import health from './routes/health.js';
|
|
4
|
+
import examples from './routes/examples.js';
|
|
5
|
+
import { errorHandler } from './middleware/error.js';
|
|
6
|
+
export const app = express();
|
|
7
|
+
app.use(cors()); app.use(express.json()); app.use(health); app.use('/api/examples', examples); app.use(errorHandler);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function errorHandler(err, _req, res, _next) { console.error(err); res.status(err.statusCode ?? 500).json({ error: err.message ?? 'Internal server error' }); }
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { app } from './app.js';
|
|
2
|
+
import { checkDb } from './config/db.js';
|
|
3
|
+
const port = Number(process.env.PORT ?? 3000);
|
|
4
|
+
app.listen(port, () => console.log(`API listening on http://localhost:${port}`));
|
|
5
|
+
checkDb().catch((err) => console.warn('PostgreSQL unavailable; /health remains available:', err.message));
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
2
|
+
class Settings(BaseSettings):
|
|
3
|
+
app_env: str = 'development'; port: int = 8000
|
|
4
|
+
mongodb_uri: str = 'mongodb://127.0.0.1:27017'; mongodb_db: str = '{{name}}'
|
|
5
|
+
model_config = SettingsConfigDict(env_file='.env', extra='ignore')
|
|
6
|
+
settings = Settings()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from fastapi import APIRouter
|
|
2
|
+
from sqlalchemy import text
|
|
3
|
+
from app.database.session import engine
|
|
4
|
+
router = APIRouter(prefix='/api/examples', tags=['examples'])
|
|
5
|
+
@router.get('/')
|
|
6
|
+
def examples():
|
|
7
|
+
with engine.connect() as conn: return [dict(row) for row in conn.execute(text('SELECT NOW() AS now')).mappings()]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
2
|
+
class Settings(BaseSettings):
|
|
3
|
+
app_env: str = 'development'
|
|
4
|
+
port: int = 8000
|
|
5
|
+
database_url: str = 'postgresql+psycopg://postgres:postgres@127.0.0.1:5432/{{name}}'
|
|
6
|
+
model_config = SettingsConfigDict(env_file='.env', extra='ignore')
|
|
7
|
+
settings = Settings()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.0.0"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "node --watch --env-file=.env src/server.js",
|
|
11
|
+
"start": "node --env-file=.env src/server.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@fastify/cors": "^11.1.0",
|
|
15
|
+
"fastify": "^5.6.0",
|
|
16
|
+
"pg": "^8.16.3"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
import cors from '@fastify/cors';
|
|
3
|
+
import { createDb } from './plugins/db.js';
|
|
4
|
+
import health from './routes/health.js';
|
|
5
|
+
export function buildApp() {
|
|
6
|
+
const app = Fastify({ logger: true });
|
|
7
|
+
app.register(cors);
|
|
8
|
+
const db = createDb();
|
|
9
|
+
app.decorate('db', db);
|
|
10
|
+
app.addHook('onClose', async () => db.end());
|
|
11
|
+
app.register(health);
|
|
12
|
+
app.get('/api/examples', async () => (await app.db.query('SELECT NOW() AS now')).rows);
|
|
13
|
+
return app;
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default async function health(fastify) { fastify.get('/health', async () => ({ status: 'ok' })); }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
Hybrid RAG project skeleton.
|
|
4
|
+
|
|
5
|
+
Leo installs the dependencies and creates the folder structure; the retrieval/ingestion implementation is intentionally left for you to build.
|
|
6
|
+
|
|
7
|
+
Hybrid is designed as a thin retrieval overlay on top of the semantic architecture: semantic search + BM25 + RRF + optional cross-encoder reranking. Keep one ingestion pipeline.
|
|
8
|
+
|
|
9
|
+
## Dependencies
|
|
10
|
+
|
|
11
|
+
Includes all Semantic RAG dependencies plus:
|
|
12
|
+
|
|
13
|
+
- `bm25s` — lexical/BM25 retrieval
|
|
14
|
+
|
|
15
|
+
The reranker uses the cross-encoder support provided by FastEmbed.
|
|
16
|
+
|
|
17
|
+
## Structure
|
|
18
|
+
|
|
19
|
+
- `app/main.py` — application entry point
|
|
20
|
+
- `app/api/routes/ingest.py` — shared ingestion route
|
|
21
|
+
- `app/api/routes/query.py` — hybrid query route
|
|
22
|
+
- `app/core/config.py` — settings
|
|
23
|
+
- `app/retrieval/bm25.py` — lexical retrieval
|
|
24
|
+
- `app/retrieval/fusion.py` — RRF fusion
|
|
25
|
+
- `app/retrieval/reranker.py` — optional cross-encoder reranking
|
|
26
|
+
|
|
27
|
+
Each Python file contains only a short note describing its responsibility.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
Semantic RAG project skeleton.
|
|
4
|
+
|
|
5
|
+
Leo installs the dependencies and creates the folder structure; the RAG implementation is intentionally left for you to build.
|
|
6
|
+
|
|
7
|
+
## Dependencies
|
|
8
|
+
|
|
9
|
+
- `fastapi` / `uvicorn[standard]` — API server
|
|
10
|
+
- `pydantic-settings` — typed environment configuration
|
|
11
|
+
- `python-multipart` — file uploads
|
|
12
|
+
- `pypdf` — PDF text extraction
|
|
13
|
+
- `fastembed` — lightweight ONNX embeddings
|
|
14
|
+
- `qdrant-client` — local vector storage/client
|
|
15
|
+
- `openai` — OpenAI-compatible LLM client
|
|
16
|
+
|
|
17
|
+
## Structure
|
|
18
|
+
|
|
19
|
+
- `app/main.py` — application entry point
|
|
20
|
+
- `app/api/routes/ingest.py` — document ingestion route
|
|
21
|
+
- `app/api/routes/query.py` — retrieval/query route
|
|
22
|
+
- `app/api/routes/health.py` — health endpoint
|
|
23
|
+
- `app/core/config.py` — settings
|
|
24
|
+
- `app/services/chunker.py` — chunking logic
|
|
25
|
+
- `app/services/llm.py` — LLM integration
|
|
26
|
+
- `app/vectorstore/qdrant.py` — vector-store integration
|
|
27
|
+
|
|
28
|
+
The API starts with `uvicorn app.main:app --reload` and exposes `GET /health`.
|
|
29
|
+
The ingestion and retrieval pipeline is a skeleton for you to implement.
|
|
30
|
+
|
|
31
|
+
The first real embedding operation will download the selected FastEmbed model.
|
package/versions.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"node": {
|
|
3
|
+
"express": "^5.1.0",
|
|
4
|
+
"mongoose": "^8.18.0",
|
|
5
|
+
"cors": "^2.8.5",
|
|
6
|
+
"pg": "^8.16.3",
|
|
7
|
+
"fastify": "^5.6.0",
|
|
8
|
+
"@fastify/cors": "^11.1.0"
|
|
9
|
+
},
|
|
10
|
+
"python": {
|
|
11
|
+
"fastapi": "fastapi==0.142.2",
|
|
12
|
+
"uvicorn": "uvicorn==0.54.0",
|
|
13
|
+
"pydantic-settings": "pydantic-settings==2.15.0",
|
|
14
|
+
"sqlalchemy": "sqlalchemy==2.1.2",
|
|
15
|
+
"psycopg": "psycopg==3.3.6",
|
|
16
|
+
"pymongo": "pymongo==4.18.2",
|
|
17
|
+
"python-multipart": "python-multipart==0.0.32",
|
|
18
|
+
"pypdf": "pypdf==6.19.0",
|
|
19
|
+
"fastembed": "fastembed==0.8.1",
|
|
20
|
+
"qdrant-client": "qdrant-client==1.19.1",
|
|
21
|
+
"openai": "openai==3.24.0",
|
|
22
|
+
"bm25s": "bm25s==0.3.11"
|
|
23
|
+
}
|
|
24
|
+
}
|