create-headless-lms 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/README.md +19 -0
- package/dist/index.js +311 -0
- package/dist/index.js.map +1 -0
- package/package.json +40 -0
- package/templates/README.md +17 -0
- package/templates/env.tmpl +11 -0
- package/templates/gitignore +3 -0
- package/templates/package.json.tmpl +26 -0
- package/templates/src/config.ts +65 -0
- package/templates/src/main.ts +22 -0
- package/templates/src/plugins/README.md +15 -0
- package/templates/tsconfig.json +15 -0
- package/templates/tsdown.config.ts +18 -0
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# create-headless-lms
|
|
2
|
+
|
|
3
|
+
Scaffolds a standalone LMS installation: `npm create headless-lms` (or
|
|
4
|
+
`create-headless-lms <name> --yes` for defaults). An interactive wizard asks
|
|
5
|
+
for name, ports, database and storage settings, renders `templates/` into a
|
|
6
|
+
new project depending on `@headless-lms/server` + `@headless-lms/cli`, then
|
|
7
|
+
offers to install and migrate.
|
|
8
|
+
|
|
9
|
+
The generated installation owns only its config, entry point, and integration
|
|
10
|
+
plugins — the backend itself is the server library.
|
|
11
|
+
|
|
12
|
+
## Develop
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pnpm --filter create-headless-lms build
|
|
16
|
+
pnpm --filter create-headless-lms test
|
|
17
|
+
bash scripts/e2e.sh # scaffold → install packed workspace tarballs → migrate → boot
|
|
18
|
+
# (needs docker Postgres from docker/docker-compose.yml)
|
|
19
|
+
```
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import * as p from "@clack/prompts";
|
|
5
|
+
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
//#region src/args.ts
|
|
9
|
+
function parseArgs(argv) {
|
|
10
|
+
const yes = argv.includes("--yes") || argv.includes("-y");
|
|
11
|
+
return {
|
|
12
|
+
name: argv.find((a) => !a.startsWith("-")),
|
|
13
|
+
yes
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** Returns an error message, or undefined when valid (npm package name rules, the subset we need). */
|
|
17
|
+
function validateName(name) {
|
|
18
|
+
if (!name) return "project name is required";
|
|
19
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(name)) return "use lowercase letters, digits, ., _ and - (must start with a letter or digit)";
|
|
20
|
+
if (name.length > 214) return "name too long (npm limit is 214)";
|
|
21
|
+
}
|
|
22
|
+
//#endregion
|
|
23
|
+
//#region src/answers.ts
|
|
24
|
+
function defaultAnswers(name) {
|
|
25
|
+
return {
|
|
26
|
+
name,
|
|
27
|
+
db: { mode: "docker" },
|
|
28
|
+
storage: { mode: "minio" },
|
|
29
|
+
port: 8e3,
|
|
30
|
+
clientOrigins: "http://localhost:8001,http://localhost:8002"
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** my-lms → my_lms: the docker Postgres database name. */
|
|
34
|
+
function dbName(name) {
|
|
35
|
+
return name.replace(/[^a-zA-Z0-9]+/g, "_").toLowerCase();
|
|
36
|
+
}
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/scaffold.ts
|
|
39
|
+
/** templates/ sits at the package root: ../templates from src/ AND dist/. */
|
|
40
|
+
const TEMPLATES = fileURLToPath(new URL("../templates/", import.meta.url));
|
|
41
|
+
function render(content, vars) {
|
|
42
|
+
return content.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
43
|
+
const v = vars[key];
|
|
44
|
+
if (v === void 0) throw new Error(`template variable {{${key}}} has no value`);
|
|
45
|
+
return v;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const POSTGRES_SERVICE = ` postgres:
|
|
49
|
+
image: postgres:17
|
|
50
|
+
environment:
|
|
51
|
+
POSTGRES_USER: postgres
|
|
52
|
+
POSTGRES_PASSWORD: postgres
|
|
53
|
+
POSTGRES_DB: {{DB_NAME}}
|
|
54
|
+
ports:
|
|
55
|
+
- "8005:5432"
|
|
56
|
+
volumes:
|
|
57
|
+
- pgdata:/var/lib/postgresql/data`;
|
|
58
|
+
const MINIO_SERVICE = ` minio:
|
|
59
|
+
image: minio/minio
|
|
60
|
+
command: server /data --console-address ":9001"
|
|
61
|
+
environment:
|
|
62
|
+
MINIO_ROOT_USER: minioadmin
|
|
63
|
+
MINIO_ROOT_PASSWORD: minioadmin
|
|
64
|
+
ports:
|
|
65
|
+
- "8006:9000"
|
|
66
|
+
- "8007:9001"
|
|
67
|
+
volumes:
|
|
68
|
+
- miniodata:/data`;
|
|
69
|
+
const MINIO_ENV = `
|
|
70
|
+
STORAGE_ENDPOINT=localhost
|
|
71
|
+
STORAGE_PORT=8006
|
|
72
|
+
STORAGE_USE_SSL=false
|
|
73
|
+
STORAGE_ACCESS_KEY=minioadmin
|
|
74
|
+
STORAGE_SECRET_KEY=minioadmin
|
|
75
|
+
STORAGE_REGION=us-east-1
|
|
76
|
+
STORAGE_BUCKET={{DB_NAME}}
|
|
77
|
+
`;
|
|
78
|
+
function s3Env(s) {
|
|
79
|
+
return `
|
|
80
|
+
STORAGE_ENDPOINT=${s.endPoint}
|
|
81
|
+
STORAGE_PORT=${s.port}
|
|
82
|
+
STORAGE_USE_SSL=${s.useSSL}
|
|
83
|
+
STORAGE_ACCESS_KEY=${s.accessKey}
|
|
84
|
+
STORAGE_SECRET_KEY=${s.secretKey}
|
|
85
|
+
STORAGE_REGION=${s.region}
|
|
86
|
+
STORAGE_BUCKET=${s.bucket}
|
|
87
|
+
`;
|
|
88
|
+
}
|
|
89
|
+
/** Same keys as s3Env, blanked — s3 credentials are real secrets and must never land in .env.example. */
|
|
90
|
+
const S3_ENV_EXAMPLE = `
|
|
91
|
+
STORAGE_ENDPOINT=
|
|
92
|
+
STORAGE_PORT=
|
|
93
|
+
STORAGE_USE_SSL=
|
|
94
|
+
STORAGE_ACCESS_KEY=
|
|
95
|
+
STORAGE_SECRET_KEY=
|
|
96
|
+
STORAGE_REGION=
|
|
97
|
+
STORAGE_BUCKET=
|
|
98
|
+
`;
|
|
99
|
+
async function scaffold(answers, targetDir) {
|
|
100
|
+
try {
|
|
101
|
+
if ((await readdir(targetDir)).length > 0) throw new Error(`target directory ${targetDir} is not empty`);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (err.code !== "ENOENT") throw err;
|
|
104
|
+
}
|
|
105
|
+
const written = [];
|
|
106
|
+
try {
|
|
107
|
+
const name = dbName(answers.name);
|
|
108
|
+
const databaseUrl = answers.db.mode === "docker" ? `postgres://postgres:postgres@localhost:8005/${name}` : answers.db.url;
|
|
109
|
+
const storageEnv = answers.storage.mode === "minio" ? render(MINIO_ENV, { DB_NAME: name }) : answers.storage.mode === "s3" ? s3Env(answers.storage) : "";
|
|
110
|
+
const vars = {
|
|
111
|
+
NAME: answers.name,
|
|
112
|
+
DB_NAME: name,
|
|
113
|
+
PORT: String(answers.port),
|
|
114
|
+
CLIENT_ORIGINS: answers.clientOrigins,
|
|
115
|
+
DATABASE_URL: databaseUrl,
|
|
116
|
+
BETTER_AUTH_SECRET: randomBytes(32).toString("base64"),
|
|
117
|
+
CREDENTIAL_STORE_KEY: randomBytes(32).toString("base64"),
|
|
118
|
+
STORAGE_ENV: storageEnv
|
|
119
|
+
};
|
|
120
|
+
await mkdir(join(targetDir, "src", "plugins"), { recursive: true });
|
|
121
|
+
const write = async (rel, content) => {
|
|
122
|
+
await writeFile(join(targetDir, rel), content);
|
|
123
|
+
written.push(rel);
|
|
124
|
+
};
|
|
125
|
+
const fromTemplate = async (tmpl, rel, extra) => write(rel, render(await readFile(join(TEMPLATES, tmpl), "utf8"), {
|
|
126
|
+
...vars,
|
|
127
|
+
...extra
|
|
128
|
+
}));
|
|
129
|
+
await fromTemplate("package.json.tmpl", "package.json");
|
|
130
|
+
await fromTemplate("README.md", "README.md");
|
|
131
|
+
await fromTemplate("env.tmpl", ".env");
|
|
132
|
+
await fromTemplate("env.tmpl", ".env.example", {
|
|
133
|
+
BETTER_AUTH_SECRET: "",
|
|
134
|
+
CREDENTIAL_STORE_KEY: "",
|
|
135
|
+
DATABASE_URL: answers.db.mode === "docker" ? databaseUrl : "",
|
|
136
|
+
...answers.storage.mode === "s3" ? { STORAGE_ENV: S3_ENV_EXAMPLE } : {}
|
|
137
|
+
});
|
|
138
|
+
await cp(join(TEMPLATES, "tsconfig.json"), join(targetDir, "tsconfig.json"));
|
|
139
|
+
await cp(join(TEMPLATES, "tsdown.config.ts"), join(targetDir, "tsdown.config.ts"));
|
|
140
|
+
await cp(join(TEMPLATES, "gitignore"), join(targetDir, ".gitignore"));
|
|
141
|
+
await cp(join(TEMPLATES, "src/main.ts"), join(targetDir, "src/main.ts"));
|
|
142
|
+
await cp(join(TEMPLATES, "src/config.ts"), join(targetDir, "src/config.ts"));
|
|
143
|
+
await cp(join(TEMPLATES, "src/plugins/README.md"), join(targetDir, "src/plugins/README.md"));
|
|
144
|
+
written.push("tsconfig.json", "tsdown.config.ts", ".gitignore", "src/main.ts", "src/config.ts", "src/plugins/README.md");
|
|
145
|
+
const services = [];
|
|
146
|
+
const volumes = [];
|
|
147
|
+
if (answers.db.mode === "docker") {
|
|
148
|
+
services.push(render(POSTGRES_SERVICE, { DB_NAME: name }));
|
|
149
|
+
volumes.push(" pgdata:");
|
|
150
|
+
}
|
|
151
|
+
if (answers.storage.mode === "minio") {
|
|
152
|
+
services.push(MINIO_SERVICE);
|
|
153
|
+
volumes.push(" miniodata:");
|
|
154
|
+
}
|
|
155
|
+
if (services.length > 0) await write("docker-compose.yml", `name: ${answers.name}\nservices:\n${services.join("\n")}\nvolumes:\n${volumes.join("\n")}\n`);
|
|
156
|
+
return written;
|
|
157
|
+
} catch (err) {
|
|
158
|
+
await rm(targetDir, {
|
|
159
|
+
recursive: true,
|
|
160
|
+
force: true
|
|
161
|
+
});
|
|
162
|
+
throw err;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
//#endregion
|
|
166
|
+
//#region src/index.ts
|
|
167
|
+
const args = parseArgs(process.argv.slice(2));
|
|
168
|
+
function bail(message = "Cancelled.") {
|
|
169
|
+
p.cancel(message);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
/** Unwrap a clack result, exiting cleanly on ctrl-c. */
|
|
173
|
+
function got(value) {
|
|
174
|
+
if (p.isCancel(value)) bail();
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
p.intro("create-headless-lms");
|
|
178
|
+
/** Reject non-integer or out-of-range (1-65535) port input; undefined/empty passes through (Enter keeps the default). */
|
|
179
|
+
function validatePort(v) {
|
|
180
|
+
if (!v) return;
|
|
181
|
+
if (!/^\d+$/.test(v)) return "Port must be a whole number.";
|
|
182
|
+
const n = Number(v);
|
|
183
|
+
if (n < 1 || n > 65535) return "Port must be between 1 and 65535.";
|
|
184
|
+
}
|
|
185
|
+
let name = args.name;
|
|
186
|
+
if (!name) {
|
|
187
|
+
if (args.yes) bail("--yes needs a project name: create-headless-lms <name> --yes");
|
|
188
|
+
name = got(await p.text({
|
|
189
|
+
message: "Project name",
|
|
190
|
+
placeholder: "my-lms",
|
|
191
|
+
validate: (v) => validateName(v ?? "")
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
const nameError = validateName(name);
|
|
195
|
+
if (nameError) bail(nameError);
|
|
196
|
+
let answers = defaultAnswers(name);
|
|
197
|
+
if (!args.yes) {
|
|
198
|
+
const db = got(await p.select({
|
|
199
|
+
message: "Database",
|
|
200
|
+
options: [{
|
|
201
|
+
value: "docker",
|
|
202
|
+
label: "Postgres via docker-compose (recommended)"
|
|
203
|
+
}, {
|
|
204
|
+
value: "url",
|
|
205
|
+
label: "I have a connection string"
|
|
206
|
+
}]
|
|
207
|
+
})) === "url" ? {
|
|
208
|
+
mode: "url",
|
|
209
|
+
url: got(await p.text({
|
|
210
|
+
message: "DATABASE_URL",
|
|
211
|
+
placeholder: "postgres://user:pass@host:5432/db"
|
|
212
|
+
}))
|
|
213
|
+
} : { mode: "docker" };
|
|
214
|
+
const storageMode = got(await p.select({
|
|
215
|
+
message: "File storage",
|
|
216
|
+
options: [
|
|
217
|
+
{
|
|
218
|
+
value: "minio",
|
|
219
|
+
label: "Bundled MinIO via docker-compose (recommended)"
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
value: "s3",
|
|
223
|
+
label: "Existing S3-compatible storage"
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
value: "skip",
|
|
227
|
+
label: "Skip for now"
|
|
228
|
+
}
|
|
229
|
+
]
|
|
230
|
+
}));
|
|
231
|
+
let storage = { mode: "minio" };
|
|
232
|
+
if (storageMode === "skip") storage = { mode: "skip" };
|
|
233
|
+
if (storageMode === "s3") storage = {
|
|
234
|
+
mode: "s3",
|
|
235
|
+
endPoint: got(await p.text({
|
|
236
|
+
message: "Endpoint host",
|
|
237
|
+
placeholder: "s3.amazonaws.com"
|
|
238
|
+
})),
|
|
239
|
+
port: Number(got(await p.text({
|
|
240
|
+
message: "Port",
|
|
241
|
+
initialValue: "443",
|
|
242
|
+
validate: validatePort
|
|
243
|
+
}))),
|
|
244
|
+
useSSL: got(await p.confirm({
|
|
245
|
+
message: "Use SSL?",
|
|
246
|
+
initialValue: true
|
|
247
|
+
})),
|
|
248
|
+
accessKey: got(await p.text({ message: "Access key" })),
|
|
249
|
+
secretKey: got(await p.text({ message: "Secret key" })),
|
|
250
|
+
region: got(await p.text({
|
|
251
|
+
message: "Region",
|
|
252
|
+
initialValue: "us-east-1"
|
|
253
|
+
})),
|
|
254
|
+
bucket: got(await p.text({
|
|
255
|
+
message: "Bucket",
|
|
256
|
+
initialValue: name
|
|
257
|
+
}))
|
|
258
|
+
};
|
|
259
|
+
const port = Number(got(await p.text({
|
|
260
|
+
message: "API port",
|
|
261
|
+
initialValue: "8000",
|
|
262
|
+
validate: validatePort
|
|
263
|
+
})));
|
|
264
|
+
const clientOrigins = got(await p.text({
|
|
265
|
+
message: "Client origins (comma-separated, for CORS + auth)",
|
|
266
|
+
initialValue: "http://localhost:8001,http://localhost:8002"
|
|
267
|
+
}));
|
|
268
|
+
answers = {
|
|
269
|
+
name,
|
|
270
|
+
db,
|
|
271
|
+
storage,
|
|
272
|
+
port,
|
|
273
|
+
clientOrigins
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
const targetDir = resolve(process.cwd(), name);
|
|
277
|
+
const s = p.spinner();
|
|
278
|
+
s.start(`Scaffolding ${name}`);
|
|
279
|
+
try {
|
|
280
|
+
await scaffold(answers, targetDir);
|
|
281
|
+
s.stop(`Created ${name}/`);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
s.stop("Scaffold failed");
|
|
284
|
+
bail(err instanceof Error ? err.message : String(err));
|
|
285
|
+
}
|
|
286
|
+
const run = (cmd, argv) => spawnSync(cmd, argv, {
|
|
287
|
+
cwd: targetDir,
|
|
288
|
+
stdio: "inherit"
|
|
289
|
+
});
|
|
290
|
+
if (!args.yes && got(await p.confirm({
|
|
291
|
+
message: "Run pnpm install now?",
|
|
292
|
+
initialValue: true
|
|
293
|
+
}))) {
|
|
294
|
+
run("pnpm", ["install"]);
|
|
295
|
+
if (got(await p.confirm({
|
|
296
|
+
message: "Run migrations now? (database must be reachable)",
|
|
297
|
+
initialValue: false
|
|
298
|
+
}))) run("pnpm", ["migrate"]);
|
|
299
|
+
}
|
|
300
|
+
p.note([
|
|
301
|
+
`cd ${name}`,
|
|
302
|
+
...answers.db.mode === "docker" || answers.storage.mode === "minio" ? ["docker compose up -d"] : [],
|
|
303
|
+
"pnpm install # if you skipped it",
|
|
304
|
+
"pnpm migrate",
|
|
305
|
+
"pnpm dev"
|
|
306
|
+
].join("\n"), "Next steps");
|
|
307
|
+
p.outro(`API will listen on http://localhost:${answers.port} — docs at /docs`);
|
|
308
|
+
//#endregion
|
|
309
|
+
export {};
|
|
310
|
+
|
|
311
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/args.ts","../src/answers.ts","../src/scaffold.ts","../src/index.ts"],"sourcesContent":["export interface CliArgs {\n name: string | undefined;\n yes: boolean;\n}\n\nexport function parseArgs(argv: string[]): CliArgs {\n const yes = argv.includes(\"--yes\") || argv.includes(\"-y\");\n const name = argv.find((a) => !a.startsWith(\"-\"));\n return { name, yes };\n}\n\n/** Returns an error message, or undefined when valid (npm package name rules, the subset we need). */\nexport function validateName(name: string): string | undefined {\n if (!name) {\n return \"project name is required\";\n }\n if (!/^[a-z0-9][a-z0-9._-]*$/.test(name)) {\n return \"use lowercase letters, digits, ., _ and - (must start with a letter or digit)\";\n }\n if (name.length > 214) {\n return \"name too long (npm limit is 214)\";\n }\n return undefined;\n}\n","export type DbAnswer = { mode: \"docker\" } | { mode: \"url\"; url: string };\nexport type StorageAnswer =\n | { mode: \"minio\" }\n | {\n mode: \"s3\";\n endPoint: string;\n port: number;\n useSSL: boolean;\n accessKey: string;\n secretKey: string;\n region: string;\n bucket: string;\n }\n | { mode: \"skip\" };\n\nexport interface Answers {\n name: string;\n db: DbAnswer;\n storage: StorageAnswer;\n port: number;\n clientOrigins: string;\n}\n\nexport function defaultAnswers(name: string): Answers {\n return {\n name,\n db: { mode: \"docker\" },\n storage: { mode: \"minio\" },\n port: 8000,\n clientOrigins: \"http://localhost:8001,http://localhost:8002\",\n };\n}\n\n/** my-lms → my_lms: the docker Postgres database name. */\nexport function dbName(name: string): string {\n return name.replace(/[^a-zA-Z0-9]+/g, \"_\").toLowerCase();\n}\n","// Renders the bundled templates into a new installation directory.\n// Pure with respect to prompts: everything variable comes in via Answers.\nimport { cp, mkdir, readFile, readdir, rm, writeFile } from \"node:fs/promises\";\nimport { randomBytes } from \"node:crypto\";\nimport { fileURLToPath } from \"node:url\";\nimport { join } from \"node:path\";\nimport { type Answers, dbName } from \"./answers.js\";\n\n/** templates/ sits at the package root: ../templates from src/ AND dist/. */\nconst TEMPLATES = fileURLToPath(new URL(\"../templates/\", import.meta.url));\n\nfunction render(content: string, vars: Record<string, string>): string {\n return content.replace(/\\{\\{(\\w+)\\}\\}/g, (_, key: string) => {\n const v = vars[key];\n if (v === undefined) {\n throw new Error(`template variable {{${key}}} has no value`);\n }\n return v;\n });\n}\n\nconst POSTGRES_SERVICE = ` postgres:\n image: postgres:17\n environment:\n POSTGRES_USER: postgres\n POSTGRES_PASSWORD: postgres\n POSTGRES_DB: {{DB_NAME}}\n ports:\n - \"8005:5432\"\n volumes:\n - pgdata:/var/lib/postgresql/data`;\n\nconst MINIO_SERVICE = ` minio:\n image: minio/minio\n command: server /data --console-address \":9001\"\n environment:\n MINIO_ROOT_USER: minioadmin\n MINIO_ROOT_PASSWORD: minioadmin\n ports:\n - \"8006:9000\"\n - \"8007:9001\"\n volumes:\n - miniodata:/data`;\n\nconst MINIO_ENV = `\nSTORAGE_ENDPOINT=localhost\nSTORAGE_PORT=8006\nSTORAGE_USE_SSL=false\nSTORAGE_ACCESS_KEY=minioadmin\nSTORAGE_SECRET_KEY=minioadmin\nSTORAGE_REGION=us-east-1\nSTORAGE_BUCKET={{DB_NAME}}\n`;\n\nfunction s3Env(s: Extract<Answers[\"storage\"], { mode: \"s3\" }>): string {\n return `\nSTORAGE_ENDPOINT=${s.endPoint}\nSTORAGE_PORT=${s.port}\nSTORAGE_USE_SSL=${s.useSSL}\nSTORAGE_ACCESS_KEY=${s.accessKey}\nSTORAGE_SECRET_KEY=${s.secretKey}\nSTORAGE_REGION=${s.region}\nSTORAGE_BUCKET=${s.bucket}\n`;\n}\n\n/** Same keys as s3Env, blanked — s3 credentials are real secrets and must never land in .env.example. */\nconst S3_ENV_EXAMPLE = `\nSTORAGE_ENDPOINT=\nSTORAGE_PORT=\nSTORAGE_USE_SSL=\nSTORAGE_ACCESS_KEY=\nSTORAGE_SECRET_KEY=\nSTORAGE_REGION=\nSTORAGE_BUCKET=\n`;\n\nexport async function scaffold(answers: Answers, targetDir: string): Promise<string[]> {\n try {\n const existing = await readdir(targetDir);\n if (existing.length > 0) {\n throw new Error(`target directory ${targetDir} is not empty`);\n }\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"ENOENT\") {\n throw err;\n }\n }\n\n const written: string[] = [];\n try {\n const name = dbName(answers.name);\n const databaseUrl =\n answers.db.mode === \"docker\"\n ? `postgres://postgres:postgres@localhost:8005/${name}`\n : answers.db.url;\n const storageEnv =\n answers.storage.mode === \"minio\"\n ? render(MINIO_ENV, { DB_NAME: name })\n : answers.storage.mode === \"s3\"\n ? s3Env(answers.storage)\n : \"\";\n\n const vars: Record<string, string> = {\n NAME: answers.name,\n DB_NAME: name,\n PORT: String(answers.port),\n CLIENT_ORIGINS: answers.clientOrigins,\n DATABASE_URL: databaseUrl,\n BETTER_AUTH_SECRET: randomBytes(32).toString(\"base64\"),\n CREDENTIAL_STORE_KEY: randomBytes(32).toString(\"base64\"),\n STORAGE_ENV: storageEnv,\n };\n\n await mkdir(join(targetDir, \"src\", \"plugins\"), { recursive: true });\n\n const write = async (rel: string, content: string) => {\n await writeFile(join(targetDir, rel), content);\n written.push(rel);\n };\n const fromTemplate = async (tmpl: string, rel: string, extra?: Record<string, string>) =>\n write(rel, render(await readFile(join(TEMPLATES, tmpl), \"utf8\"), { ...vars, ...extra }));\n\n await fromTemplate(\"package.json.tmpl\", \"package.json\");\n await fromTemplate(\"README.md\", \"README.md\");\n await fromTemplate(\"env.tmpl\", \".env\");\n await fromTemplate(\"env.tmpl\", \".env.example\", {\n BETTER_AUTH_SECRET: \"\",\n CREDENTIAL_STORE_KEY: \"\",\n DATABASE_URL: answers.db.mode === \"docker\" ? databaseUrl : \"\",\n ...(answers.storage.mode === \"s3\" ? { STORAGE_ENV: S3_ENV_EXAMPLE } : {}),\n });\n await cp(join(TEMPLATES, \"tsconfig.json\"), join(targetDir, \"tsconfig.json\"));\n await cp(join(TEMPLATES, \"tsdown.config.ts\"), join(targetDir, \"tsdown.config.ts\"));\n await cp(join(TEMPLATES, \"gitignore\"), join(targetDir, \".gitignore\"));\n await cp(join(TEMPLATES, \"src/main.ts\"), join(targetDir, \"src/main.ts\"));\n await cp(join(TEMPLATES, \"src/config.ts\"), join(targetDir, \"src/config.ts\"));\n await cp(join(TEMPLATES, \"src/plugins/README.md\"), join(targetDir, \"src/plugins/README.md\"));\n written.push(\n \"tsconfig.json\",\n \"tsdown.config.ts\",\n \".gitignore\",\n \"src/main.ts\",\n \"src/config.ts\",\n \"src/plugins/README.md\",\n );\n\n const services: string[] = [];\n const volumes: string[] = [];\n if (answers.db.mode === \"docker\") {\n services.push(render(POSTGRES_SERVICE, { DB_NAME: name }));\n volumes.push(\" pgdata:\");\n }\n if (answers.storage.mode === \"minio\") {\n services.push(MINIO_SERVICE);\n volumes.push(\" miniodata:\");\n }\n if (services.length > 0) {\n const compose = `name: ${answers.name}\\nservices:\\n${services.join(\"\\n\")}\\nvolumes:\\n${volumes.join(\"\\n\")}\\n`;\n await write(\"docker-compose.yml\", compose);\n }\n\n return written;\n } catch (err) {\n // Leave no half-written project behind.\n await rm(targetDir, { recursive: true, force: true });\n throw err;\n }\n}\n","#!/usr/bin/env node\n// npm create headless-lms → this. Walks the prompts (or --yes for defaults),\n// scaffolds the installation, then offers install + migrate.\nimport { spawnSync } from \"node:child_process\";\nimport { resolve } from \"node:path\";\nimport * as p from \"@clack/prompts\";\nimport { parseArgs, validateName } from \"./args.js\";\nimport { defaultAnswers, type Answers, type StorageAnswer } from \"./answers.js\";\nimport { scaffold } from \"./scaffold.js\";\n\nconst args = parseArgs(process.argv.slice(2));\n\nfunction bail(message = \"Cancelled.\"): never {\n p.cancel(message);\n process.exit(1);\n}\n/** Unwrap a clack result, exiting cleanly on ctrl-c. */\nfunction got<T>(value: T | symbol): T {\n if (p.isCancel(value)) {\n bail();\n }\n return value as T;\n}\n\np.intro(\"create-headless-lms\");\n\n/** Reject non-integer or out-of-range (1-65535) port input; undefined/empty passes through (Enter keeps the default). */\nfunction validatePort(v: string | undefined): string | undefined {\n if (!v) {\n return undefined;\n }\n if (!/^\\d+$/.test(v)) {\n return \"Port must be a whole number.\";\n }\n const n = Number(v);\n if (n < 1 || n > 65535) {\n return \"Port must be between 1 and 65535.\";\n }\n return undefined;\n}\n\nlet name = args.name;\nif (!name) {\n if (args.yes) {\n bail(\"--yes needs a project name: create-headless-lms <name> --yes\");\n }\n name = got(\n await p.text({\n message: \"Project name\",\n placeholder: \"my-lms\",\n validate: (v) => validateName(v ?? \"\"),\n }),\n );\n}\nconst nameError = validateName(name);\nif (nameError) {\n bail(nameError);\n}\n\nlet answers: Answers = defaultAnswers(name);\n\nif (!args.yes) {\n const dbMode = got(\n await p.select({\n message: \"Database\",\n options: [\n { value: \"docker\", label: \"Postgres via docker-compose (recommended)\" },\n { value: \"url\", label: \"I have a connection string\" },\n ],\n }),\n );\n const db =\n dbMode === \"url\"\n ? {\n mode: \"url\" as const,\n url: got(\n await p.text({\n message: \"DATABASE_URL\",\n placeholder: \"postgres://user:pass@host:5432/db\",\n }),\n ),\n }\n : { mode: \"docker\" as const };\n\n const storageMode = got(\n await p.select({\n message: \"File storage\",\n options: [\n { value: \"minio\", label: \"Bundled MinIO via docker-compose (recommended)\" },\n { value: \"s3\", label: \"Existing S3-compatible storage\" },\n { value: \"skip\", label: \"Skip for now\" },\n ],\n }),\n );\n let storage: StorageAnswer = { mode: \"minio\" };\n if (storageMode === \"skip\") {\n storage = { mode: \"skip\" };\n }\n if (storageMode === \"s3\") {\n storage = {\n mode: \"s3\",\n endPoint: got(await p.text({ message: \"Endpoint host\", placeholder: \"s3.amazonaws.com\" })),\n port: Number(\n got(await p.text({ message: \"Port\", initialValue: \"443\", validate: validatePort })),\n ),\n useSSL: got(await p.confirm({ message: \"Use SSL?\", initialValue: true })),\n accessKey: got(await p.text({ message: \"Access key\" })),\n secretKey: got(await p.text({ message: \"Secret key\" })),\n region: got(await p.text({ message: \"Region\", initialValue: \"us-east-1\" })),\n bucket: got(await p.text({ message: \"Bucket\", initialValue: name })),\n };\n }\n\n const port = Number(\n got(await p.text({ message: \"API port\", initialValue: \"8000\", validate: validatePort })),\n );\n const clientOrigins = got(\n await p.text({\n message: \"Client origins (comma-separated, for CORS + auth)\",\n initialValue: \"http://localhost:8001,http://localhost:8002\",\n }),\n );\n\n answers = { name, db, storage, port, clientOrigins };\n}\n\nconst targetDir = resolve(process.cwd(), name);\nconst s = p.spinner();\ns.start(`Scaffolding ${name}`);\ntry {\n await scaffold(answers, targetDir);\n s.stop(`Created ${name}/`);\n} catch (err) {\n s.stop(\"Scaffold failed\");\n bail(err instanceof Error ? err.message : String(err));\n}\n\nconst run = (cmd: string, argv: string[]) =>\n spawnSync(cmd, argv, { cwd: targetDir, stdio: \"inherit\" });\n\nif (!args.yes && got(await p.confirm({ message: \"Run pnpm install now?\", initialValue: true }))) {\n run(\"pnpm\", [\"install\"]);\n if (\n got(\n await p.confirm({\n message: \"Run migrations now? (database must be reachable)\",\n initialValue: false,\n }),\n )\n ) {\n run(\"pnpm\", [\"migrate\"]);\n }\n}\n\np.note(\n [\n `cd ${name}`,\n ...(answers.db.mode === \"docker\" || answers.storage.mode === \"minio\"\n ? [\"docker compose up -d\"]\n : []),\n \"pnpm install # if you skipped it\",\n \"pnpm migrate\",\n \"pnpm dev\",\n ].join(\"\\n\"),\n \"Next steps\",\n);\np.outro(`API will listen on http://localhost:${answers.port} — docs at /docs`);\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-headless-lms",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"create-headless-lms": "dist/index.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"templates"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsdown",
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@clack/prompts": "^0.11.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.10.2",
|
|
22
|
+
"tsdown": "^0.22.9",
|
|
23
|
+
"typescript": "^5.7.2",
|
|
24
|
+
"vitest": "^2.1.8"
|
|
25
|
+
},
|
|
26
|
+
"description": "Scaffold a new headless LMS project",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/mdwt/headless-lms.git",
|
|
31
|
+
"directory": "packages/create-headless-lms"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/mdwt/headless-lms#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/mdwt/headless-lms/issues"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# {{NAME}}
|
|
2
|
+
|
|
3
|
+
A Headless LMS installation.
|
|
4
|
+
|
|
5
|
+
## Run
|
|
6
|
+
|
|
7
|
+
docker compose up -d # if you chose the bundled Postgres/MinIO
|
|
8
|
+
pnpm install
|
|
9
|
+
pnpm migrate
|
|
10
|
+
pnpm dev # API on http://localhost:{{PORT}}, docs at /docs
|
|
11
|
+
|
|
12
|
+
## Layout
|
|
13
|
+
|
|
14
|
+
- `src/main.ts` — entry point: config → container → server
|
|
15
|
+
- `src/config.ts` — reads .env into the ServerConfig
|
|
16
|
+
- `src/plugins/` — integrations (see its README)
|
|
17
|
+
- `.env` — secrets and runtime config (never commit)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
PORT={{PORT}}
|
|
2
|
+
BETTER_AUTH_URL=http://localhost:{{PORT}}
|
|
3
|
+
CLIENT_ORIGIN={{CLIENT_ORIGINS}}
|
|
4
|
+
STUDENT_PORTAL_URL=http://localhost:8002
|
|
5
|
+
ADMIN_APP_URL=http://localhost:8001
|
|
6
|
+
|
|
7
|
+
DATABASE_URL={{DATABASE_URL}}
|
|
8
|
+
|
|
9
|
+
BETTER_AUTH_SECRET={{BETTER_AUTH_SECRET}}
|
|
10
|
+
CREDENTIAL_STORE_KEY={{CREDENTIAL_STORE_KEY}}
|
|
11
|
+
{{STORAGE_ENV}}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{NAME}}",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/main.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"dev": "tsx watch --env-file=.env src/main.ts",
|
|
9
|
+
"build": "tsdown",
|
|
10
|
+
"start": "node --env-file=.env dist/main.js",
|
|
11
|
+
"migrate": "headless-lms migrate",
|
|
12
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@headless-lms/adapter-storage-minio": "^0.0.0",
|
|
16
|
+
"@headless-lms/cli": "^0.0.0",
|
|
17
|
+
"@headless-lms/core": "^0.0.0",
|
|
18
|
+
"@headless-lms/server": "^0.0.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.10.2",
|
|
22
|
+
"tsdown": "^0.22.9",
|
|
23
|
+
"tsx": "^4.19.2",
|
|
24
|
+
"typescript": "^5.7.2"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Reads all runtime configuration from the environment and builds the
|
|
2
|
+
// ServerConfig that @headless-lms/server consumes. The only file that
|
|
3
|
+
// touches process.env.
|
|
4
|
+
import type { ServerConfig, ContainerConfig } from "@headless-lms/server";
|
|
5
|
+
import type { MinioStorageConfig } from "@headless-lms/adapter-storage-minio";
|
|
6
|
+
|
|
7
|
+
/** STORAGE_ENDPOINT unset → no storage adapter; asset operations fail loudly. */
|
|
8
|
+
export function loadStorageConfig(): MinioStorageConfig | undefined {
|
|
9
|
+
const endPoint = process.env.STORAGE_ENDPOINT;
|
|
10
|
+
if (!endPoint) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
endPoint,
|
|
15
|
+
port: Number(process.env.STORAGE_PORT ?? 9000),
|
|
16
|
+
useSSL: (process.env.STORAGE_USE_SSL ?? "false") === "true",
|
|
17
|
+
accessKey: process.env.STORAGE_ACCESS_KEY ?? "",
|
|
18
|
+
secretKey: process.env.STORAGE_SECRET_KEY ?? "",
|
|
19
|
+
region: process.env.STORAGE_REGION ?? "us-east-1",
|
|
20
|
+
bucket: process.env.STORAGE_BUCKET ?? "headless-lms",
|
|
21
|
+
uploadExpirySeconds: Number(process.env.STORAGE_UPLOAD_EXPIRY ?? 300),
|
|
22
|
+
downloadExpirySeconds: Number(process.env.STORAGE_DOWNLOAD_EXPIRY ?? 300),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Browser app origins from CLIENT_ORIGIN (comma-separated). */
|
|
27
|
+
export function parseClientOrigins(): string[] {
|
|
28
|
+
return (process.env.CLIENT_ORIGIN ?? "http://localhost:8001,http://localhost:8002")
|
|
29
|
+
.split(",")
|
|
30
|
+
.map((o) => o.trim())
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function loadContainerConfig(): ContainerConfig {
|
|
35
|
+
const clientOrigins = parseClientOrigins();
|
|
36
|
+
const apiOrigin = process.env.BETTER_AUTH_URL ?? "http://localhost:8000";
|
|
37
|
+
const trustedOrigins = [...new Set([...clientOrigins, apiOrigin])];
|
|
38
|
+
return {
|
|
39
|
+
databaseUrl: process.env.DATABASE_URL ?? "",
|
|
40
|
+
authBaseURL: apiOrigin,
|
|
41
|
+
authSecret: process.env.BETTER_AUTH_SECRET ?? "",
|
|
42
|
+
trustedOrigins,
|
|
43
|
+
credentialStoreKey: process.env.CREDENTIAL_STORE_KEY ?? "",
|
|
44
|
+
cookieDomain: process.env.AUTH_COOKIE_DOMAIN || undefined,
|
|
45
|
+
secureCookies: process.env.NODE_ENV === "production",
|
|
46
|
+
studentPortalUrl: process.env.STUDENT_PORTAL_URL ?? "http://localhost:8002",
|
|
47
|
+
adminAppUrl: process.env.ADMIN_APP_URL ?? "http://localhost:8001",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function loadServerConfig(): ServerConfig {
|
|
52
|
+
const container = loadContainerConfig();
|
|
53
|
+
return {
|
|
54
|
+
port: Number(process.env.PORT ?? 8000),
|
|
55
|
+
host: process.env.HOST ?? "0.0.0.0",
|
|
56
|
+
// BETTER_AUTH_URL is the API's own origin; reuse it so the public URL
|
|
57
|
+
// can never drift from what better-auth is configured with.
|
|
58
|
+
publicUrl: container.authBaseURL,
|
|
59
|
+
clientOrigins: parseClientOrigins(),
|
|
60
|
+
// Separate from STORAGE_DOWNLOAD_EXPIRY: raising the general storage
|
|
61
|
+
// default must never silently extend entitled/paywalled link lifetimes.
|
|
62
|
+
deliveryExpirySeconds: Number(process.env.DELIVERY_URL_EXPIRY ?? 300),
|
|
63
|
+
container,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Process entry point: env → config → container → server → listen.
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { createContainer, buildServer } from "@headless-lms/server";
|
|
4
|
+
import { MinioStorageAdapter } from "@headless-lms/adapter-storage-minio";
|
|
5
|
+
import { loadServerConfig, loadStorageConfig } from "./config.js";
|
|
6
|
+
|
|
7
|
+
const config = loadServerConfig();
|
|
8
|
+
const storageConfig = loadStorageConfig();
|
|
9
|
+
const container = await createContainer(config, {
|
|
10
|
+
// One folder per integration (directory name = integration id). Compiled
|
|
11
|
+
// to dist/plugins/ by the standard build, so this resolves in dev and prod.
|
|
12
|
+
pluginsDir: fileURLToPath(new URL("./plugins/", import.meta.url)),
|
|
13
|
+
adapters: {
|
|
14
|
+
storage: storageConfig && new MinioStorageAdapter(storageConfig),
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
const app = await buildServer(config, container);
|
|
18
|
+
|
|
19
|
+
app.listen({ port: config.port, host: config.host }).catch((err) => {
|
|
20
|
+
app.log.error(err);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# plugins/
|
|
2
|
+
|
|
3
|
+
One folder per integration; the **directory name is the integration id**. Each
|
|
4
|
+
folder's `index.ts` default-exports an object satisfying the `Integration`
|
|
5
|
+
contract from `@headless-lms/core/types`. Loaded once at startup — a malformed
|
|
6
|
+
plugin fails the boot, not a request.
|
|
7
|
+
|
|
8
|
+
Two ways to add one:
|
|
9
|
+
|
|
10
|
+
1. **Published integration** — install it and re-export:
|
|
11
|
+
`pnpm add @headless-lms/plugin-slack`, then `plugins/slack/index.ts`:
|
|
12
|
+
`export { default } from "@headless-lms/plugin-slack";`
|
|
13
|
+
2. **Custom integration** — write the folder directly, depending only on
|
|
14
|
+
`@headless-lms/core/types` (+ `@headless-lms/core/integrations` for the zod
|
|
15
|
+
helpers).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"verbatimModuleSyntax": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"types": ["node"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig } from "tsdown";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
// Transpile-only: mirror the src/ tree into dist/ file-for-file rather than
|
|
5
|
+
// bundling into one file. Keeps the hexagonal layout intact and preserves
|
|
6
|
+
// dist/main.js as the process entry (main field + start script).
|
|
7
|
+
unbundle: true,
|
|
8
|
+
entry: ["src/**/*.ts", "!src/**/*.test.ts"],
|
|
9
|
+
outDir: "dist",
|
|
10
|
+
format: ["esm"],
|
|
11
|
+
fixedExtension: false,
|
|
12
|
+
target: "node22",
|
|
13
|
+
platform: "node",
|
|
14
|
+
sourcemap: true,
|
|
15
|
+
clean: true,
|
|
16
|
+
// Type-checking is the job of `pnpm typecheck` (tsc --noEmit); tsdown only emits.
|
|
17
|
+
dts: false,
|
|
18
|
+
});
|